1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Custom roles area state: the admin-defined agent records — the list, the
//! create/edit window's state and the pending deletion. The Settings page
//! renders it (see [`super::settings`]).
//!
//! The list only ever changes through the admin's own actions, so it is read on
//! navigation and after every mutation instead of being polled.
use crate::custom_role::{self, CustomRole, CustomRoleBehavior};
use iced::Task;
use super::editor_widget::{EditorAction, EditorBuffer};
#[derive(Debug, Clone)]
pub(crate) enum CustomRolesMessage {
/// Read the list from the store.
Refresh,
/// A read finished. `Err` keeps the last loaded list on screen.
Refreshed(Result<Vec<CustomRole>, String>),
/// Open the window on an empty record.
OpenCreate,
/// Open the window on the named record.
OpenEdit(String),
/// Close the create/edit window.
CloseEditor,
/// A shared-editor action on the name field.
Name(EditorAction),
/// A shared-editor action on the prompt editor.
Prompt(EditorAction),
/// Pick what the role does when it is launched without a branch identifier.
SetBehavior(CustomRoleBehavior),
/// Mark or unmark one tool of the catalogue.
ToggleTool(String),
/// Mark or unmark one KIND of a catalog tool that has a kind vocabulary.
ToggleToolKind { tool: String, kind: String },
/// Submit the open window: create the record, or save the edited one.
SubmitEditor,
/// Result of a submit. An error keeps the window open with it.
SubmitResult(Result<(), String>),
/// Open the delete confirmation for the named record.
DeleteRequest(String),
/// Delete the record the confirmation is open for.
ConfirmDelete,
/// Close the delete confirmation without deleting.
DismissDelete,
/// Result of a delete. An error is shown by the section's banner.
DeleteResult(Result<(), String>),
}
pub(crate) struct CustomRolesState {
pub(crate) roles: Vec<CustomRole>,
pub(crate) loaded: bool,
pub(crate) error: Option<String>,
/// The open create/edit window; `None` when closed.
pub(crate) editor: Option<RoleEditor>,
/// The role name a delete confirmation is open for; `None` when closed.
pub(crate) delete_target: Option<String>,
}
pub(crate) struct RoleEditor {
/// `Some(name)` when editing an existing record (the name then cannot be
/// changed), `None` when creating.
pub(crate) original: Option<String>,
pub(crate) name: EditorBuffer,
pub(crate) prompt: EditorBuffer,
pub(crate) behavior: CustomRoleBehavior,
/// The chosen tool names (the picker's marks).
pub(crate) tools: Vec<String>,
/// The tool catalogue the picker offers, captured when the window opened —
/// it constructs the tool objects and reads CONFIG, so it must not be
/// rebuilt on every render pass.
pub(crate) catalog: Vec<&'static str>,
pub(crate) in_flight: bool,
pub(crate) error: Option<String>,
}
impl RoleEditor {
/// A window opened on the given record (or on an empty one for a create).
fn new(
original: Option<String>,
name: &str,
prompt: &str,
behavior: CustomRoleBehavior,
tools: Vec<String>,
) -> Self {
// A role name is one line: newlines never enter the field.
let name_buffer = EditorBuffer::with_text(name, None);
name_buffer.set_single_line(true);
let catalog = crate::Role::custom_role_tool_catalog();
// The stored names are kept as they are: a name this installation cannot
// serve right now stays marked — the launch omits it and the record keeps
// it, so a mere open-and-save never drops it. The picker renders it too,
// marked and labelled unavailable, so it can still be removed deliberately.
Self {
original,
name: name_buffer,
prompt: EditorBuffer::with_text(prompt, None),
behavior,
tools,
catalog,
in_flight: false,
error: None,
}
}
/// The kinds of `tool` the record currently holds, in interface order. A
/// bare entry holds the tool's narrowest kind, so the picker marks what the
/// role can actually call. The read is the same lenient one the launch path
/// uses ([`crate::tools::grants::parse_lossy`]): one unparsable entry drops
/// itself alone instead of unmarking every kind.
pub(crate) fn held_kinds(&self, tool: &str) -> Vec<String> {
crate::tools::grants::parse_lossy(&self.tools)
.into_iter()
.find(|g| g.tool == tool)
.map(|grant| crate::tools::grants::held_kinds(&grant))
.unwrap_or_default()
}
/// Mark or unmark one kind of a tool with a kind vocabulary — a thin call to
/// [`crate::tools::grants::toggle`], which owns the grammar, the interface
/// order and the drop of an entry with its last kind.
fn toggle_kind(&mut self, tool: &str, kind: &str) {
self.tools = crate::tools::grants::toggle(&self.tools, tool, kind);
}
}
impl CustomRolesState {
pub(crate) const fn new() -> Self {
Self {
roles: Vec::new(),
loaded: false,
error: None,
editor: None,
delete_target: None,
}
}
#[allow(clippy::unused_self)]
pub(crate) fn refresh(&self) -> Task<CustomRolesMessage> {
Task::perform(custom_role::list(), |res| {
CustomRolesMessage::Refreshed(res.map_err(|e| e.to_string()))
})
}
/// Dismiss the open window and the delete confirmation (Escape).
pub(crate) fn close_dialogs(&mut self) {
self.editor = None;
self.delete_target = None;
}
#[expect(clippy::too_many_lines)]
pub(crate) fn update(&mut self, msg: CustomRolesMessage) -> Task<CustomRolesMessage> {
match msg {
CustomRolesMessage::Refresh => self.refresh(),
CustomRolesMessage::Refreshed(Ok(roles)) => {
self.roles = roles;
self.loaded = true;
// A successful read replaces the previous failure.
self.error = None;
Task::none()
}
CustomRolesMessage::Refreshed(Err(e)) => {
self.error = Some(e);
Task::none()
}
CustomRolesMessage::OpenCreate => {
self.editor = Some(RoleEditor::new(
None,
"",
"",
CustomRoleBehavior::Continuous,
Vec::new(),
));
Task::none()
}
CustomRolesMessage::OpenEdit(name) => {
// The list is the source the window is filled from: a name it
// no longer holds opens nothing.
if let Some(role) = self.roles.iter().find(|r| r.name == name) {
self.editor = Some(RoleEditor::new(
Some(role.name.clone()),
&role.name,
&role.prompt,
role.behavior,
role.tools.clone(),
));
}
Task::none()
}
CustomRolesMessage::CloseEditor => {
self.editor = None;
Task::none()
}
CustomRolesMessage::Name(action) => {
if let Some(editor) = &self.editor {
// The window's buffers are their own state (it is
// discarded on close), so an action applies directly.
editor.name.perform_action(action);
}
Task::none()
}
CustomRolesMessage::Prompt(action) => {
if let Some(editor) = &self.editor {
editor.prompt.perform_action(action);
}
Task::none()
}
CustomRolesMessage::SetBehavior(behavior) => {
if let Some(editor) = &mut self.editor {
editor.behavior = behavior;
}
Task::none()
}
CustomRolesMessage::ToggleTool(name) => {
if let Some(editor) = &mut self.editor {
// An entry is identified by its tool name: a stored entry
// this installation cannot serve may still carry kinds, and
// unticking it must remove it rather than add a second entry.
match editor
.tools
.iter()
.position(|entry| crate::tools::grants::tool_name(entry) == name.as_str())
{
Some(at) => {
editor.tools.remove(at);
}
None => editor.tools.push(name),
}
}
Task::none()
}
CustomRolesMessage::ToggleToolKind { tool, kind } => {
if let Some(editor) = &mut self.editor {
editor.toggle_kind(&tool, &kind);
}
Task::none()
}
CustomRolesMessage::SubmitEditor => {
let Some(editor) = &mut self.editor else {
return Task::none();
};
// A double submit never goes out.
if editor.in_flight {
return Task::none();
}
// Nothing is validated here: the record's own validators are
// the authority, and their errors land in the window.
let name = editor.name.text().trim().to_string();
let prompt = editor.prompt.text();
let tools = editor.tools.clone();
let behavior = editor.behavior;
// An edit addresses the record by the name it was opened on —
// the window edits a role, it never renames one.
let original = editor.original.clone();
editor.in_flight = true;
Task::perform(
async move {
let saved = match &original {
Some(target) => {
custom_role::update(
target,
Some(&prompt),
Some(&tools),
Some(behavior),
)
.await
}
None => custom_role::create(&name, &prompt, &tools, behavior).await,
};
saved.map_err(|e| e.to_string())
},
CustomRolesMessage::SubmitResult,
)
}
CustomRolesMessage::SubmitResult(Ok(())) => {
self.editor = None;
self.error = None;
// The list the window was opened from is re-read, so the saved
// record shows what the store now holds.
Task::done(CustomRolesMessage::Refresh)
}
CustomRolesMessage::SubmitResult(Err(e)) => {
// The window stays open on what it holds, with the reason.
if let Some(editor) = &mut self.editor {
editor.in_flight = false;
editor.error = Some(e);
}
Task::none()
}
CustomRolesMessage::DeleteRequest(name) => {
self.delete_target = Some(name);
Task::none()
}
CustomRolesMessage::ConfirmDelete => {
let Some(name) = self.delete_target.take() else {
return Task::none();
};
Task::perform(
async move { custom_role::delete(&name).await.map_err(|e| e.to_string()) },
CustomRolesMessage::DeleteResult,
)
}
CustomRolesMessage::DismissDelete => {
self.delete_target = None;
Task::none()
}
CustomRolesMessage::DeleteResult(Ok(())) => {
self.error = None;
// The card leaves through the re-read.
Task::done(CustomRolesMessage::Refresh)
}
CustomRolesMessage::DeleteResult(Err(e)) => {
// The record is still there — the banner says why it stayed.
self.error = Some(e);
Task::none()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn role(name: &str) -> CustomRole {
CustomRole {
name: name.to_string(),
prompt: format!("prompt of {name}"),
tools: vec!["web_search".to_string()],
behavior: CustomRoleBehavior::OneOff,
}
}
fn loaded_state() -> CustomRolesState {
let mut state = CustomRolesState::new();
let _ = state.update(CustomRolesMessage::Refreshed(Ok(vec![role("analyst")])));
state
}
/// A state loaded with one role whose tool marks are `tools`.
fn state_with_tools(name: &str, tools: &[&str]) -> CustomRolesState {
let mut state = CustomRolesState::new();
let mut role = role(name);
role.tools = tools.iter().map(|s| (*s).to_string()).collect();
let _ = state.update(CustomRolesMessage::Refreshed(Ok(vec![role])));
state
}
/// The kind picker reads and writes the stored grant grammar: a bare
/// `analyze` shows the kind it implies, a kind toggles on and off, the entry
/// drops when its last kind goes, and a plain `ToggleTool` on the bare entry
/// still behaves as it always has.
#[test]
fn kind_marks_track_the_stored_entries() {
let mut state = state_with_tools("analyst", &["analyze", "web_search"]);
let _ = state.update(CustomRolesMessage::OpenEdit("analyst".to_string()));
let editor = state.editor.as_ref().expect("the window is open");
assert_eq!(editor.held_kinds("analyze"), vec!["quick".to_string()]);
assert!(
editor.held_kinds("web_search").is_empty(),
"a kindless tool holds no kinds"
);
assert!(
editor.held_kinds("read").is_empty(),
"an unheld tool holds no kinds"
);
// Marking the second kind spells the entry out.
let _ = state.update(CustomRolesMessage::ToggleToolKind {
tool: "analyze".to_string(),
kind: "deep".to_string(),
});
assert_eq!(
state.editor.as_ref().unwrap().tools,
vec!["analyze:quick,deep".to_string(), "web_search".to_string()]
);
// Unmarking the implied kind leaves the one still held.
let _ = state.update(CustomRolesMessage::ToggleToolKind {
tool: "analyze".to_string(),
kind: "quick".to_string(),
});
assert_eq!(
state.editor.as_ref().unwrap().tools,
vec!["analyze:deep".to_string(), "web_search".to_string()]
);
assert_eq!(
state.editor.as_ref().unwrap().held_kinds("analyze"),
vec!["deep".to_string()]
);
// The last kind leaving drops the entry from the record.
let _ = state.update(CustomRolesMessage::ToggleToolKind {
tool: "analyze".to_string(),
kind: "deep".to_string(),
});
assert_eq!(
state.editor.as_ref().unwrap().tools,
vec!["web_search".to_string()]
);
// `ToggleTool` on the (now absent) bare entry behaves as today: it adds
// the name plainly, and the implied kind shows again.
let _ = state.update(CustomRolesMessage::ToggleTool("analyze".to_string()));
assert_eq!(
state.editor.as_ref().unwrap().tools,
vec!["web_search".to_string(), "analyze".to_string()]
);
assert_eq!(
state.editor.as_ref().unwrap().held_kinds("analyze"),
vec!["quick".to_string()]
);
let _ = state.update(CustomRolesMessage::ToggleTool("analyze".to_string()));
assert_eq!(
state.editor.as_ref().unwrap().tools,
vec!["web_search".to_string()]
);
}
/// The window is filled from the record the list holds — including the
/// behaviour and the marks — and a mark toggles in and out.
#[test]
fn editing_fills_the_window_and_marks_toggle() {
let mut state = loaded_state();
let _ = state.update(CustomRolesMessage::OpenEdit("analyst".to_string()));
let editor = state.editor.as_ref().expect("the window is open");
assert_eq!(editor.original.as_deref(), Some("analyst"));
assert_eq!(editor.name.text(), "analyst");
assert_eq!(editor.prompt.text(), "prompt of analyst");
assert_eq!(editor.behavior, CustomRoleBehavior::OneOff);
assert_eq!(editor.tools, vec!["web_search".to_string()]);
let _ = state.update(CustomRolesMessage::ToggleTool("web_search".to_string()));
let _ = state.update(CustomRolesMessage::ToggleTool("read".to_string()));
assert_eq!(
state.editor.as_ref().expect("the window is open").tools,
vec!["read".to_string()]
);
let _ = state.update(CustomRolesMessage::CloseEditor);
assert!(state.editor.is_none());
}
/// A submit in flight is never doubled, and an error keeps the window open
/// with its own line.
#[test]
fn a_submit_in_flight_is_not_doubled_and_an_error_keeps_the_window() {
let mut state = loaded_state();
let _ = state.update(CustomRolesMessage::OpenCreate);
state.editor.as_mut().expect("the window is open").in_flight = true;
let _ = state.update(CustomRolesMessage::SubmitEditor);
assert!(state.editor.as_ref().expect("the window is open").in_flight);
let _ = state.update(CustomRolesMessage::SubmitResult(Err("nope".to_string())));
let editor = state.editor.as_ref().expect("the window stays open");
assert!(!editor.in_flight);
assert_eq!(editor.error.as_deref(), Some("nope"));
}
}