@alisio/alisio-code 0.1.0-alpha.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +95 -16
  2. package/dist/analysis.d.ts +27 -0
  3. package/dist/analysis.js +145 -0
  4. package/dist/banner.js +2 -1
  5. package/dist/builtin.d.ts +3 -1
  6. package/dist/builtin.js +0 -24
  7. package/dist/main.js +133 -6
  8. package/dist/serve.d.ts +15 -0
  9. package/dist/serve.js +99 -0
  10. package/dist/tui/agent-manager.d.ts +105 -0
  11. package/dist/tui/agent-manager.js +269 -0
  12. package/dist/tui/agents.d.ts +5 -0
  13. package/dist/tui/agents.js +5 -0
  14. package/dist/tui/app.d.ts +42 -1
  15. package/dist/tui/app.js +1895 -97
  16. package/dist/tui/artifact-preview.d.ts +52 -0
  17. package/dist/tui/artifact-preview.js +212 -0
  18. package/dist/tui/artifacts.d.ts +54 -0
  19. package/dist/tui/artifacts.js +207 -0
  20. package/dist/tui/btw.d.ts +57 -0
  21. package/dist/tui/btw.js +76 -0
  22. package/dist/tui/components.d.ts +174 -12
  23. package/dist/tui/components.js +962 -66
  24. package/dist/tui/connect-input.d.ts +2 -0
  25. package/dist/tui/connect-input.js +1 -1
  26. package/dist/tui/exit.d.ts +10 -0
  27. package/dist/tui/exit.js +15 -0
  28. package/dist/tui/git-branch.d.ts +48 -0
  29. package/dist/tui/git-branch.js +99 -0
  30. package/dist/tui/goal.d.ts +75 -0
  31. package/dist/tui/goal.js +171 -0
  32. package/dist/tui/modes.d.ts +71 -0
  33. package/dist/tui/modes.js +90 -0
  34. package/dist/tui/open-path.d.ts +38 -0
  35. package/dist/tui/open-path.js +80 -0
  36. package/dist/tui/plan-review.d.ts +48 -0
  37. package/dist/tui/plan-review.js +76 -0
  38. package/dist/tui/queue.d.ts +2 -0
  39. package/dist/tui/queue.js +5 -1
  40. package/dist/tui/scroll.d.ts +8 -0
  41. package/dist/tui/scroll.js +12 -0
  42. package/dist/tui/settings-menu.d.ts +40 -0
  43. package/dist/tui/settings-menu.js +132 -0
  44. package/dist/tui/settings.d.ts +194 -0
  45. package/dist/tui/settings.js +482 -0
  46. package/dist/tui/state.d.ts +343 -7
  47. package/dist/tui/state.js +823 -54
  48. package/dist/tui/tasks.d.ts +123 -0
  49. package/dist/tui/tasks.js +369 -0
  50. package/dist/tui/theme.d.ts +42 -1
  51. package/dist/tui/theme.js +516 -1
  52. package/dist/tui/viewer-state.d.ts +7 -0
  53. package/dist/tui/viewer-state.js +36 -0
  54. package/dist/version.d.ts +7 -5
  55. package/dist/version.js +44 -8
  56. package/package.json +7 -9
package/dist/tui/app.js CHANGED
@@ -1,46 +1,127 @@
1
1
  import { homedir } from "node:os";
2
- import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, getNativeClipboard, Key, matchesKey, ProcessTerminal, ScrollView, SelectList, TuiAltScreen, truncateToWidth, VStack, } from "@earendil-works/pi-tui";
2
+ import { join } from "node:path";
3
+ import { AGENT_TEMPLATES, agentAuthoringGuidance, agentCommandDescriptors, agentIdFromCommand, agentModelCapabilities, agentModelLabels, agentModelOptions, changelogNews, describePermissionStatus, formatChangelogMarkdown, formatReloadReport, generateAgentDraft, goalOptInTools, goalOutcome, goalWaiting, IMPLEMENTATION_AGENT_ID, isTaskLive, loadChangelog, modeFromFlags, NOT_SANDBOXED, nextAgent, PERMISSION_MODE_TABLE, PERMISSION_USAGE, PLAN_QUESTION_ID, PLAN_TEXT_KEY, parseGoalCommand, parsePermissionCommand, ReloadFailedError, ReloadRefusedError, reloadApplication, selectEntries, settlePlanRun, stateHome, toGrantWire, toRef, validateReloadConfig, } from "@alisio/core";
4
+ import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, getNativeClipboard, Key, MouseRegion, matchesKey, ProcessTerminal, ScrollView, SelectList, TuiAltScreen, truncateToWidth, VStack, } from "@earendil-works/pi-tui";
3
5
  import { loadVersion } from "../version.js";
6
+ import { AGENT_PICKER_ACTIONS, agentEditorRows, agentFieldChoices, agentFieldSupported, agentPickerRows, applyDraft, decodeMultiline, emptyAgentForm, encodeMultiline, filterAgentRows, formErrors, formFromDefinition, formFromTemplate, formToInput, parseAgentsArgs, } from "./agent-manager.js";
7
+ import { activeAgentCatalog, agentRunOptions, mainAgentFromRecord, resolveActiveAgent, } from "./agents.js";
8
+ import { ArtifactPreview, loadPreview } from "./artifact-preview.js";
9
+ import { ARTIFACTS_HINT, artifactActions, artifactRow, capabilityApprovalTitle, detailRows, installApprovalDetail, installApprovalTitle, matchArtifact, permissionRow, } from "./artifacts.js";
4
10
  import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_IMAGE_BYTES, pasteImageFromClipboard, removeLastAttachment, toApiAttachment, } from "./attachments.js";
11
+ import { BTW_DESCRIPTION, BTW_USAGE, btwBrowser, btwPending } from "./btw.js";
5
12
  import { copyText, nodeSpawn } from "./clipboard.js";
6
- import { AttachmentsBar, BannerBlock, clock, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
13
+ import { AttachmentsBar, BannerBlock, BtwPanel, ContentInset, clock, contentInnerWidth, Footer, Header, MarkdownPanel, PlanReviewPanel, QuestionPanel, Switch, setArtifactPathResolver, TranscriptSync, TreePanel, } from "./components.js";
7
14
  import { ConnectInputPrompt } from "./connect-input.js";
15
+ import { bounded, EXIT_PENDING_CAP_MS, EXIT_SESSION_END_CAP_MS } from "./exit.js";
16
+ import { BRANCH_REFRESH_MS, createBranchCache } from "./git-branch.js";
17
+ import { formatGoalStatus, GoalBar, goalPickerItems, goalPickerPrefill, goalStopNotice, planGoalCommand, } from "./goal.js";
18
+ import { agentCycleHint, isPermissionMode, modeDisplay, permissionMenuRows, permissionTransition, reloadGuard, shiftTabDecision, } from "./modes.js";
19
+ import { cannotOpenMessage, openPath, revealPath } from "./open-path.js";
8
20
  import { initialPanelState, reducePanel, visibleRows } from "./panel.js";
21
+ import { planTranscriptMarkdown } from "./plan-review.js";
9
22
  import { summarizeAnswers } from "./questions.js";
10
23
  import { InteractiveQueue } from "./queue.js";
24
+ import { settingsMenuRows } from "./settings.js";
25
+ import { SettingsMenu } from "./settings-menu.js";
11
26
  import { SkillsManager } from "./skills-manager.js";
12
- import { addItem, COMMANDS, configuredProviderModelItems, formatContext, formatDuration, formatTokens, hostOf, initialViewState, itemsFromHistory, lastAssistantText, mcpServerItems, mcpToolItems, parseCommand, pluginCatalogItems, pluginToggleNeedsConfirmation, providerModelItems, reduceEvent, reservedCommandNames, resolveCommand, shortenPath, shortId, summarizeToolArgs, } from "./state.js";
27
+ import { addItem, COMMANDS, configuredProviderModelItems, editorCopyKey, effectiveEffort, effortPickerItems, foldCandidates, foldToggleKey, formatContext, formatDuration, formatTokens, hostOf, initialViewState, isGroupHeader, itemsFromHistory, lastAssistantText, mcpServerItems, mcpToolItems, parseCommand, pluginCatalogItems, pluginToggleNeedsConfirmation, providerModelItems, reduceEvent, reservedCommandNames, resolveCommand, richPartsOf, shortenPath, shortId, skipGroupHeaders, slashCompletionCommands, summarizeToolArgs, validateEffortLevel, visibleGroupedItems, } from "./state.js";
28
+ import { TasksView, wakeDecision } from "./tasks.js";
13
29
  import { editorTheme, selectListTheme, style } from "./theme.js";
30
+ import { readViewerState, writeViewerState } from "./viewer-state.js";
14
31
  const VERSION = loadVersion(import.meta.url);
15
- /** Inline selection list with type-to-filter, rendered above the editor. */
16
- class Picker {
32
+ /**
33
+ * Inline selection list with type-to-filter, rendered above the editor.
34
+ *
35
+ * Grouped item builders (pluginCatalogItems) interleave their output with non-selectable
36
+ * `__group:` header items. Headers stay in the SelectList so they render, scroll and truncate
37
+ * like rows, but selection is owned here: navigation jumps over headers via skipGroupHeaders
38
+ * (wrapping both directions), Enter never fires onSelect for a header, and the whole display
39
+ * sequence is re-derived on every filter keystroke so headers of fully-filtered groups vanish.
40
+ */
41
+ export class Picker {
17
42
  title;
43
+ onSelectItem;
44
+ onCancelPick;
18
45
  filterable;
19
46
  detail;
20
- list;
47
+ match;
48
+ list = new SelectList([], 1, selectListTheme);
21
49
  filter = "";
22
- constructor(title, items, onSelect, onCancel, filterable = false, detail) {
50
+ /** Full item sequence (group headers + rows) before any filtering. */
51
+ allItems;
52
+ /** Display sequence: filtered rows plus the headers of groups that still match. */
53
+ items = [];
54
+ /** Selection index within `items`; always a real row, never a group header. */
55
+ index = 0;
56
+ /** Picker height target: counted on real rows only, so headers never stretch the list. */
57
+ maxVisible;
58
+ constructor(title, items, onSelectItem, onCancelPick, filterable = false, detail,
59
+ /** Custom filter (default: value prefix, keeping group headers). */
60
+ match) {
23
61
  this.title = title;
62
+ this.onSelectItem = onSelectItem;
63
+ this.onCancelPick = onCancelPick;
24
64
  this.filterable = filterable;
25
65
  this.detail = detail;
26
- this.list = new SelectList(items, Math.min(10, Math.max(1, items.length)), selectListTheme);
27
- this.list.onSelect = onSelect;
28
- this.list.onCancel = onCancel;
66
+ this.match = match;
67
+ this.allItems = items;
68
+ this.maxVisible = Math.min(10, Math.max(1, items.filter((item) => !isGroupHeader(item)).length));
69
+ this.resetList();
70
+ }
71
+ /** Copies items, dimming group-header labels (style helpers stay plain under NO_COLOR). */
72
+ static displayItems(items) {
73
+ return items.map((item) => isGroupHeader(item) ? { ...item, label: style.gray(item.label) } : item);
74
+ }
75
+ /** (Re)builds the SelectList over the filtered display sequence; selection starts on a row. */
76
+ resetList() {
77
+ this.items = Picker.displayItems(this.filter
78
+ ? this.match
79
+ ? this.match(this.allItems, this.filter)
80
+ : visibleGroupedItems(this.allItems, this.filter)
81
+ : this.allItems);
82
+ this.list = new SelectList(this.items, this.maxVisible, selectListTheme);
83
+ this.list.onSelect = (item) => {
84
+ if (!isGroupHeader(item))
85
+ this.onSelectItem(item);
86
+ };
87
+ this.list.onCancel = this.onCancelPick;
88
+ this.index = this.items.length ? skipGroupHeaders(this.items, -1, 1) : 0;
89
+ if (this.index < 0)
90
+ this.index = 0;
91
+ this.list.setSelectedIndex(this.index);
29
92
  }
30
93
  invalidate() {
31
94
  this.list.invalidate();
32
95
  }
96
+ /** The currently selected item — always a real row, never a group header. */
97
+ getSelectedItem() {
98
+ return this.items[this.index] ?? null;
99
+ }
33
100
  handleInput(data) {
34
101
  if (this.filterable && matchesKey(data, Key.backspace)) {
35
102
  this.filter = this.filter.slice(0, -1);
36
- this.list.setFilter(this.filter);
103
+ this.resetList();
37
104
  }
38
105
  else if (this.filterable && data.length === 1 && data >= " " && data <= "~") {
39
106
  this.filter += data;
40
- this.list.setFilter(this.filter);
107
+ this.resetList();
41
108
  }
42
- else
109
+ else if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
110
+ const dir = matchesKey(data, Key.down) ? 1 : -1;
111
+ this.index = this.items.length ? skipGroupHeaders(this.items, this.index, dir) : 0;
112
+ this.list.setSelectedIndex(this.index);
113
+ }
114
+ else if (matchesKey(data, Key.enter)) {
115
+ const item = this.items[this.index];
116
+ if (item && !isGroupHeader(item))
117
+ this.onSelectItem(item);
118
+ }
119
+ else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
120
+ this.onCancelPick();
121
+ }
122
+ else {
43
123
  this.list.handleInput(data);
124
+ }
44
125
  }
45
126
  render(width) {
46
127
  const hint = this.filterable
@@ -75,19 +156,38 @@ class Picker {
75
156
  }
76
157
  }
77
158
  export async function runTui(options) {
78
- const { createApplication } = await import("@alisio/core");
159
+ const { createApplication, CommandCatalog } = await import("@alisio/core");
79
160
  const { BUILTIN_PLUGINS } = await import("../builtin.js");
80
161
  const { BUILTIN_PROMPTS } = await import("../prompts/index.js");
81
162
  let dispatch = () => { };
82
163
  let approve = async () => "deny";
83
- const app = await createApplication({
164
+ let approveExternalDirectory = async () => "deny";
165
+ // The options of every application this process builds: the first one and each `/reload`.
166
+ const appOptions = {
84
167
  builtins: BUILTIN_PLUGINS,
85
168
  builtinPrompts: BUILTIN_PROMPTS,
86
169
  reservedPromptNames: reservedCommandNames(),
87
170
  ...options,
88
171
  onEvent: (event) => dispatch(event),
89
172
  approve: (request) => approve(request),
90
- });
173
+ approveExternalDirectory: (request) => approveExternalDirectory(request),
174
+ tasks: {
175
+ // Finished background tasks wake the owner agent through the normal prompt path (bound below).
176
+ wake: (request) => wakeForTasks(request),
177
+ onChange: (task, root) => {
178
+ tasksView?.refresh().catch(() => { });
179
+ onTaskChange(task, root);
180
+ },
181
+ },
182
+ // A goal changed (a user command, a finished run, the model's `update_goal`): redraw its bar.
183
+ goals: { onChange: (sessionId) => onGoalChange(sessionId) },
184
+ };
185
+ let wakeForTasks = () => "busy";
186
+ let onTaskChange = () => { };
187
+ let onGoalChange = () => { };
188
+ let tasksView;
189
+ // `let`: `/reload` swaps in a freshly built application (the session id and database persist).
190
+ let app = await createApplication(appOptions);
91
191
  let activeProvider = app.providerInfo;
92
192
  let session = options.session ?? app.store.create(app.workspace, app.provider.id, app.provider.model).id;
93
193
  if (options.session && options.model && app.store.get(session).model !== options.model)
@@ -96,23 +196,60 @@ export async function runTui(options) {
96
196
  if (options.session)
97
197
  view = { ...view, items: itemsFromHistory(app.store.messages(session)) };
98
198
  let pendingAttachments = [];
199
+ /**
200
+ * The permission mode label (`/permission`). Derived from the launch flags and only a LABEL until
201
+ * the user picks a mode: nothing is applied at startup, so the startup behavior is unchanged.
202
+ */
203
+ let permissionMode = modeFromFlags(options);
204
+ /** The user picked a mode (so a reload re-applies it); a flag-derived label never changes policy. */
205
+ let modePicked = false;
206
+ /** Agent chosen with Shift+Tab before (or without) the config write that persists it. */
207
+ let agentOverride;
208
+ let reloading = false;
99
209
  let modelList;
100
210
  /** Cached active-provider catalog for completion and context-window discovery. */
101
211
  const models = () => (modelList ??= app.loadModels(AbortSignal.timeout(10_000)).catch((e) => {
102
212
  modelList = undefined;
103
213
  throw e;
104
214
  }));
215
+ /** Active-provider catalog by model id; feeds effort support and display names. */
216
+ let modelCatalog = new Map();
105
217
  /**
106
218
  * Reloads the active-provider catalog and repaints when it lands, so the context bar learns
107
219
  * the ACTIVE model's real window (previously only the first /model picker or autocomplete
108
220
  * call loaded it, leaving a fabricated 40k total on screen). Called at startup and after
109
221
  * every provider/model switch; the bar honestly shows `?` until the catalog arrives.
222
+ * The same refresh feeds the effort status (supported levels + default) and the model
223
+ * display name used by the header and the status line below the editor.
110
224
  */
111
225
  const primeModels = () => {
112
226
  void models()
113
- .then(() => tui.requestRender())
227
+ .then((list) => {
228
+ modelCatalog = new Map(list.map((m) => [m.id, m]));
229
+ refreshEffort();
230
+ tui.requestRender();
231
+ })
114
232
  .catch(() => { });
115
233
  };
234
+ const effortSupportFor = (model) => modelCatalog.get(model)?.effort;
235
+ const modelDisplayName = (model) => modelCatalog.get(model)?.name ?? model;
236
+ /** Persisted `agents.effort` is silently replaced by the model default when unsupported; one notice. */
237
+ let notifiedEffortFallback = "";
238
+ function refreshEffort() {
239
+ const persisted = app.config.agents.effort;
240
+ const capability = effortSupportFor(view.model);
241
+ if (!persisted || !capability?.supportedLevels?.length)
242
+ return;
243
+ if (capability.supportedLevels.includes(persisted))
244
+ return;
245
+ const key = `${view.model}:${persisted}`;
246
+ if (notifiedEffortFallback !== key) {
247
+ notifiedEffortFallback = key;
248
+ flashHint(`Reasoning effort "${persisted}" is not supported by ${modelDisplayName(view.model)}; using ${capability.defaultLevel ? `"${capability.defaultLevel}"` : "the provider default"}.`, 7000);
249
+ }
250
+ }
251
+ /** Effective effort sent on runs: the persisted level when supported, else the model default. */
252
+ const currentEffort = () => effectiveEffort(app.config.agents.effort, effortSupportFor(view.model));
116
253
  const terminal = new ProcessTerminal();
117
254
  const copy = async (text) => copyText(text, {
118
255
  platform: process.platform,
@@ -138,6 +275,23 @@ export async function runTui(options) {
138
275
  // Read-only views of child sessions (subagents), fed by their events.
139
276
  const childViews = new Map();
140
277
  const childView = new TranscriptSync();
278
+ // Fold state of every collapsible transcript row (reasoning sections, tool batches, long
279
+ // outputs). Keyed by stable fold keys from state.ts; absent entries use the pure defaults.
280
+ const folded = new Map();
281
+ const foldOf = (candidate) => folded.get(candidate.key) ?? candidate.defaultExpanded;
282
+ main.setFoldResolver(foldOf);
283
+ childView.setFoldResolver(foldOf);
284
+ /** Which transcript is on screen right now (child view while viewing a subagent, else main). */
285
+ const visibleTranscript = () => viewedState()
286
+ ? { items: viewedState()?.items ?? [], sync: childView }
287
+ : { items: view.items, sync: main };
288
+ /** Applies one fold toggle and re-syncs only the affected transcript (cheap: events, not frames). */
289
+ const toggleFold = (candidate) => {
290
+ folded.set(candidate.key, !(folded.get(candidate.key) ?? candidate.defaultExpanded));
291
+ const visible = visibleTranscript();
292
+ visible.sync.sync(visible.items);
293
+ tui.requestRender();
294
+ };
141
295
  let panelState = initialPanelState();
142
296
  const startupDiagnostics = [];
143
297
  let hint;
@@ -151,6 +305,39 @@ export async function runTui(options) {
151
305
  }, ms);
152
306
  tui.requestRender();
153
307
  };
308
+ // Git branch of the workspace for the header. Reading refs is a read operation, so it stays on
309
+ // under --read-only; the TTL cache means git is spawned at most once per ~10s, never per frame,
310
+ // and a missing repo/git or a slow spawn simply yields no branch segment.
311
+ const branchCache = createBranchCache();
312
+ let branchName;
313
+ const refreshBranch = () => {
314
+ void branchCache.read(app.workspace).then((branch) => {
315
+ if (branch !== branchName) {
316
+ branchName = branch;
317
+ tui.requestRender();
318
+ }
319
+ });
320
+ };
321
+ refreshBranch();
322
+ const branchTimer = setInterval(refreshBranch, BRANCH_REFRESH_MS);
323
+ /**
324
+ * Selectable ACTIVE (main-session) agents: the built-in `build`/`plan` pair plus the
325
+ * main-capable definitions the subagents plugin publishes (`mode: primary|all`) through its
326
+ * plugin state. Never imports the plugin package; a missing/unavailable contribution is just
327
+ * an empty extra list. The persisted `agents.active` id resolves against this catalog.
328
+ */
329
+ const mainAgents = () => {
330
+ let contributions = [];
331
+ try {
332
+ const records = app.plugins.pluginState("subagents", "mainAgents");
333
+ contributions = (records ?? []).map(mainAgentFromRecord);
334
+ }
335
+ catch {
336
+ /* plugin state is best-effort */
337
+ }
338
+ return activeAgentCatalog(contributions);
339
+ };
340
+ const currentAgent = () => resolveActiveAgent(mainAgents(), agentOverride ?? app.config.agents.active);
154
341
  const headerInfo = () => {
155
342
  const policy = app.runner.policy, ask = app.runner.approvals;
156
343
  return {
@@ -158,10 +345,14 @@ export async function runTui(options) {
158
345
  host: hostOf(String(activeProvider?.profile.baseURL ?? app.config.provider.baseURL)),
159
346
  apiMode: String(activeProvider?.profile.apiMode ?? app.config.provider.apiMode),
160
347
  provider: app.providers.get(activeProvider?.id ?? "")?.name,
348
+ modelName: modelDisplayName(view.model),
349
+ effort: currentEffort(),
161
350
  cwd: shortenPath(app.workspace, homedir()),
162
351
  session: shortId(session),
352
+ branch: branchName,
163
353
  write: policy.write ? "on" : ask ? "ask" : "off",
164
354
  process: policy.process ? "on" : ask ? "ask" : "off",
355
+ mode: modeDisplay(permissionMode),
165
356
  mcp: app.mcpRuntimePermission() === "granted",
166
357
  readOnly: !!options.readOnly,
167
358
  };
@@ -208,7 +399,16 @@ export async function runTui(options) {
208
399
  : panelState.focus === "view"
209
400
  ? viewHint()
210
401
  : undefined;
211
- const footer = new Footer(() => viewedState() ?? view, () => app.contextBudget((viewedState() ?? view).model), () => panelHint() ?? hint, () => [...app.plugins.status.values()].map((s) => s.text));
402
+ const footer = new Footer(() => viewedState() ?? view, () => app.contextBudget((viewedState() ?? view).model), () => panelHint() ?? hint, () => [...app.plugins.status.values()].map((s) => s.text),
403
+ // Status row below the editor: `agent: <name> · <model> · <provider> · <effort>` (the
404
+ // effort segment appears only when the active model advertises supported levels).
405
+ () => ({
406
+ agent: currentAgent().name,
407
+ mode: modeDisplay(permissionMode),
408
+ model: modelDisplayName(view.model),
409
+ provider: app.providers.get(activeProvider?.id ?? "")?.name,
410
+ effort: currentEffort(),
411
+ }));
212
412
  const treePanel = new TreePanel(() => {
213
413
  const entry = panelEntry();
214
414
  if (!entry)
@@ -229,25 +429,47 @@ export async function runTui(options) {
229
429
  app.plugins.onStatusChange = () => tui.requestRender();
230
430
  const pickerSlot = new Container();
231
431
  const attachmentsBar = new AttachmentsBar(() => pendingAttachments);
232
- const editor = new Editor(tui, editorTheme, { paddingX: 1 });
432
+ const editor = new Editor(tui, editorTheme, { paddingX: app.config.tui.paddingX });
233
433
  const bottom = new Container();
234
434
  bottom.addChild(pickerSlot);
235
435
  bottom.addChild(attachmentsBar);
436
+ bottom.addChild(new GoalBar(() => goalInfoNow()));
236
437
  bottom.addChild(editor);
237
438
  bottom.addChild(treePanel);
238
439
  bottom.addChild(footer);
440
+ // Click-to-toggle on collapsible transcript rows: pi-tui synthesizes `click` events after a
441
+ // press+release without movement, so plain clicks fold/unfold while drag-selection keeps
442
+ // working (copyOnSelect must never be disturbed). The region sits between the ScrollView and
443
+ // the content switch; content rows = visible rows + the scroll offset.
444
+ let transcriptScroll;
445
+ // Transcript content is inset from both borders in ONE place: the wrapper hands children a
446
+ // reduced width and prefixes every row, so agent text, copy hints, tool sub-lines, reasoning
447
+ // lines and group headers all share the same visual column. The config is read live, so a
448
+ // `/settings` change applies on the next render without rebuilding the view.
449
+ const contentInsetX = () => app.config.tui.contentPaddingX;
450
+ const transcriptRegion = new MouseRegion(new ContentInset(new Switch(() => (viewedState() ? childView.container : transcript)), contentInsetX), (event) => {
451
+ if (picker || event.type !== "click" || event.button !== "left")
452
+ return undefined;
453
+ const scroll = transcriptScroll;
454
+ if (!scroll)
455
+ return undefined;
456
+ const visible = visibleTranscript();
457
+ // entryAt re-renders each row to measure its height, so it must use the same reduced width
458
+ // the wrapper hands the rows during render.
459
+ const candidate = visible.sync.entryAt(event.y + scroll.scrollTop, contentInnerWidth(event.width, contentInsetX()));
460
+ if (!candidate)
461
+ return undefined;
462
+ toggleFold(candidate);
463
+ return { handled: true };
464
+ });
465
+ transcriptScroll = new ScrollView(transcriptRegion, {
466
+ follow: "end",
467
+ primary: true,
468
+ overscroll: "chain",
469
+ });
239
470
  tui.setLayoutRoot(new VStack([
240
471
  { component: header, basis: "auto" },
241
- {
242
- component: new ScrollView(new Switch(() => (viewedState() ? childView.container : transcript)), {
243
- follow: "end",
244
- primary: true,
245
- overscroll: "chain",
246
- }),
247
- basis: 0,
248
- grow: 1,
249
- minSize: 1,
250
- },
472
+ { component: transcriptScroll, basis: 0, grow: 1, minSize: 1 },
251
473
  { component: bottom, basis: "auto", shrink: 1, minSize: 3 },
252
474
  ]));
253
475
  // Prime the active provider's catalog so the context bar shows the model's real window
@@ -296,7 +518,21 @@ export async function runTui(options) {
296
518
  const error = (e) => push({ kind: "error", text: e instanceof Error ? e.message : String(e) });
297
519
  const info = (text) => push({ kind: "info", text });
298
520
  let terminalEvent = false;
521
+ let artifactsThisRun = 0;
522
+ // Artifact announcements show the real local file (resolved from the store, never the model).
523
+ setArtifactPathResolver((id) => {
524
+ const record = app.artifacts.get(id);
525
+ return record?.status === "ready" ? app.artifacts.localPath(record) : undefined;
526
+ });
299
527
  dispatch = (event) => {
528
+ // The event stream stays text-only (headless/JSONL consumers see the projection); the TUI
529
+ // pulls the persisted rich parts (ui/image) itself so native rendering survives resume.
530
+ if (event.type === "tool_completed") {
531
+ const data = (event.data ?? {});
532
+ const rich = richPartsOf(app.store.callResult(event.sessionId, String(data.id ?? "")), String(data.name ?? ""));
533
+ if (rich.ui !== undefined || rich.image !== undefined)
534
+ event = { ...event, data: { ...data, ...rich } };
535
+ }
300
536
  if (event.sessionId !== session) {
301
537
  // Child session (e.g. a subagent): keep a view model for its read-only view.
302
538
  const child = childViews.get(event.sessionId) ?? initialViewState(view.model);
@@ -306,9 +542,22 @@ export async function runTui(options) {
306
542
  tui.requestRender();
307
543
  return;
308
544
  }
309
- if (["run_completed", "run_failed", "run_cancelled", "compaction_failed"].includes(event.type))
545
+ if (event.type === "artifact_published")
546
+ artifactsThisRun++;
547
+ const ended = [
548
+ "run_completed",
549
+ "run_failed",
550
+ "run_cancelled",
551
+ "run_turns_exceeded",
552
+ "compaction_failed",
553
+ ].includes(event.type);
554
+ if (ended)
310
555
  terminalEvent = true;
311
556
  view = reduceEvent(view, event);
557
+ if (ended && event.type !== "compaction_failed" && artifactsThisRun > 0) {
558
+ artifactsThisRun = 0;
559
+ view = addItem(view, { kind: "notice", text: ARTIFACTS_HINT });
560
+ }
312
561
  sync();
313
562
  };
314
563
  const refreshEstimate = () => {
@@ -323,7 +572,7 @@ export async function runTui(options) {
323
572
  })
324
573
  .catch(() => { });
325
574
  };
326
- let busy = false, controller, pending, picker;
575
+ let busy = false, controller, pending, picker, settingsMenu;
327
576
  const showPicker = (next) => {
328
577
  picker = next;
329
578
  pickerSlot.clear();
@@ -333,6 +582,7 @@ export async function runTui(options) {
333
582
  };
334
583
  const closePicker = () => {
335
584
  picker = undefined;
585
+ settingsMenu = undefined;
336
586
  pickerSlot.clear();
337
587
  tui.setFocus(editor);
338
588
  tui.requestRender();
@@ -348,6 +598,7 @@ export async function runTui(options) {
348
598
  */
349
599
  const interactiveQueue = new InteractiveQueue();
350
600
  approve = (request) => interactiveQueue.submit({
601
+ kind: "approval",
351
602
  sessionId: request.session,
352
603
  label: request.label,
353
604
  signal: request.signal,
@@ -364,6 +615,31 @@ export async function runTui(options) {
364
615
  };
365
616
  const onAbort = () => finish("deny");
366
617
  request.signal.addEventListener("abort", onAbort, { once: true });
618
+ if (request.capability === "analysis.install" && request.install) {
619
+ // Installing the optional packages: only once or deny (never stored), with what is
620
+ // installed, the download estimate and that it needs the network.
621
+ showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}${installApprovalTitle(request.install)}`, [
622
+ { value: "once", label: "Allow once" },
623
+ { value: "deny", label: "Deny" },
624
+ ], (item) => finish(item.value), () => finish("deny"), false, installApprovalDetail(request.install)));
625
+ return;
626
+ }
627
+ if (request.capability) {
628
+ // A capability (Python analysis): its own title, the script preview and the
629
+ // not-a-sandbox warning; "for this session" is persisted for the root session.
630
+ showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}${capabilityApprovalTitle(request.runtime)}`, [
631
+ { value: "once", label: "Allow once" },
632
+ { value: "session", label: "Allow for this session" },
633
+ { value: "deny", label: "Deny" },
634
+ ], (item) => finish(item.value), () => finish("deny"), false, [
635
+ request.preview ?? "",
636
+ "",
637
+ request.runtime === "oci"
638
+ ? "Runs in a container without network access. Repository files are not modified by this step."
639
+ : `${NOT_SANDBOXED} It could change repository files; Alisio itself does not modify them in this step.`,
640
+ ].join("\n")));
641
+ return;
642
+ }
367
643
  showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}Allow ${request.call.name} (${request.effect}): ${summarizeToolArgs(request.call.name, request.call.arguments)}?`, [
368
644
  { value: "once", label: "Allow once" },
369
645
  { value: "session", label: `Always allow ${request.effect} in this session` },
@@ -371,13 +647,106 @@ export async function runTui(options) {
371
647
  ], (item) => finish(item.value), () => finish("deny")));
372
648
  }),
373
649
  });
650
+ /**
651
+ * External-directory approval: a tool path outside the workspace and every declared extra root
652
+ * asks here before the call runs, scoped to the containing directory (not the individual file),
653
+ * so one approval covers that directory's subtree for the session. Same queue and the same
654
+ * allow-once / allow-session / deny choices as the capability approval above.
655
+ */
656
+ approveExternalDirectory = (request) => interactiveQueue.submit({
657
+ kind: "approval",
658
+ sessionId: request.session,
659
+ label: request.label,
660
+ signal: request.signal,
661
+ onWithdrawn: () => "deny",
662
+ run: () => new Promise((resolve) => {
663
+ let settled = false;
664
+ const finish = (decision) => {
665
+ if (settled)
666
+ return;
667
+ settled = true;
668
+ request.signal.removeEventListener("abort", onAbort);
669
+ closePicker();
670
+ resolve(decision);
671
+ };
672
+ const onAbort = () => finish("deny");
673
+ request.signal.addEventListener("abort", onAbort, { once: true });
674
+ showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}Allow access outside the workspace to ${shortenPath(request.directory, homedir(), 72)}?`, [
675
+ { value: "once", label: "Allow once" },
676
+ { value: "session", label: "Always allow this directory in this session" },
677
+ { value: "deny", label: "Deny" },
678
+ ], (item) => finish(item.value), () => finish("deny")));
679
+ }),
680
+ });
374
681
  /**
375
682
  * `ask_user_question` / `/ask`: routed through the same `interactiveQueue` as `approve`/`select`,
376
683
  * so a root or subagent question never races another prompt for the screen. A question withdrawn
377
684
  * while queued or displayed (its session cancelled) resolves every question undefined and prints
378
685
  * a notice; it never leaves a stale prompt on screen or blocks the next queued item.
379
686
  */
687
+ /**
688
+ * `exit_plan`: the plan is printed in the transcript (above the panel), then the three decisions
689
+ * (and the free-text line for "Add context") are asked through the same queue. Dismissing the
690
+ * panel (Esc) or cancelling the run answers "skip": the tool always returns a result.
691
+ */
692
+ const askPlanReview = (request, plan) => {
693
+ const question = request.questions[0];
694
+ const options = (question?.options ?? []).map((option) => ({
695
+ value: option.value,
696
+ label: option.label,
697
+ ...(option.description ? { description: option.description } : {}),
698
+ ...(option.recommended ? { recommended: true } : {}),
699
+ ...(option.textInput ? { textInput: true } : {}),
700
+ }));
701
+ const withdrawn = () => ({ [PLAN_QUESTION_ID]: undefined });
702
+ return interactiveQueue.submit({
703
+ kind: "question",
704
+ sessionId: request.session,
705
+ label: request.label,
706
+ signal: request.signal,
707
+ onWithdrawn: () => {
708
+ notice("Plan review withdrawn: the run was cancelled.");
709
+ return withdrawn();
710
+ },
711
+ run: () => new Promise((resolve) => {
712
+ let settled = false;
713
+ const finish = (result) => {
714
+ if (settled)
715
+ return;
716
+ settled = true;
717
+ request.signal?.removeEventListener("abort", onAbort);
718
+ closePicker();
719
+ resolve(result);
720
+ };
721
+ const onAbort = () => {
722
+ notice("Plan review withdrawn: the run was cancelled.");
723
+ finish(withdrawn());
724
+ };
725
+ request.signal?.addEventListener("abort", onAbort, { once: true });
726
+ push({
727
+ kind: "assistant",
728
+ text: planTranscriptMarkdown(plan),
729
+ reasoning: "",
730
+ done: true,
731
+ });
732
+ showPicker(new PlanReviewPanel(options, {
733
+ title: plan.title,
734
+ revision: plan.revision,
735
+ ...(plan.artifact ? { artifactId: plan.artifact.id } : {}),
736
+ question: question?.question ?? "",
737
+ }, (effect) => {
738
+ info(`Plan review: ${options.find((o) => o.value === effect.value)?.label ?? effect.value}`);
739
+ finish({
740
+ [PLAN_QUESTION_ID]: effect.value,
741
+ ...(effect.text ? { [PLAN_TEXT_KEY]: effect.text } : {}),
742
+ });
743
+ }, question?.options.find((o) => o.textInput)?.textInput?.placeholder));
744
+ }),
745
+ });
746
+ };
380
747
  const askQuestions = (request) => {
748
+ if (request.plan && request.questions.length === 1)
749
+ return askPlanReview(request, request.plan);
381
750
  const specs = request.questions.map((q) => ({
382
751
  header: q.header,
383
752
  question: q.question,
@@ -403,6 +772,7 @@ export async function runTui(options) {
403
772
  return result;
404
773
  };
405
774
  return interactiveQueue.submit({
775
+ kind: "question",
406
776
  sessionId: request.session,
407
777
  label: request.label,
408
778
  signal: request.signal,
@@ -432,17 +802,246 @@ export async function runTui(options) {
432
802
  }),
433
803
  });
434
804
  };
435
- const task = async (work) => {
805
+ // ---- Session goal (`/goal`) --------------------------------------------------------------
806
+ // The goal lives in the shared database and `app.goals` (core) owns its rules; this block only
807
+ // shows it, asks the core whether it may continue (`kickGoal`) and starts the continuation as a
808
+ // normal prompt. A claimed continuation can only start once (the core records the claim).
809
+ let goalCache;
810
+ const goalRoot = () => app.store.rootOf(session);
811
+ const goalHostView = () => {
812
+ const asking = interactiveQueue.current();
813
+ return {
814
+ running: busy,
815
+ // What the user typed but did not send goes first (a goal never talks over a draft).
816
+ queuedInput: editor.getText().trim().length > 0,
817
+ ...(asking ? { awaiting: asking.kind ?? "question" } : {}),
818
+ planMode: currentAgent().id === "plan",
819
+ liveTasks: app.tasks.liveCount(goalRoot()),
820
+ };
821
+ };
822
+ /** The cached goal with what it waits for NOW (computed live: no database read per render). */
823
+ const goalInfoNow = () => {
824
+ if (!goalCache)
825
+ return undefined;
826
+ const waiting = goalWaiting({
827
+ ...goalHostView(),
828
+ status: goalCache.status,
829
+ enabled: app.goals.enabled,
830
+ inflight: false,
831
+ });
832
+ return { ...goalCache, ...(waiting ? { waiting } : {}) };
833
+ };
834
+ /** Re-reads the goal; one transcript line when it stopped by itself. */
835
+ const refreshGoal = () => {
836
+ const before = goalCache;
837
+ goalCache = app.goals.info(goalRoot());
838
+ if (before &&
839
+ goalCache &&
840
+ before.goalId === goalCache.goalId &&
841
+ before.status === "active" &&
842
+ goalCache.status !== "active" &&
843
+ goalCache.reason !== "user_paused") {
844
+ const line = goalStopNotice(goalCache);
845
+ if (line)
846
+ notice(line);
847
+ }
848
+ tui.requestRender();
849
+ };
850
+ onGoalChange = (sessionId) => {
851
+ if (sessionId === goalRoot())
852
+ refreshGoal();
853
+ };
854
+ refreshGoal();
855
+ /** The goal continues if the core says so; otherwise it waits (the bar says for what). */
856
+ const kickGoal = () => {
857
+ if (stopping || reloading)
858
+ return;
859
+ try {
860
+ const root = goalRoot();
861
+ if (!app.goals.get(root))
862
+ return;
863
+ const decision = app.goals.next(root, goalHostView());
864
+ refreshGoal();
865
+ if (decision.kind !== "continue")
866
+ return;
867
+ const timeout = app.config.limits.timeoutMs;
868
+ void runPrompt(decision.prompt.display, decision.prompt.text, decision.prompt.display, {
869
+ maxTokens: decision.run.maxTokens,
870
+ timeoutMs: Math.max(1000, Math.min(decision.run.remainingMs, timeout)),
871
+ });
872
+ }
873
+ catch (cause) {
874
+ error(cause);
875
+ }
876
+ };
877
+ onTaskChange = (task, root) => {
878
+ if (root !== goalRoot())
879
+ return;
880
+ // The last background task of a waiting goal ended: it continues.
881
+ if (!isTaskLive(task.status))
882
+ setImmediate(kickGoal);
883
+ else
884
+ refreshGoal();
885
+ };
886
+ /** After a run: folds it into the goal (tokens, turns, breakers). */
887
+ const settleGoalRun = (owner, run, outcome) => {
888
+ const root = app.store.rootOf(owner);
889
+ if (!app.goals.get(root))
890
+ return;
891
+ app.goals.settle(root, goalOutcome({
892
+ runId: run.runId,
893
+ startedAt: run.startedAt,
894
+ endedAt: Date.now(),
895
+ cancelled: outcome.cancelled,
896
+ ...(outcome.result
897
+ ? { result: outcome.result }
898
+ : {}),
899
+ ...(outcome.error !== undefined ? { error: outcome.error } : {}),
900
+ }));
901
+ };
902
+ const goalConfirmReplace = (objective, tokenBudget) => showPicker(new Picker("This session already has a goal. Replace it?", [
903
+ {
904
+ value: "replace",
905
+ label: "Replace it",
906
+ description: "The current goal is removed and the new one starts",
907
+ },
908
+ { value: "keep", label: "Keep the current goal" },
909
+ ], (item) => {
910
+ closePicker();
911
+ if (item.value === "replace")
912
+ startGoal(objective, tokenBudget, true);
913
+ }, closePicker));
914
+ const startGoal = (objective, tokenBudget, replace) => {
915
+ const outcome = app.goals.create(goalRoot(), {
916
+ objective,
917
+ ...(tokenBudget ? { tokenBudget } : {}),
918
+ ...(replace ? { replace: true } : {}),
919
+ });
920
+ if (!outcome.ok)
921
+ return notice(outcome.message);
922
+ refreshGoal();
923
+ notice(tokenBudget
924
+ ? "Goal started with a token budget. The agent works on it until it is done, blocked, paused or out of budget."
925
+ : "Goal started. Without a token budget only the turn and time limits stop it: /goal budget=50k sets one.");
926
+ kickGoal();
927
+ };
928
+ /** `/goal …`: everything but the continuation itself. */
929
+ const goalCommand = (args) => {
930
+ const root = goalRoot();
931
+ const plan = planGoalCommand(parseGoalCommand(args), {
932
+ goal: app.goals.info(root),
933
+ enabled: app.goals.enabled,
934
+ });
935
+ const applied = (outcome, done) => {
936
+ if (!outcome.ok)
937
+ return notice(outcome.message);
938
+ refreshGoal();
939
+ notice(done);
940
+ };
941
+ switch (plan.kind) {
942
+ case "say":
943
+ return plan.tone === "info" ? info(plan.text) : notice(plan.text);
944
+ case "picker": {
945
+ const goal = app.goals.info(root);
946
+ if (!goal)
947
+ return;
948
+ return showPicker(new Picker("Start or manage the current Session Goal", goalPickerItems(goal), (item) => {
949
+ closePicker();
950
+ const prefill = goalPickerPrefill(item.value, goal);
951
+ if (prefill !== undefined) {
952
+ editor.setText(prefill);
953
+ return tui.requestRender();
954
+ }
955
+ if (item.value === "status") {
956
+ refreshGoal();
957
+ const current = goalInfoNow();
958
+ return current ? info(formatGoalStatus(current)) : undefined;
959
+ }
960
+ goalCommand(item.value);
961
+ }, closePicker));
962
+ }
963
+ case "confirm-replace":
964
+ return goalConfirmReplace(plan.objective, plan.tokenBudget);
965
+ case "create":
966
+ return startGoal(plan.objective, plan.tokenBudget, false);
967
+ case "act": {
968
+ const done = {
969
+ pause: "Goal paused: it will not continue until you resume it.",
970
+ resume: "Goal resumed.",
971
+ clear: "Goal cleared.",
972
+ }[plan.action];
973
+ const outcome = plan.action === "pause"
974
+ ? app.goals.pause(root)
975
+ : plan.action === "resume"
976
+ ? app.goals.resume(root)
977
+ : app.goals.clear(root);
978
+ applied(outcome, done);
979
+ return plan.action === "resume" ? kickGoal() : undefined;
980
+ }
981
+ case "edit": {
982
+ applied(app.goals.edit(root, plan.objective), "Goal objective updated.");
983
+ return;
984
+ }
985
+ case "edit-prefill":
986
+ editor.setText(plan.text);
987
+ return tui.requestRender();
988
+ case "budget": {
989
+ applied(app.goals.setBudget(root, plan.tokens), plan.tokens === null ? "Goal token budget removed." : "Goal token budget set.");
990
+ return kickGoal();
991
+ }
992
+ }
993
+ };
994
+ /**
995
+ * After a run: an approved plan (`exit_plan`) starts its implementation exactly once. The claim is
996
+ * a compare-and-set in the session database, so a double Enter or a second surface cannot start
997
+ * a second turn. The agent switches to build BEFORE the turn (the run that took the decision has
998
+ * already ended, so no tool call is cut in half); the permission mode is left as it is. A run the
999
+ * user cancelled drops an approval it had not started yet.
1000
+ */
1001
+ const followUpPlan = async (owner, aborted) => {
1002
+ if (stopping)
1003
+ return;
1004
+ try {
1005
+ const next = settlePlanRun(app.store, owner, { aborted });
1006
+ if (next.kind === "dropped")
1007
+ return notice("Plan approval dropped: the run was cancelled before the implementation started.");
1008
+ if (next.kind !== "implement")
1009
+ return;
1010
+ agentOverride = IMPLEMENTATION_AGENT_ID;
1011
+ if (!options.readOnly)
1012
+ try {
1013
+ await app.updateSetting("agents.active", IMPLEMENTATION_AGENT_ID);
1014
+ if (agentOverride === IMPLEMENTATION_AGENT_ID)
1015
+ agentOverride = undefined;
1016
+ }
1017
+ catch (cause) {
1018
+ agentOverride = undefined;
1019
+ return error(new Error(`The plan was approved but Alisio could not switch to the build agent: ${cause instanceof Error ? cause.message : String(cause)}. Switch with /agent:build and ask it to implement the plan.`));
1020
+ }
1021
+ refreshEffort();
1022
+ notice(`Plan approved: switching to the build agent to implement revision ${next.plan.revision}.`);
1023
+ await runPrompt(next.display, next.text, next.display);
1024
+ }
1025
+ catch (cause) {
1026
+ error(cause);
1027
+ }
1028
+ };
1029
+ const task = async (work, goalRun) => {
436
1030
  busy = true;
437
1031
  terminalEvent = false;
438
1032
  controller = new AbortController();
439
1033
  const signal = controller.signal;
1034
+ const owner = session;
1035
+ const startedAt = Date.now();
440
1036
  const promise = work(signal);
441
1037
  pending = promise;
1038
+ let result;
1039
+ let failure;
442
1040
  try {
443
- await promise;
1041
+ result = await promise;
444
1042
  }
445
1043
  catch (e) {
1044
+ failure = e;
446
1045
  if (!terminalEvent)
447
1046
  error(e);
448
1047
  }
@@ -454,18 +1053,397 @@ export async function runTui(options) {
454
1053
  if (!view.context || view.context.estimated)
455
1054
  refreshEstimate();
456
1055
  tui.requestRender();
1056
+ // A finished-task notification held back while this run was busy may go now.
1057
+ app.tasks.sessionIdle(app.store.rootOf(owner));
1058
+ }
1059
+ // The run counts for the session's goal (its tokens, turns and breakers) before anything else
1060
+ // starts; an interrupt (Esc) pauses the goal.
1061
+ if (goalRun)
1062
+ try {
1063
+ settleGoalRun(owner, { runId: goalRun.runId, startedAt }, {
1064
+ cancelled: signal.aborted,
1065
+ ...(result !== undefined ? { result } : {}),
1066
+ ...(failure !== undefined ? { error: failure } : {}),
1067
+ });
1068
+ }
1069
+ catch (cause) {
1070
+ error(cause);
1071
+ }
1072
+ await followUpPlan(owner, signal.aborted);
1073
+ // The goal continues unless something (a question, a task, plan mode, you) says to wait.
1074
+ if (goalRun)
1075
+ kickGoal();
1076
+ };
1077
+ /** `/tasks`: the background tasks of this session, their live output and the user's stop. */
1078
+ const showTasks = () => {
1079
+ const owner = () => app.store.rootOf(session);
1080
+ const view = new TasksView({
1081
+ list: () => app.tasks.list(owner(), { limit: 100, mirror: true }),
1082
+ read: (id, offset) => app.tasks.read(owner(), id, { offset, reader: "user" }),
1083
+ stop: (id) => app.tasks.stop(owner(), id, "user"),
1084
+ rows: () => process.stdout.rows ?? 24,
1085
+ requestRender: () => tui.requestRender(),
1086
+ close: () => {
1087
+ tasksView = undefined;
1088
+ closePicker();
1089
+ },
1090
+ active: () => picker === view,
1091
+ notice,
1092
+ });
1093
+ tasksView = view;
1094
+ view.start();
1095
+ showPicker(view);
1096
+ };
1097
+ /**
1098
+ * The wake-up of a finished background task: ONE run of this session carrying the notification,
1099
+ * started through the normal prompt path (`runner.enqueue` would leave the text waiting for the
1100
+ * next prompt). It waits (answers `busy`, the core retries) while a turn runs, a question or
1101
+ * approval is on screen, or another session is open; it is never started while exiting.
1102
+ */
1103
+ wakeForTasks = (request) => {
1104
+ const decision = wakeDecision({
1105
+ exiting: stopping,
1106
+ reloading,
1107
+ busy,
1108
+ promptPending: !!interactiveQueue.current(),
1109
+ openRoot: app.store.rootOf(session),
1110
+ ownerRoot: request.sessionId,
1111
+ });
1112
+ if (decision !== "start")
1113
+ return decision === "skip" ? "skip" : "busy";
1114
+ void runPrompt(request.display, request.text, request.display);
1115
+ return "started";
1116
+ };
1117
+ /** `/artifacts [filter]`: the root session's artifacts, newest first, then their actions. */
1118
+ const browseArtifacts = (filter) => {
1119
+ const all = app.artifacts
1120
+ .list(app.store.rootOf(session), { limit: 500, includeExpired: true })
1121
+ .map(toRef);
1122
+ const shown = filter ? all.filter((artifact) => matchArtifact(artifact, filter)) : all;
1123
+ if (!shown.length)
1124
+ return notice(all.length ? `No artifacts match "${filter}"` : "No artifacts in this session yet.");
1125
+ const byId = new Map(all.map((artifact) => [artifact.id, artifact]));
1126
+ const now = Date.now();
1127
+ showPicker(new Picker(`Artifacts · ${shown.length}`, shown.map((artifact) => ({ value: artifact.id, label: artifactRow(artifact, now) })), (item) => artifactMenu(item.value, filter), () => closePicker(), true, undefined, (items, text) => items.filter((item) => {
1128
+ const artifact = byId.get(item.value);
1129
+ return !!artifact && matchArtifact(artifact, text);
1130
+ })));
1131
+ };
1132
+ const artifactMenu = (id, filter) => {
1133
+ const record = app.artifacts.get(id);
1134
+ if (!record || (record.status !== "ready" && record.status !== "expired")) {
1135
+ closePicker();
1136
+ return notice("This artifact is no longer available.");
457
1137
  }
1138
+ const artifact = toRef(record);
1139
+ const path = app.artifacts.localPath(record);
1140
+ const back = () => artifactMenu(id, filter);
1141
+ const copyPath = async () => {
1142
+ const result = await copy(path);
1143
+ closePicker();
1144
+ if (result.ok)
1145
+ tui.flash("Path copied");
1146
+ else
1147
+ notice(`${copyMessage(result)}. Path: ${path}`);
1148
+ };
1149
+ /** Opens (or reveals) a store path; without a desktop, offers to copy it instead. */
1150
+ const launch = async (target, reveal) => {
1151
+ const outcome = await (reveal ? revealPath(target) : openPath(target));
1152
+ if (outcome.ok)
1153
+ return closePicker();
1154
+ // No desktop (SSH, no display) or no opener: nothing was launched; offer the path.
1155
+ return showPicker(new Picker(cannotOpenMessage(target, outcome), [
1156
+ { value: "copy", label: "Copy path" },
1157
+ { value: "close", label: "Close" },
1158
+ ], (choice) => choice.value === "copy"
1159
+ ? void copy(target).then((result) => {
1160
+ closePicker();
1161
+ if (result.ok)
1162
+ tui.flash("Path copied");
1163
+ else
1164
+ notice(`${copyMessage(result)}. Path: ${target}`);
1165
+ })
1166
+ : closePicker(), () => closePicker()));
1167
+ };
1168
+ // `Copy to workspace…` needs artifact_export (absent under --read-only or with analysis off).
1169
+ const canExport = !options.readOnly && app.registry.list().some((tool) => tool.name === "artifact_export");
1170
+ const job = record.executionId ? app.analysis.jobs.get(record.executionId) : undefined;
1171
+ // `Rerun` runs python_run { rerunOf } (absent under --read-only or with analysis disabled).
1172
+ const canRerun = !options.readOnly && app.registry.list().some((tool) => tool.name === "python_run");
1173
+ showPicker(new Picker(artifact.fileName, artifactActions(artifact, {
1174
+ readOnly: !canExport,
1175
+ sources: !!job,
1176
+ rerun: !!job && canRerun,
1177
+ }), (item) => void (async () => {
1178
+ if (item.value === "copy")
1179
+ return copyPath();
1180
+ if (item.value === "open" || item.value === "reveal")
1181
+ return launch(path, item.value === "reveal");
1182
+ if (item.value === "preview") {
1183
+ const preview = await loadPreview(artifact, path);
1184
+ return showPicker(new ArtifactPreview(artifact, preview, {
1185
+ height: () => Math.max(5, (process.stdout.rows ?? 24) - 12),
1186
+ open: () => void launch(path, false).catch(error),
1187
+ copy: () => void copyPath().catch(error),
1188
+ close: back,
1189
+ }));
1190
+ }
1191
+ if (item.value === "sources" && job)
1192
+ // An explicit user action: the job folder (script, logs, work) of this execution.
1193
+ return launch(join(app.artifacts.root, ...job.relDir.split(/[\\/]/)), false);
1194
+ if (item.value === "details") {
1195
+ const provenance = record.provenance;
1196
+ return showPicker(new Picker(`Details · ${artifact.fileName}`, [{ value: "back", label: "Back" }], back, back, false, detailRows(artifact, provenance)
1197
+ .map(([label, value]) => `${label}: ${value}`)
1198
+ .join("\n")));
1199
+ }
1200
+ if (item.value === "rerun") {
1201
+ if (busy) {
1202
+ closePicker();
1203
+ return notice("Wait until the current run finishes, then run the analysis again.");
1204
+ }
1205
+ closePicker();
1206
+ info(`Run the analysis of ${artifact.fileName} again`);
1207
+ // A tool call without the model: the same capability gate as the first run.
1208
+ void task((signal) => app.runner.runToolCall(session, "python_run", { rerunOf: id }, { signal, display: `Run the analysis of ${artifact.fileName} again` }));
1209
+ return;
1210
+ }
1211
+ if (item.value === "export") {
1212
+ if (busy) {
1213
+ closePicker();
1214
+ return notice("Wait until the current run finishes, then copy the artifact.");
1215
+ }
1216
+ return showPicker(new ConnectInputPrompt({
1217
+ provider: "",
1218
+ title: `Copy ${artifact.fileName} to the workspace`,
1219
+ label: "Folder in the workspace",
1220
+ initial: ".",
1221
+ secret: false,
1222
+ placeholder: ".",
1223
+ hint: "Relative to the workspace. Writing asks first unless --allow-write.",
1224
+ step: 1,
1225
+ steps: 1,
1226
+ onSubmit: (target) => {
1227
+ closePicker();
1228
+ info(`Copy ${artifact.fileName} to the workspace (${target.trim() || "."})`);
1229
+ // A tool call without the model: the write gate (approval) applies as usual.
1230
+ void task((signal) => app.runner.runToolCall(session, "artifact_export", { id, target: target.trim() || "." }, {
1231
+ signal,
1232
+ display: `Copy ${artifact.fileName} to the workspace (${target.trim() || "."})`,
1233
+ }));
1234
+ },
1235
+ onCancel: back,
1236
+ }));
1237
+ }
1238
+ if (item.value === "delete")
1239
+ return showPicker(new Picker(`Delete ${artifact.fileName}? This can't be undone.`, [
1240
+ { value: "delete", label: "Delete" },
1241
+ { value: "cancel", label: "Cancel" },
1242
+ ], (choice) => void (async () => {
1243
+ if (choice.value !== "delete")
1244
+ return back();
1245
+ await app.artifacts.delete(id);
1246
+ closePicker();
1247
+ notice(`Deleted ${artifact.fileName}`);
1248
+ })(), back));
1249
+ })().catch(error), () => browseArtifacts(filter)));
1250
+ };
1251
+ /** `/permissions`: persisted grants of the root session; choosing one offers to revoke it. */
1252
+ const managePermissions = () => {
1253
+ const grants = app.capabilityGrants.live(session).map(toGrantWire);
1254
+ if (!grants.length)
1255
+ return notice("No saved permissions in this session.");
1256
+ showPicker(new Picker("Session permissions", grants.map((grant) => ({ value: grant.id, label: permissionRow(grant) })), (item) => showPicker(new Picker(`Revoke "${item.label}"?`, [
1257
+ { value: "revoke", label: "Revoke" },
1258
+ { value: "cancel", label: "Cancel" },
1259
+ ], (choice) => {
1260
+ if (choice.value !== "revoke")
1261
+ return managePermissions();
1262
+ app.capabilityGrants.revoke(session, item.value, "tui");
1263
+ closePicker();
1264
+ notice("Permission revoked: the next Python analysis asks again.");
1265
+ }, () => managePermissions())), () => closePicker()));
1266
+ };
1267
+ // ---- Permission modes, agent cycle, /reload and /changelog (modes spec, Phase 1) ----------
1268
+ /** Markdown status of the current permission mode and what actually runs without asking. */
1269
+ const permissionStatusText = () => {
1270
+ const policy = app.runner.policy;
1271
+ return [
1272
+ describePermissionStatus({
1273
+ mode: permissionMode,
1274
+ policy,
1275
+ approvals: app.runner.approvals,
1276
+ }),
1277
+ ...(permissionMode === "ask" && policy.external
1278
+ ? [
1279
+ "",
1280
+ "Note: external (network, MCP, plugin tools) was already allowed at startup because plugins or MCP are enabled; pick a mode to set it explicitly.",
1281
+ ]
1282
+ : []),
1283
+ ].join("\n");
458
1284
  };
459
- const runPrompt = (display, prompt, persistDisplay) => {
1285
+ /** Applies a permission mode between turns: it resets "allow for this session" widenings. */
1286
+ const setPermissionMode = (target) => {
1287
+ if (busy)
1288
+ return flashHint("A turn is running: wait for it to finish before changing the mode");
1289
+ const result = permissionTransition(permissionMode, target);
1290
+ if (!result.ok)
1291
+ return notice(result.message);
1292
+ app.runner.setPolicy(PERMISSION_MODE_TABLE[target].policy);
1293
+ permissionMode = target;
1294
+ modePicked = true;
1295
+ notice(result.message);
1296
+ if (result.warning)
1297
+ notice(result.warning);
1298
+ flashHint(`Permission mode: ${target}`);
1299
+ tui.requestRender();
1300
+ };
1301
+ /** `/permission`: ONE menu with the three modes, the status and the saved permissions. */
1302
+ const openPermissionMenu = () => {
1303
+ const rows = permissionMenuRows(permissionMode);
1304
+ showPicker(new Picker("Permission mode", rows.map((row) => ({ value: row.value, label: row.label, description: row.description })), (item) => {
1305
+ const choice = item.value;
1306
+ if (choice === "status") {
1307
+ closePicker();
1308
+ return info(permissionStatusText());
1309
+ }
1310
+ if (choice === "manage") {
1311
+ closePicker();
1312
+ return managePermissions();
1313
+ }
1314
+ if (choice === "full" && permissionMode !== "locked")
1315
+ return showPicker(new Picker("Enable full access?", [
1316
+ { value: "yes", label: "Yes, enable full access" },
1317
+ { value: "no", label: "Cancel" },
1318
+ ], (confirm) => {
1319
+ closePicker();
1320
+ if (confirm.value === "yes")
1321
+ setPermissionMode("full");
1322
+ }, () => closePicker(), false, "Writes, commands and network calls will run without asking. This is not a sandbox: anything the agent runs has your user's permissions."));
1323
+ closePicker();
1324
+ if (isPermissionMode(choice))
1325
+ setPermissionMode(choice);
1326
+ }, () => closePicker(), false, "ask: every effect asks · auto: workspace edits run, commands and network ask · full access: nothing asks (not a sandbox). Modes apply from the next tool call and reset session approvals."));
1327
+ };
1328
+ /** Shift+Tab: the next main agent (build, plan, then custom ones); applies from the next prompt. */
1329
+ const cycleAgent = async () => {
1330
+ const next = nextAgent(mainAgents(), currentAgent().id, 1);
1331
+ if (!next)
1332
+ return;
1333
+ const previous = currentAgent().name;
1334
+ // Set synchronously so a quick second press continues from this agent, not the stale config.
1335
+ agentOverride = next.id;
1336
+ flashHint(agentCycleHint(next, view.model), 4000);
1337
+ if (!options.readOnly) {
1338
+ try {
1339
+ await app.updateSetting("agents.active", next.id);
1340
+ if (agentOverride === next.id)
1341
+ agentOverride = undefined;
1342
+ }
1343
+ catch (cause) {
1344
+ agentOverride = undefined;
1345
+ flashHint(`Could not switch to ${next.name} (still ${previous})`);
1346
+ return error(cause);
1347
+ }
1348
+ }
1349
+ refreshEffort();
1350
+ tui.requestRender();
1351
+ // A goal that waited for the plan agent to leave plan mode may continue now.
1352
+ refreshGoal();
1353
+ if (!busy)
1354
+ kickGoal();
1355
+ };
1356
+ /** `/changelog [version]`: a scrollable panel with the shipped, offline changelog. */
1357
+ const showChangelog = (args) => {
1358
+ const selected = selectEntries(loadChangelog(), args ? { version: args } : {});
1359
+ if (!selected.found)
1360
+ return notice(`No changelog entry for version ${args}. Run /changelog to see the latest releases.`);
1361
+ showPicker(new MarkdownPanel(`What's new · Alisio ${VERSION}${args ? ` · ${args}` : ""}`, formatChangelogMarkdown(selected.entries), closePicker, () => Math.max(6, (process.stdout.rows ?? 30) - 14)));
1362
+ };
1363
+ /** After an upgrade: ONE discreet line (never on a first run), then remember the version. */
1364
+ const announceNews = async () => {
1365
+ const dir = stateHome();
1366
+ const news = changelogNews({
1367
+ entries: loadChangelog(),
1368
+ current: VERSION,
1369
+ lastSeen: (await readViewerState(dir)).lastSeenVersion,
1370
+ });
1371
+ if (news.show)
1372
+ notice(`Alisio updated to ${VERSION} · ${news.versions.length} new ${news.versions.length === 1 ? "entry" : "entries"} · /changelog`);
1373
+ if (news.record && !options.readOnly)
1374
+ await writeViewerState(dir, { lastSeenVersion: VERSION });
1375
+ };
1376
+ /**
1377
+ * `/reload`: refused mid-turn; validates the configuration, builds a NEW application next to the
1378
+ * current one and only then swaps, so a broken config leaves this session untouched.
1379
+ */
1380
+ const reloadAll = async () => {
1381
+ const guard = () => reloadGuard({
1382
+ busy,
1383
+ runningChildren: panelNodes().filter((node) => node.status === "running").length,
1384
+ queuedPrompts: !!interactiveQueue.current(),
1385
+ backgroundTasks: app.tasks.liveCount(),
1386
+ });
1387
+ const reason = guard();
1388
+ if (reason)
1389
+ return notice(reason);
1390
+ reloading = true;
1391
+ flashHint("Reloading configuration…", 60_000);
1392
+ try {
1393
+ const report = await reloadApplication({
1394
+ current: app,
1395
+ busy: guard,
1396
+ validate: () => validateReloadConfig(appOptions),
1397
+ create: () => createApplication(appOptions),
1398
+ swap: (next) => rebindApplication(next),
1399
+ });
1400
+ editor.setAutocompleteProvider(buildSlashCompletionProvider());
1401
+ info(formatReloadReport(report));
1402
+ for (const failure of app.mcpStartupFailures())
1403
+ notice(failure);
1404
+ }
1405
+ catch (cause) {
1406
+ if (cause instanceof ReloadRefusedError)
1407
+ notice(cause.message);
1408
+ else if (cause instanceof ReloadFailedError)
1409
+ error(new Error(`Reload failed; the current session is unchanged: ${cause.message}`));
1410
+ else
1411
+ error(cause);
1412
+ }
1413
+ finally {
1414
+ reloading = false;
1415
+ flashHint("Reload finished", 1500);
1416
+ }
1417
+ };
1418
+ const runPrompt = (display, prompt, persistDisplay,
1419
+ /** The goal's own caps for this run (what is left of its token budget and time). */
1420
+ caps) => {
460
1421
  // Any pending clipboard-pasted images ride along with the very next turn, then are cleared.
461
1422
  const attachments = pendingAttachments.map(toApiAttachment);
462
1423
  pendingAttachments = [];
463
1424
  attachmentsBar.invalidate();
464
1425
  push({ kind: "user", text: display });
1426
+ // The ACTIVE agent drives the main session: its system prompt is appended per run and a
1427
+ // read-only agent narrows the run to reads (no approvals). The persisted reasoning effort is
1428
+ // resolved against the ACTIVE model and sent when the model advertises supported levels.
1429
+ const agent = currentAgent();
1430
+ // An explicit /effort wins; otherwise the agent's own default effort (agentRunOptions) is kept.
1431
+ const effort = app.config.agents.effort || !agent.effort ? currentEffort() : undefined;
1432
+ // The goal tools are offered while the session has an ACTIVE goal (never to the plan agent).
1433
+ const agentOptions = agentRunOptions(agent);
1434
+ const goalTools = goalOptInTools(app.goals.get(app.store.rootOf(session)), agent.id === "plan");
1435
+ const optInTools = [...(agentOptions.optInTools ?? []), ...goalTools];
1436
+ const runId = crypto.randomUUID();
465
1437
  return task((signal) => app.runner.run(session, prompt, signal, {
1438
+ ...agentOptions,
1439
+ runId,
1440
+ ...(optInTools.length ? { optInTools } : {}),
1441
+ ...(effort ? { reasoningEffort: effort } : {}),
466
1442
  ...(persistDisplay ? { display: persistDisplay } : {}),
467
1443
  ...(attachments.length ? { attachments } : {}),
468
- }));
1444
+ ...(caps?.maxTokens !== undefined ? { maxTokens: caps.maxTokens } : {}),
1445
+ ...(caps?.timeoutMs !== undefined ? { timeoutMs: caps.timeoutMs } : {}),
1446
+ }), { runId });
469
1447
  };
470
1448
  let resolveExit = () => { };
471
1449
  const exited = new Promise((resolve) => {
@@ -476,7 +1454,7 @@ export async function runTui(options) {
476
1454
  const endSession = async (reason) => {
477
1455
  if (!view.stats.runs || !app.plugins.hasSessionEndHooks)
478
1456
  return;
479
- flashHint("Running session-end plugin hooks (bounded by pluginHooks.sessionEndTimeoutMs)…", 60_000);
1457
+ flashHint("Running session-end plugin hooks…", 60_000);
480
1458
  tui.renderNow?.();
481
1459
  try {
482
1460
  const { failures } = await app.endSession(session, reason);
@@ -496,8 +1474,12 @@ export async function runTui(options) {
496
1474
  return;
497
1475
  stopping = true;
498
1476
  controller?.abort(new Error("Exiting"));
499
- await Promise.race([pending?.catch(() => { }), new Promise((r) => setTimeout(r, 3000))]);
500
- await endSession("exit");
1477
+ // Exit must feel instant: an in-flight turn gets up to 3s (the abort above settles it
1478
+ // immediately in practice), then session-end hooks get a short cap — /clear still honors
1479
+ // the full pluginHooks.sessionEndTimeoutMs. app.close() later in the main path is itself
1480
+ // parallel and capped, so the whole exit path is bounded end to end.
1481
+ await bounded(pending?.catch(() => { }), EXIT_PENDING_CAP_MS);
1482
+ await bounded(endSession("exit"), EXIT_SESSION_END_CAP_MS);
501
1483
  resolveExit();
502
1484
  };
503
1485
  const askInput = (input) => new Promise((resolve) => {
@@ -922,6 +1904,647 @@ export async function runTui(options) {
922
1904
  };
923
1905
  openCatalog();
924
1906
  };
1907
+ /**
1908
+ * `/agents` (active agent): a navigable picker listing every selectable main-session agent with
1909
+ * its description. Selecting one persists `agents.active` globally (applies from the next
1910
+ * prompt), optionally switches the session model to the agent's declared model, and starts a
1911
+ * fresh session just like `/model` when it does. Esc returns to the editor.
1912
+ */
1913
+ const openAgentsPicker = () => {
1914
+ const agents = mainAgents();
1915
+ const current = currentAgent();
1916
+ const rows = [...AGENT_PICKER_ACTIONS, ...agentPickerRows(agents, current.id, agentRoots())];
1917
+ showPicker(new Picker("Agents", rows, (item) => {
1918
+ if (item.value === "__agents:new") {
1919
+ closePicker();
1920
+ return void newAgentFlow().catch(error);
1921
+ }
1922
+ if (item.value === "__agents:manage") {
1923
+ closePicker();
1924
+ return void manageAgents().catch(error);
1925
+ }
1926
+ const agent = agents.find((candidate) => candidate.id === item.value);
1927
+ if (agent)
1928
+ void applyActiveAgent(agent);
1929
+ }, closePicker, true, "Type to search by name or description. Selecting an agent activates it from the next prompt; build (default) returns to the default Alisio agent.", (items, filter) => filterAgentRows(items, filter)));
1930
+ };
1931
+ const applyActiveAgent = (agent) => {
1932
+ void (async () => {
1933
+ closePicker();
1934
+ const previous = currentAgent().name;
1935
+ try {
1936
+ await app.updateSetting("agents.active", agent.id);
1937
+ let switched = false;
1938
+ if (agent.model && app.store.get(session).model !== agent.model) {
1939
+ flashHint(`Switching to ${agent.name}'s model ${agent.model}…`, 60_000);
1940
+ const created = await app.switchModel(agent.model);
1941
+ if (created) {
1942
+ activeProvider = app.providerInfo;
1943
+ modelList = undefined;
1944
+ session = created.id;
1945
+ reset(initialViewState(created.model));
1946
+ switched = true;
1947
+ }
1948
+ }
1949
+ notice(`Active agent: ${agent.name}${agent.readOnly ? " (read-only)" : ""}. Applies from the next prompt.`);
1950
+ if (agent.model && switched)
1951
+ notice(`Agent ${agent.name} switches the session model to ${app.store.get(session).model}.`);
1952
+ else if (agent.model)
1953
+ flashHint(`Agent ${agent.name} keeps the current model (already ${view.model}). Override any time with /model.`);
1954
+ else
1955
+ flashHint(`Active agent: ${agent.name} (was ${previous}).`);
1956
+ refreshEffort();
1957
+ primeModels();
1958
+ tui.requestRender();
1959
+ refreshGoal();
1960
+ if (!busy)
1961
+ kickGoal();
1962
+ }
1963
+ catch (cause) {
1964
+ error(cause);
1965
+ }
1966
+ })();
1967
+ };
1968
+ // ---- Agent manager: project/global `.agents/agents` files (shared core service) ----------
1969
+ const agentRoots = () => ({ workspace: app.workspace, home: homedir() });
1970
+ let agentModels;
1971
+ /** Configured models with capabilities (cached per process; the running model always listed). */
1972
+ const loadAgentModels = async () => {
1973
+ if (agentModels)
1974
+ return agentModels;
1975
+ let options = [];
1976
+ try {
1977
+ options = agentModelOptions(await app.listAvailableModels(), {
1978
+ ...(app.providerInfo?.profileName ? { profile: app.providerInfo.profileName } : {}),
1979
+ ...(app.providerInfo?.id ? { provider: app.providerInfo.id } : {}),
1980
+ model: view.model,
1981
+ });
1982
+ }
1983
+ catch {
1984
+ /* configured catalogs unavailable: offer the running model only */
1985
+ }
1986
+ if (!options.some((option) => option.active)) {
1987
+ const capabilities = agentModelCapabilities(modelCatalog.get(view.model) ?? { id: view.model });
1988
+ options.unshift({
1989
+ reference: view.model,
1990
+ provider: app.provider.id,
1991
+ profile: app.providerInfo?.profileName ?? "",
1992
+ providerName: app.providers.get(activeProvider?.id ?? "")?.name ?? app.provider.id,
1993
+ id: view.model,
1994
+ active: true,
1995
+ capabilities,
1996
+ labels: agentModelLabels(capabilities),
1997
+ });
1998
+ }
1999
+ agentModels = options;
2000
+ return options;
2001
+ };
2002
+ const capabilitiesOf = (models, reference) => models.find((m) => m.reference === reference || m.id === reference)?.capabilities ??
2003
+ agentModelCapabilities(undefined);
2004
+ const defaultAgentModel = (models) => models.find((m) => m.active)?.reference ?? view.model;
2005
+ /**
2006
+ * "Building with Alisio": one tool-less call of the ACTIVE model writes (or refines) the agent
2007
+ * with the authoring guidance (a discovered `create-agent` skill, else the bundled one). Runs as
2008
+ * a task, so Esc cancels it; failures are reported and leave the form untouched.
2009
+ */
2010
+ const buildWithAlisio = async (description, base) => {
2011
+ let draft;
2012
+ flashHint("◐ Building with Alisio… writing the agent instructions · Esc to cancel", 180_000);
2013
+ await task(async (signal) => {
2014
+ draft = await generateAgentDraft(app.provider, description, {
2015
+ signal,
2016
+ guidance: await agentAuthoringGuidance(app.skills),
2017
+ ...(base
2018
+ ? {
2019
+ base: {
2020
+ name: base.name,
2021
+ description: base.description,
2022
+ instructions: base.instructions,
2023
+ },
2024
+ }
2025
+ : {}),
2026
+ });
2027
+ });
2028
+ flashHint(draft
2029
+ ? `Draft by ${draft.generatedBy} (${draft.guidance}). Review it, then save.`
2030
+ : "Building with Alisio stopped; nothing was changed.", 5000);
2031
+ return draft;
2032
+ };
2033
+ /** Saves a form; reports honestly whether the running registry already uses the change. */
2034
+ const saveAgentForm = async (form, models) => {
2035
+ const input = formToInput(form, capabilitiesOf(models, form.model));
2036
+ const change = form.id
2037
+ ? await app.agentDefinitions.update(form.scope, form.id, input)
2038
+ : await app.agentDefinitions.create(form.scope, input);
2039
+ editor.setAutocompleteProvider(buildSlashCompletionProvider());
2040
+ notice(change.live
2041
+ ? `Agent ${change.result.name} saved to ${change.result.path} and available now: /agent:${change.result.id}.`
2042
+ : `Agent ${change.result.name} saved to ${change.result.path}. Restart Alisio to use it${form.scope === "project" ? " (project agents also need a trusted workspace)" : ""}.`);
2043
+ return change;
2044
+ };
2045
+ /** Starts a fresh session with `id` as the active agent (the "Try it" action). */
2046
+ const tryAgentInNewSession = async (id) => {
2047
+ const agent = mainAgents().find((candidate) => candidate.id === id);
2048
+ if (!agent)
2049
+ return notice(`Agent ${id} is not loaded in this process; restart Alisio to try it.`);
2050
+ await endSession("clear");
2051
+ await app.updateSetting("agents.active", agent.id);
2052
+ let created;
2053
+ if (agent.model && agent.model !== view.model)
2054
+ created = await app.switchModel(agent.model).catch(() => undefined);
2055
+ if (created) {
2056
+ activeProvider = app.providerInfo;
2057
+ modelList = undefined;
2058
+ session = created.id;
2059
+ reset(initialViewState(created.model));
2060
+ }
2061
+ else {
2062
+ session = app.store.create(app.workspace, app.provider.id, view.model).id;
2063
+ reset(initialViewState(view.model));
2064
+ }
2065
+ notice(`New session ${session} with agent ${agent.name}. Type a prompt to try it.`);
2066
+ void app.herdr.report("idle", session);
2067
+ refreshEffort();
2068
+ refreshEstimate();
2069
+ };
2070
+ /** After the first save: try the agent in a new session (default) or keep editing. */
2071
+ const offerTryAgent = async (form, models, live) => {
2072
+ const choice = await askChoice(`Agent ${form.name} created`, live
2073
+ ? [
2074
+ { value: "try", label: "▶ Try it in a new session", description: "Default" },
2075
+ { value: "stay", label: "Stay in editor" },
2076
+ ]
2077
+ : [{ value: "stay", label: "Stay in editor", description: "Restart Alisio to try it" }]);
2078
+ if (choice === "try" && form.id)
2079
+ return tryAgentInNewSession(form.id);
2080
+ if (choice === "stay")
2081
+ return openAgentEditor(form, models, form);
2082
+ };
2083
+ /** The editor: one picker row per field; each edit returns to it. Esc closes. */
2084
+ const openAgentEditor = async (initial, models, saved) => {
2085
+ let form = initial;
2086
+ let baseline = saved ? JSON.stringify(saved) : undefined;
2087
+ const show = () => {
2088
+ const capabilities = capabilitiesOf(models, form.model);
2089
+ const dirty = baseline === undefined || JSON.stringify(form) !== baseline;
2090
+ showPicker(new Picker(form.id ? `Agents › ${form.name}` : "Agents › New agent", agentEditorRows(form, capabilities, {
2091
+ creating: !form.id,
2092
+ scopes: app.agentDefinitions.scopes,
2093
+ dirty,
2094
+ }), (item) => {
2095
+ closePicker();
2096
+ void edit(item.value, dirty).catch((cause) => {
2097
+ error(cause);
2098
+ show();
2099
+ });
2100
+ }, closePicker, false, `${form.scope === "project" ? "Project" : "Global"} scope · ${shortenPath(app.agentDefinitions.dir(form.scope), homedir())}`));
2101
+ };
2102
+ const input = (label, initialValue, placeholder, hint) => askInput({
2103
+ provider: "agent",
2104
+ title: form.id ? `Agents › ${form.name}` : "Agents › New agent",
2105
+ label,
2106
+ initial: initialValue,
2107
+ placeholder,
2108
+ ...(hint ? { hint } : {}),
2109
+ step: 1,
2110
+ steps: 1,
2111
+ });
2112
+ const edit = async (field, dirty) => {
2113
+ const capabilities = capabilitiesOf(models, form.model);
2114
+ switch (field) {
2115
+ case "scope": {
2116
+ const scope = await askChoice("Scope", app.agentDefinitions.scopes.map((value) => ({
2117
+ value,
2118
+ label: value === "project" ? "Project" : "Global",
2119
+ description: app.agentDefinitions.dir(value),
2120
+ })));
2121
+ if (scope === "project" || scope === "global")
2122
+ form = { ...form, scope };
2123
+ break;
2124
+ }
2125
+ case "name": {
2126
+ const value = await input("Name", form.name, "New agent");
2127
+ if (value !== undefined)
2128
+ form = { ...form, name: value.trim() };
2129
+ break;
2130
+ }
2131
+ case "description": {
2132
+ const value = await input("Description", form.description, "One sentence: what this agent does");
2133
+ if (value !== undefined)
2134
+ form = { ...form, description: value.trim() };
2135
+ break;
2136
+ }
2137
+ case "instructions": {
2138
+ const value = await input("Instructions", encodeMultiline(form.instructions), "Describe desired model behavior (tone, tool usage, response style)", "Type \\n for a new line. ✦ Refine with Alisio writes them for you.");
2139
+ if (value !== undefined)
2140
+ form = { ...form, instructions: decodeMultiline(value) };
2141
+ break;
2142
+ }
2143
+ case "model": {
2144
+ const value = await askChoice("Model", models.map((m) => ({
2145
+ value: m.reference,
2146
+ label: `${m.name ?? m.id} · ${m.providerName}${m.active ? " (active)" : ""}${m.reference === form.model ? " ✓" : ""}`,
2147
+ description: m.labels.join(" · ") || "capabilities not declared by the provider",
2148
+ })));
2149
+ if (value) {
2150
+ const fitted = formToInput({ ...form, model: value }, capabilitiesOf(models, value));
2151
+ form = {
2152
+ ...form,
2153
+ model: value,
2154
+ reasoning: { ...(fitted.reasoning ?? {}) },
2155
+ text: {
2156
+ ...(fitted.text?.format ? { format: fitted.text.format.type } : {}),
2157
+ ...(fitted.text?.verbosity ? { verbosity: fitted.text.verbosity } : {}),
2158
+ },
2159
+ };
2160
+ }
2161
+ break;
2162
+ }
2163
+ case "effort":
2164
+ case "summary":
2165
+ case "verbosity":
2166
+ case "format": {
2167
+ if (field !== "format" && !agentFieldSupported(field, capabilities)) {
2168
+ flashHint(`${form.model} does not support this setting.`);
2169
+ break;
2170
+ }
2171
+ const value = await askChoice(field, agentFieldChoices(field, capabilities));
2172
+ if (value === undefined)
2173
+ break;
2174
+ const clear = value === "!clear" ? undefined : value;
2175
+ if (field === "effort")
2176
+ form = { ...form, reasoning: { ...form.reasoning, effort: clear } };
2177
+ if (field === "summary")
2178
+ form = {
2179
+ ...form,
2180
+ reasoning: { ...form.reasoning, summary: clear },
2181
+ };
2182
+ if (field === "verbosity")
2183
+ form = {
2184
+ ...form,
2185
+ text: { ...form.text, verbosity: clear },
2186
+ };
2187
+ if (field === "format")
2188
+ form = {
2189
+ ...form,
2190
+ text: { ...form.text, format: clear },
2191
+ };
2192
+ break;
2193
+ }
2194
+ case "refine": {
2195
+ const changes = await input("What should change? (optional)", "", "e.g. focus on security, answer in Spanish");
2196
+ if (changes === undefined)
2197
+ break;
2198
+ const draft = await buildWithAlisio(changes, form);
2199
+ if (draft)
2200
+ form = applyDraft(form, draft);
2201
+ break;
2202
+ }
2203
+ case "save": {
2204
+ const errors = formErrors(form);
2205
+ if (errors.length) {
2206
+ flashHint(errors.join("; "), 5000);
2207
+ break;
2208
+ }
2209
+ if (!dirty) {
2210
+ flashHint("No unsaved changes");
2211
+ break;
2212
+ }
2213
+ const creating = !form.id;
2214
+ const change = await saveAgentForm(form, models);
2215
+ form = { ...form, id: change.result.id, name: change.result.name };
2216
+ baseline = JSON.stringify(form);
2217
+ if (creating)
2218
+ return offerTryAgent(form, models, change.live);
2219
+ break;
2220
+ }
2221
+ case "cancel": {
2222
+ if (dirty) {
2223
+ const discard = await askChoice("Discard unsaved changes?", [
2224
+ { value: "keep", label: "Keep editing" },
2225
+ { value: "discard", label: "Discard and close" },
2226
+ ]);
2227
+ if (discard === "discard")
2228
+ return;
2229
+ break;
2230
+ }
2231
+ return;
2232
+ }
2233
+ }
2234
+ show();
2235
+ };
2236
+ show();
2237
+ };
2238
+ /** `/agents new [description]`: assisted (with a description), template or blank. */
2239
+ const newAgentFlow = async (description) => {
2240
+ const models = await loadAgentModels();
2241
+ const blank = emptyAgentForm(app.agentDefinitions.defaultScope, defaultAgentModel(models));
2242
+ let mode = description ? "assisted" : undefined;
2243
+ if (!mode)
2244
+ mode = await askChoice("Agents › New agent", [
2245
+ {
2246
+ value: "assisted",
2247
+ label: "✦ Describe it — Alisio writes the agent",
2248
+ description: "The active model drafts the name, instructions and settings",
2249
+ },
2250
+ { value: "template", label: "From a template…" },
2251
+ { value: "blank", label: "Blank agent" },
2252
+ ]);
2253
+ if (mode === "template")
2254
+ return chooseAgentTemplate();
2255
+ if (mode === "blank")
2256
+ return openAgentEditor(blank, models);
2257
+ if (mode !== "assisted")
2258
+ return;
2259
+ const text = description ??
2260
+ (await askInput({
2261
+ provider: "agent",
2262
+ title: "Agents › New agent",
2263
+ label: "Describe the agent you want",
2264
+ initial: "",
2265
+ placeholder: "e.g. reviews pull requests for security issues",
2266
+ step: 1,
2267
+ steps: 1,
2268
+ }));
2269
+ if (!text?.trim())
2270
+ return;
2271
+ const draft = await buildWithAlisio(text);
2272
+ if (!draft)
2273
+ return;
2274
+ const form = applyDraft(blank, draft);
2275
+ if (formErrors(form).length)
2276
+ return openAgentEditor(form, models);
2277
+ const change = await saveAgentForm(form, models);
2278
+ return offerTryAgent({ ...form, id: change.result.id }, models, change.live);
2279
+ };
2280
+ /** `/agents templates`: pick a template, then customize it (and optionally refine it). */
2281
+ const chooseAgentTemplate = async () => {
2282
+ const models = await loadAgentModels();
2283
+ showPicker(new Picker("Agents › Templates", AGENT_TEMPLATES.map((template) => ({
2284
+ value: template.id,
2285
+ label: template.name,
2286
+ description: template.description,
2287
+ })), (item) => {
2288
+ closePicker();
2289
+ const template = AGENT_TEMPLATES.find((candidate) => candidate.id === item.value);
2290
+ if (template)
2291
+ void openAgentEditor(formFromTemplate(template, app.agentDefinitions.defaultScope, defaultAgentModel(models)), models).catch(error);
2292
+ }, closePicker, true, "Type to search. The template opens in the editor; ✦ Refine with Alisio adapts it to your changes.", (items, filter) => filterAgentRows(items, filter)));
2293
+ };
2294
+ /** `/agents manage`: saved project/global agents → edit, activate, try or delete. */
2295
+ const manageAgents = async () => {
2296
+ const list = await app.agentDefinitions.list();
2297
+ if (!list.length)
2298
+ return notice("No saved agents yet. Create one with /agents new.");
2299
+ showPicker(new Picker("Agents › Saved agents", list.map((a) => ({
2300
+ value: `${a.scope}:${a.id}`,
2301
+ label: `${a.name} [${a.scope === "project" ? "Project" : "Global"}]${a.overridesGlobal ? " overrides global" : ""}${a.overriddenByProject ? " (overridden by project)" : ""}`,
2302
+ description: [a.description, a.model].filter(Boolean).join(" · "),
2303
+ })), (item) => {
2304
+ closePicker();
2305
+ const found = list.find((a) => `${a.scope}:${a.id}` === item.value);
2306
+ if (found)
2307
+ void agentActions(found).catch(error);
2308
+ }, closePicker, true, `Project: ${app.agentDefinitions.scopes.includes("project") ? app.agentDefinitions.dir("project") : "—"} · Global: ${app.agentDefinitions.dir("global")}`, (items, filter) => filterAgentRows(items, filter)));
2309
+ };
2310
+ const agentActions = async (definition) => {
2311
+ const action = await askChoice(`${definition.name} [${definition.scope}]`, [
2312
+ { value: "edit", label: "Edit" },
2313
+ { value: "activate", label: "Activate in this session", description: "From the next prompt" },
2314
+ { value: "try", label: "Try it in a new session" },
2315
+ { value: "delete", label: "Delete", description: definition.path },
2316
+ ]);
2317
+ if (action === "edit") {
2318
+ const form = formFromDefinition(definition);
2319
+ return openAgentEditor(form, await loadAgentModels(), form);
2320
+ }
2321
+ if (action === "try")
2322
+ return tryAgentInNewSession(definition.id);
2323
+ if (action === "activate") {
2324
+ const agent = mainAgents().find((candidate) => candidate.id === definition.id);
2325
+ if (!agent)
2326
+ return notice(`Agent ${definition.id} is not loaded; restart Alisio to use it.`);
2327
+ return applyActiveAgent(agent);
2328
+ }
2329
+ if (action === "delete") {
2330
+ const confirm = await askChoice(`Delete ${definition.path}?`, [
2331
+ { value: "no", label: "Keep it" },
2332
+ { value: "yes", label: "Delete the file" },
2333
+ ]);
2334
+ if (confirm !== "yes")
2335
+ return;
2336
+ const change = await app.agentDefinitions.delete(definition.scope, definition.id);
2337
+ editor.setAutocompleteProvider(buildSlashCompletionProvider());
2338
+ notice(change.live
2339
+ ? `Deleted agent ${definition.name}; it is no longer offered.`
2340
+ : `Deleted ${definition.path}. Restart Alisio to drop it from the running agents.`);
2341
+ }
2342
+ };
2343
+ /** Routes `/agents [verb]`: TUI manager verbs, else the subagents plugin's verbs. */
2344
+ const agentsCommand = async (args, raw) => {
2345
+ const command = parseAgentsArgs(args);
2346
+ if (command.kind === "plugin") {
2347
+ const handler = app.plugins.commands.get("agents");
2348
+ if (!handler)
2349
+ return error("Subagent task management needs the Subagents plugin.");
2350
+ const output = await handler(command.args, { sessionId: session });
2351
+ if (/^reload\b/.test(command.args))
2352
+ editor.setAutocompleteProvider(buildSlashCompletionProvider());
2353
+ return info(output);
2354
+ }
2355
+ if (busy) {
2356
+ editor.setText(raw);
2357
+ flashHint("A turn is running: wait for it to finish before managing agents");
2358
+ return;
2359
+ }
2360
+ switch (command.kind) {
2361
+ case "picker":
2362
+ // Pick up files edited outside Alisio before listing.
2363
+ await app.agentDefinitions.reload();
2364
+ return openAgentsPicker();
2365
+ case "new":
2366
+ return newAgentFlow(command.description);
2367
+ case "templates":
2368
+ return chooseAgentTemplate();
2369
+ case "manage":
2370
+ return manageAgents();
2371
+ case "edit":
2372
+ case "delete": {
2373
+ const list = await app.agentDefinitions.list();
2374
+ const found = list.find((a) => a.id === command.id && (!command.scope || a.scope === command.scope)) ??
2375
+ undefined;
2376
+ if (!found)
2377
+ return error(`No saved agent ${command.id}. See /agents manage.`);
2378
+ if (command.kind === "edit") {
2379
+ const form = formFromDefinition(found);
2380
+ return openAgentEditor(form, await loadAgentModels(), form);
2381
+ }
2382
+ return agentActions(found);
2383
+ }
2384
+ }
2385
+ };
2386
+ /**
2387
+ * `/effort [level]`: without an argument, a picker over the ACTIVE model's advertised effort
2388
+ * levels (marking the model default); with an argument, the level is validated against
2389
+ * `effort.supportedLevels` and persisted globally. The chosen level is sent from the next
2390
+ * prompt; if the model later changes to one that does not support it, the model's default is
2391
+ * used silently with a one-time notice.
2392
+ */
2393
+ const setEffort = async (args) => {
2394
+ const capability = effortSupportFor(view.model);
2395
+ if (!args) {
2396
+ if (!capability?.supportedLevels?.length)
2397
+ return notice(`${view.model} does not advertise reasoning effort levels (ModelInfo.effort), so /effort is not available for it.`);
2398
+ const items = effortPickerItems(capability, currentEffort());
2399
+ showPicker(new Picker(`Reasoning effort · ${modelDisplayName(view.model)}`, items, (item) => void applyEffort(item.value), closePicker, true));
2400
+ return;
2401
+ }
2402
+ const invalid = validateEffortLevel(args, capability);
2403
+ if (invalid)
2404
+ return notice(invalid);
2405
+ await applyEffort(args);
2406
+ };
2407
+ const applyEffort = async (value) => {
2408
+ const clear = value === "!clear";
2409
+ closePicker();
2410
+ try {
2411
+ await app.updateSetting("agents.effort", clear ? undefined : value);
2412
+ const capability = effortSupportFor(view.model);
2413
+ const effective = effectiveEffort(clear ? undefined : value, capability);
2414
+ notice(clear
2415
+ ? `Reasoning effort cleared; ${capability?.defaultLevel ? `the model default "${capability.defaultLevel}" is used` : "the provider default is used"}.`
2416
+ : `Reasoning effort: ${value}${value === capability?.defaultLevel ? ` (default for ${modelDisplayName(view.model)})` : ""}${effective && effective !== value ? ` — effective: ${effective}` : ""}. Applies from the next prompt.`);
2417
+ refreshEffort();
2418
+ tui.requestRender();
2419
+ }
2420
+ catch (cause) {
2421
+ error(cause);
2422
+ }
2423
+ };
2424
+ /**
2425
+ * Navigation rows at the bottom of `/settings`: they route to the existing managers and one-shot
2426
+ * actions exactly like the old picker, so every prior entry stays reachable. `compact` and
2427
+ * `stats` finish here and re-open the settings list (mirroring managePlugins' openCatalog loop);
2428
+ * rows that delegate to another manager (provider/model, connect, plugins, skills, MCP) leave
2429
+ * the list and that manager owns its Esc/back behavior.
2430
+ */
2431
+ const SETTINGS_NAVIGATION = [
2432
+ {
2433
+ id: "model",
2434
+ label: "Provider & model",
2435
+ description: "Switch the active provider and model",
2436
+ },
2437
+ {
2438
+ id: "connect",
2439
+ label: "Connect provider",
2440
+ description: "Configure a provider and choose its active model",
2441
+ },
2442
+ {
2443
+ id: "compact",
2444
+ label: "Compact context now",
2445
+ description: "Summarize older history with the current model, then return here",
2446
+ },
2447
+ { id: "plugins", label: "Plugins", description: "Browse and manage project plugins" },
2448
+ { id: "skills", label: "Skills", description: "Browse and manage effective skills" },
2449
+ { id: "mcp", label: "MCP servers", description: "Browse and manage MCP servers" },
2450
+ {
2451
+ id: "stats",
2452
+ label: "Session statistics",
2453
+ description: "View session statistics, then return here",
2454
+ },
2455
+ ];
2456
+ /**
2457
+ * Central `/settings` (`/prefs`) menu: an OpenCode-style list of REAL, wired preferences
2458
+ * (compaction, context fallback, MCP consent, limits, editor padding) with a live type-to-search
2459
+ * filter, Enter/Space to cycle a value, a `(n/total)` counter, and a footer describing the
2460
+ * highlighted row. Esc returns to the editor. Every setting persists to the GLOBAL user config
2461
+ * through `app.updateSetting` (or the MCP consent path) and is applied to the running process
2462
+ * where supported; rows rebuild from the live config after each change.
2463
+ */
2464
+ const openSettings = () => {
2465
+ const providerName = app.providers.get(activeProvider?.id ?? "")?.name;
2466
+ const current = view.model
2467
+ ? providerName
2468
+ ? `${providerName} · ${view.model}`
2469
+ : view.model
2470
+ : "";
2471
+ const build = () => settingsMenuRows({
2472
+ config: app.config,
2473
+ mcpAllowPersisted: app.mcpAllowPersisted(),
2474
+ readOnly: !!options.readOnly,
2475
+ }, SETTINGS_NAVIGATION);
2476
+ const applySetting = (row, value) => {
2477
+ const settle = () => settingsMenu?.refresh(build());
2478
+ if (row.id === "mcp.allow") {
2479
+ const consent = value === true ? app.rememberGlobalMcpConsent() : app.revokeGlobalMcpConsent();
2480
+ void consent
2481
+ .then(() => notice(value === true
2482
+ ? "MCP consent remembered globally (mcp.allow); enabled servers will auto-connect on every start. Use /mcps to connect them now."
2483
+ : "Global MCP consent revoked: runtime permission dropped and configured servers disconnected."))
2484
+ .catch(error)
2485
+ .finally(settle);
2486
+ return;
2487
+ }
2488
+ const stored = row.id === "limits.timeoutMs" || row.id === "analysis.limits.timeoutMs"
2489
+ ? Number(value) * 1000
2490
+ : value;
2491
+ void app
2492
+ // Setting ids are the exact SettableSettingKey paths SETTINGS_DEFINITIONS is built from.
2493
+ .updateSetting(row.id, stored)
2494
+ .then(() => {
2495
+ if (row.id === "tui.paddingX")
2496
+ editor.setPaddingX(Number(value));
2497
+ // The inset wrapper reads the live config; repaint so the new value shows immediately.
2498
+ if (row.id === "tui.contentPaddingX")
2499
+ tui.requestRender();
2500
+ // Rebuild the slash provider: the toggle gates the skill:<id> entries immediately.
2501
+ if (row.id === "tui.skillSlashCommands")
2502
+ editor.setAutocompleteProvider(buildSlashCompletionProvider());
2503
+ })
2504
+ .catch(error)
2505
+ .finally(settle);
2506
+ };
2507
+ const navigate = (row) => {
2508
+ switch (row.action) {
2509
+ case "model":
2510
+ closePicker();
2511
+ return void chooseModel();
2512
+ case "connect":
2513
+ closePicker();
2514
+ return void connect();
2515
+ case "compact":
2516
+ closePicker();
2517
+ void task((signal) => app.runner.compact(session, { signal }))
2518
+ .catch(error)
2519
+ .then(openSettings);
2520
+ return;
2521
+ case "plugins":
2522
+ closePicker();
2523
+ return managePlugins();
2524
+ case "skills":
2525
+ closePicker();
2526
+ return manageSkills();
2527
+ case "mcp":
2528
+ closePicker();
2529
+ return manageMcp();
2530
+ case "stats":
2531
+ closePicker();
2532
+ info(statsReport());
2533
+ openSettings();
2534
+ return;
2535
+ default:
2536
+ return openSettings();
2537
+ }
2538
+ };
2539
+ const menu = new SettingsMenu("Settings", build(), applySetting, navigate, closePicker, [
2540
+ current ? `Current: ${current}` : "",
2541
+ ...(options.readOnly ? ["Read-only run: changes are not persisted"] : []),
2542
+ ]
2543
+ .filter(Boolean)
2544
+ .join("\n") || undefined);
2545
+ settingsMenu = menu;
2546
+ showPicker(menu);
2547
+ };
925
2548
  const workspaceSessions = () => app.store.list().filter((s) => s.workspace === app.workspace && s.provider === app.provider.id);
926
2549
  const firstPrompt = (id) => {
927
2550
  const first = app.store.messages(id).find((m) => m.role === "user" && !m.summary);
@@ -942,24 +2565,9 @@ export async function runTui(options) {
942
2565
  items: itemsFromHistory(app.store.messages(found.id)),
943
2566
  });
944
2567
  notice(`Resumed session ${found.id}`);
2568
+ refreshGoal();
945
2569
  refreshEstimate();
946
2570
  };
947
- const toolsReport = () => {
948
- const policy = app.runner.policy;
949
- const rows = app.registry
950
- .list()
951
- .map((t) => {
952
- const effect = t.effect ?? "external";
953
- const state = effect === "read" || effect === "internal" || policy[effect]
954
- ? "enabled"
955
- : app.runner.approvals && (effect === "write" || effect === "process")
956
- ? "ask"
957
- : "disabled";
958
- return `| \`${t.name}\` | ${effect} | ${state} |`;
959
- })
960
- .join("\n");
961
- return `**Tools**\n\n| Tool | Effect | State |\n| --- | --- | --- |\n${rows}\n\n\`ask\` prompts before running (allow once / session / deny). Use --allow-write / --allow-process to pre-allow; --read-only disables them. \`internal\` tools (built-in plugins) only write Alisio's own state.`;
962
- };
963
2571
  const statsReport = () => {
964
2572
  const s = view.stats;
965
2573
  const budget = app.contextBudget(view.model);
@@ -1016,23 +2624,82 @@ export async function runTui(options) {
1016
2624
  "- `/init` (above) writes AGENTS.md; the shell command `alisio setup` only scaffolds `.alisio/config.json`",
1017
2625
  "- `/command plugin.id:name args` — run a plugin command",
1018
2626
  "",
1019
- "**Keys**: Enter send · Shift+Enter / Alt+Enter / Ctrl+J newline · Tab complete · ↑↓ history · Esc interrupt · Ctrl+C clear input (twice to exit) · Ctrl+D exit on empty input · PgUp/PgDn or mouse wheel scroll · Ctrl+X agent panel · Ctrl+B background running agents",
2627
+ "**Keys**: Enter send · Shift+Enter / Alt+Enter / Ctrl+J newline · Tab complete · ↑↓ history · Esc interrupt · Ctrl+C clear input (twice to exit) · Ctrl+D exit on empty input · c / y copy last response (empty input) · x expand/collapse latest thought/tool batch/output (empty input) · click a collapsible header row to toggle it · PgUp/PgDn or mouse wheel scroll · Ctrl+X agent panel · Ctrl+B background running agents · Shift+Tab cycle the main agents (build, plan, your own)",
1020
2628
  `**Paste**: multi-line text pastes as one block automatically · Ctrl+V attach a clipboard image (PNG/JPEG/GIF/WebP, up to ${(MAX_IMAGE_BYTES / (1024 * 1024)).toFixed(0)} MB, up to ${MAX_ATTACHMENTS_PER_MESSAGE} per message) · Ctrl+R remove the last attached image`,
1021
2629
  ].join("\n");
2630
+ // `/btw` side questions: answered in the picker slot, never appended to the transcript. One
2631
+ // question is in flight at a time; Esc cancels it (independent of the run's controller, so a
2632
+ // side question also works, and is cancelled on its own, while a turn is running).
2633
+ let btwController;
2634
+ let btwPanel;
2635
+ const btwHeight = () => Math.max(4, Math.floor((process.stdout.rows ?? 30) / 2) - 6);
2636
+ const closeBtw = () => {
2637
+ btwController?.abort(new Error("Cancelled"));
2638
+ btwController = undefined;
2639
+ if (btwPanel && picker === btwPanel)
2640
+ closePicker();
2641
+ btwPanel = undefined;
2642
+ };
2643
+ const sideQuestion = async (question) => {
2644
+ const asked = session;
2645
+ const entries = app.sideQuestions.history(asked);
2646
+ if (!question) {
2647
+ const state = btwBrowser(entries);
2648
+ if (!state)
2649
+ return notice(`${BTW_USAGE}\n${BTW_DESCRIPTION}`);
2650
+ btwPanel = new BtwPanel(state, closeBtw, btwHeight);
2651
+ return showPicker(btwPanel);
2652
+ }
2653
+ btwController?.abort(new Error("Cancelled"));
2654
+ const controller = new AbortController();
2655
+ btwController = controller;
2656
+ const panel = new BtwPanel(btwPending(entries, question), closeBtw, btwHeight);
2657
+ btwPanel = panel;
2658
+ showPicker(panel);
2659
+ try {
2660
+ await app.sideQuestions.ask(asked, question, { signal: controller.signal });
2661
+ if (controller.signal.aborted)
2662
+ return;
2663
+ panel.dispatch({ type: "answered", entries: app.sideQuestions.history(asked) });
2664
+ // An approval may have taken the slot meanwhile: the answer is in the history anyway.
2665
+ if (picker !== panel)
2666
+ flashHint("Side answer ready: /btw to view it", 4000);
2667
+ }
2668
+ catch (e) {
2669
+ if (controller.signal.aborted)
2670
+ return;
2671
+ panel.dispatch({ type: "failed", message: e instanceof Error ? e.message : String(e) });
2672
+ if (picker !== panel)
2673
+ error(e);
2674
+ }
2675
+ finally {
2676
+ if (btwController === controller)
2677
+ btwController = undefined;
2678
+ tui.requestRender();
2679
+ }
2680
+ };
2681
+ let commandCatalog = new CommandCatalog(app);
1022
2682
  const mutating = new Set([
1023
2683
  "connect",
1024
2684
  "model",
1025
2685
  "plugins",
1026
2686
  "skills",
1027
- "mcp",
2687
+ "mcps",
2688
+ "settings",
1028
2689
  "compact",
1029
2690
  "clear",
1030
2691
  "resume",
2692
+ "reload",
1031
2693
  ]);
1032
2694
  const handleSubmit = async (raw) => {
1033
2695
  const text = raw.trim();
1034
2696
  if (!text)
1035
2697
  return;
2698
+ if (reloading) {
2699
+ editor.setText(raw);
2700
+ flashHint("Reloading configuration: wait a moment");
2701
+ return;
2702
+ }
1036
2703
  editor.addToHistory(raw);
1037
2704
  const parsed = parseCommand(text);
1038
2705
  const name = parsed ? resolveCommand(parsed.name) : undefined;
@@ -1062,6 +2729,19 @@ export async function runTui(options) {
1062
2729
  return error(`Unknown plugin command: ${command ?? ""}`);
1063
2730
  return info(await handler(rest.join(" ")));
1064
2731
  }
2732
+ const agentId = agentIdFromCommand(parsed.name);
2733
+ if (agentId) {
2734
+ // `/agent:<id>`: activates a loaded agent from the next prompt (as the /agents picker).
2735
+ if (busy) {
2736
+ editor.setText(raw);
2737
+ flashHint("A turn is running: wait for it to finish before switching the active agent");
2738
+ return;
2739
+ }
2740
+ const agent = mainAgents().find((candidate) => candidate.id === agentId);
2741
+ if (!agent)
2742
+ return error(`Unknown agent ${agentId}. Open /agents to see the loaded agents.`);
2743
+ return applyActiveAgent(agent);
2744
+ }
1065
2745
  switch (name) {
1066
2746
  case "help":
1067
2747
  return info(helpReport());
@@ -1070,7 +2750,11 @@ export async function runTui(options) {
1070
2750
  case "stats":
1071
2751
  return info(statsReport());
1072
2752
  case "tools":
1073
- return info(toolsReport());
2753
+ case "sessions": {
2754
+ // Text-identical core handlers (parity-tested): output comes from the CommandCatalog.
2755
+ const result = await commandCatalog.execute(name, parsed.args, { sessionId: session });
2756
+ return result.tone === "notice" ? notice(result.text ?? "") : info(result.text ?? "");
2757
+ }
1074
2758
  case "connect":
1075
2759
  return await connect();
1076
2760
  case "model":
@@ -1079,8 +2763,10 @@ export async function runTui(options) {
1079
2763
  return managePlugins();
1080
2764
  case "skills":
1081
2765
  return manageSkills();
1082
- case "mcp":
2766
+ case "mcps":
1083
2767
  return manageMcp();
2768
+ case "settings":
2769
+ return openSettings();
1084
2770
  case "compact":
1085
2771
  return await task((signal) => app.runner.compact(session, { focus: parsed.args || undefined, signal }));
1086
2772
  case "copy": {
@@ -1092,6 +2778,28 @@ export async function runTui(options) {
1092
2778
  tui.flash(message);
1093
2779
  return result.ok ? undefined : notice(message);
1094
2780
  }
2781
+ case "btw":
2782
+ return await sideQuestion(parsed.args);
2783
+ case "artifacts":
2784
+ return browseArtifacts(parsed.args);
2785
+ case "tasks":
2786
+ return showTasks();
2787
+ case "goal":
2788
+ return goalCommand(parsed.args);
2789
+ case "permission": {
2790
+ const action = parsePermissionCommand(parsed.args);
2791
+ if (action.type === "menu")
2792
+ return openPermissionMenu();
2793
+ if (action.type === "status")
2794
+ return info(permissionStatusText());
2795
+ if (action.type === "set")
2796
+ return setPermissionMode(action.mode);
2797
+ return notice(PERMISSION_USAGE);
2798
+ }
2799
+ case "reload":
2800
+ return await reloadAll();
2801
+ case "changelog":
2802
+ return showChangelog(parsed.args);
1095
2803
  case "ask": {
1096
2804
  if (!parsed.args)
1097
2805
  return notice("Usage: /ask <question>");
@@ -1103,24 +2811,27 @@ export async function runTui(options) {
1103
2811
  `(reuse the user's own wording for the question text). Do not answer in plain text ` +
1104
2812
  `first; call the tool right away.`);
1105
2813
  }
2814
+ case "agents":
2815
+ // TUI manager verbs (picker, new, templates, manage, edit, delete); other verbs go to the
2816
+ // subagents plugin's own `/agents` command (list/open/cancel/kill/resume/merge/discard/
2817
+ // defs/reload), still available while a turn runs.
2818
+ return await agentsCommand(parsed.args, raw);
2819
+ case "effort":
2820
+ if (busy) {
2821
+ editor.setText(raw);
2822
+ flashHint("A turn is running: wait for it to finish before changing the effort");
2823
+ return;
2824
+ }
2825
+ return await setEffort(parsed.args);
1106
2826
  case "clear": {
1107
2827
  await endSession("clear");
1108
2828
  session = app.store.create(app.workspace, app.provider.id, view.model).id;
1109
2829
  reset(initialViewState(view.model));
1110
2830
  notice(`New session ${session}`);
1111
2831
  void app.herdr.report("idle", session);
2832
+ refreshGoal();
1112
2833
  return refreshEstimate();
1113
2834
  }
1114
- case "sessions": {
1115
- const list = workspaceSessions().slice(0, 20);
1116
- if (!list.length)
1117
- return notice("No sessions in this workspace");
1118
- return info([
1119
- "**Recent sessions** (use `/resume <id-prefix>`)",
1120
- "",
1121
- ...list.map((s) => `- \`${s.id}\` ${s.id === session ? "**(current)** " : ""}${s.model} · ${app.store.messages(s.id).length} msgs · ${firstPrompt(s.id) || "_empty_"}`),
1122
- ].join("\n"));
1123
- }
1124
2835
  case "resume":
1125
2836
  if (parsed.args)
1126
2837
  return resume(parsed.args);
@@ -1149,39 +2860,58 @@ export async function runTui(options) {
1149
2860
  editor.onSubmit = (text) => {
1150
2861
  void handleSubmit(text);
1151
2862
  };
1152
- editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
1153
- ...COMMANDS,
1154
- ...[...app.prompts.templates.values()].map((t) => ({
1155
- name: t.name,
1156
- description: `${t.description} (template)`,
1157
- ...(t.argumentHint ? { argumentHint: t.argumentHint } : {}),
1158
- })),
1159
- ...[...app.plugins.commandInfo.entries()]
1160
- .filter(([name]) => !resolveCommand(name))
1161
- .map(([name, c]) => ({
1162
- name,
1163
- description: c.description ?? `plugin ${c.plugin}`,
1164
- ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
1165
- })),
1166
- ].map((c) => ({
1167
- name: c.name,
1168
- description: c.description,
1169
- ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
1170
- ...(c.name === "resume"
1171
- ? {
1172
- getArgumentCompletions: (prefix) => workspaceSessions()
1173
- .filter((s) => s.id.startsWith(prefix))
1174
- .slice(0, 20)
1175
- .map((s) => ({ value: s.id, label: shortId(s.id), description: s.model })),
1176
- }
1177
- : {}),
1178
- })), app.workspace));
2863
+ /**
2864
+ * Builds the editor slash-autocomplete provider from the live config: `tui.skillSlashCommands`
2865
+ * gates the standalone `skill:<id>` entries (the `/skills` manager always keeps its argument
2866
+ * completion), so rebuilding after a settings save takes effect without a restart.
2867
+ */
2868
+ const buildSlashCompletionProvider = () => {
2869
+ const sources = [
2870
+ ...COMMANDS,
2871
+ ...[...app.prompts.templates.values()].map((t) => ({
2872
+ name: t.name,
2873
+ description: `${t.description} (template)`,
2874
+ ...(t.argumentHint ? { argumentHint: t.argumentHint } : {}),
2875
+ })),
2876
+ ...[...app.plugins.commandInfo.entries()]
2877
+ .filter(([name]) => !resolveCommand(name))
2878
+ .map(([name, c]) => ({
2879
+ name,
2880
+ description: c.description ?? `plugin ${c.plugin}`,
2881
+ ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
2882
+ })),
2883
+ // `/agent:<id>` for every loaded agent (namespaced: never collides with other commands).
2884
+ ...agentCommandDescriptors(mainAgents()).map((d) => ({
2885
+ name: d.name,
2886
+ description: d.description,
2887
+ })),
2888
+ ];
2889
+ return new CombinedAutocompleteProvider(slashCompletionCommands(sources, {
2890
+ sessions: (prefix) => workspaceSessions()
2891
+ .filter((s) => s.id.startsWith(prefix))
2892
+ .map((s) => ({ value: s.id, label: shortId(s.id), description: s.model })),
2893
+ skills: app
2894
+ .skillCatalog()
2895
+ .map(({ id, name, displayId, description, scope, enabled, locked, effective }) => ({
2896
+ id,
2897
+ name,
2898
+ displayId,
2899
+ description,
2900
+ scope,
2901
+ enabled,
2902
+ locked,
2903
+ effective,
2904
+ })),
2905
+ }, { skillEntries: app.config.tui.skillSlashCommands !== false }), app.workspace);
2906
+ };
2907
+ editor.setAutocompleteProvider(buildSlashCompletionProvider());
1179
2908
  // Interactive services for plugins: choices (e.g. worktree isolation) and session views.
1180
- app.plugins.setInteractiveUI({
2909
+ const interactiveUI = {
1181
2910
  // No session/label/signal on SelectRequest: queued FIFO like everything else, never withdrawn
1182
2911
  // early. Routed through the same queue as approve/askQuestions since plugin-subagents can call
1183
2912
  // `select` concurrently with an approval, which previously raced on the shared picker slot.
1184
2913
  select: (request) => interactiveQueue.submit({
2914
+ kind: "question",
1185
2915
  onWithdrawn: () => undefined,
1186
2916
  run: () => new Promise((resolve) => {
1187
2917
  showPicker(new Picker(request.title, request.options.map((o) => ({
@@ -1209,7 +2939,24 @@ export async function runTui(options) {
1209
2939
  openChild(sessionId);
1210
2940
  return true;
1211
2941
  },
1212
- });
2942
+ };
2943
+ app.plugins.setInteractiveUI(interactiveUI);
2944
+ /** `/reload`: moves every surface binding to the new application (the old one is closed after). */
2945
+ const rebindApplication = (next) => {
2946
+ app = next;
2947
+ commandCatalog = new CommandCatalog(app);
2948
+ activeProvider = app.providerInfo;
2949
+ modelList = undefined;
2950
+ modelCatalog = new Map();
2951
+ agentModels = undefined;
2952
+ app.plugins.onStatusChange = () => tui.requestRender();
2953
+ app.plugins.setInteractiveUI(interactiveUI);
2954
+ // A mode the user picked survives the reload; a label that only came from flags is re-derived.
2955
+ if (modePicked && permissionMode !== "custom" && permissionMode !== "locked")
2956
+ app.runner.setPolicy(PERMISSION_MODE_TABLE[permissionMode].policy);
2957
+ primeModels();
2958
+ refreshEstimate();
2959
+ };
1213
2960
  const panelKey = (data) => {
1214
2961
  if (matchesKey(data, Key.up))
1215
2962
  return "up";
@@ -1258,6 +3005,22 @@ export async function runTui(options) {
1258
3005
  };
1259
3006
  let lastCtrlC = 0;
1260
3007
  tui.addInputListener((data) => {
3008
+ // Shift+Tab cycles the main agents. Input listeners run before the focused editor, so this is
3009
+ // the one place that sees the key; a picker, the autocomplete list or the agents panel keep it.
3010
+ const cycle = shiftTabDecision(data, {
3011
+ busy,
3012
+ picker: !!picker,
3013
+ autocomplete: editor.isShowingAutocomplete(),
3014
+ panelFocused: panelState.focus !== "editor",
3015
+ });
3016
+ if (cycle.type === "blocked") {
3017
+ flashHint(cycle.hint);
3018
+ return { consume: true };
3019
+ }
3020
+ if (cycle.type === "cycle") {
3021
+ void cycleAgent();
3022
+ return { consume: true };
3023
+ }
1261
3024
  if (!picker && panelState.focus === "editor") {
1262
3025
  if (matchesKey(data, Key.ctrl("v"))) {
1263
3026
  void pasteImage();
@@ -1267,6 +3030,20 @@ export async function runTui(options) {
1267
3030
  removeAttachment();
1268
3031
  return { consume: true };
1269
3032
  }
3033
+ // `x` on an EMPTY editor line toggles the nearest collapsible transcript row — a finished
3034
+ // `Thought` section, a grouped batch of tool calls, or a long command output. Same
3035
+ // empty-input convention as Enter, Ctrl+D and `c`/`y`; typing `x` mid-message is never
3036
+ // intercepted, and with nothing collapsible it types normally.
3037
+ if (foldToggleKey(data, {
3038
+ text: editor.getText(),
3039
+ autocomplete: editor.isShowingAutocomplete(),
3040
+ })) {
3041
+ const target = foldCandidates(visibleTranscript().items).at(-1);
3042
+ if (target) {
3043
+ toggleFold(target);
3044
+ return { consume: true };
3045
+ }
3046
+ }
1270
3047
  }
1271
3048
  if (!picker) {
1272
3049
  if (matchesKey(data, Key.ctrl("x"))) {
@@ -1303,6 +3080,24 @@ export async function runTui(options) {
1303
3080
  return { consume: true };
1304
3081
  }
1305
3082
  }
3083
+ // `c`/`y` on an EMPTY editor line copies the last assistant response as raw text, the same
3084
+ // as /copy; typing `c` or `y` mid-message is never intercepted.
3085
+ if (editorCopyKey(data, {
3086
+ text: editor.getText(),
3087
+ autocomplete: editor.isShowingAutocomplete(),
3088
+ busy,
3089
+ })) {
3090
+ const last = lastAssistantText(view.items);
3091
+ if (last) {
3092
+ void copy(last).then((result) => {
3093
+ const message = copyMessage(result);
3094
+ tui.flash(message);
3095
+ if (!result.ok)
3096
+ notice(message);
3097
+ });
3098
+ }
3099
+ return { consume: true };
3100
+ }
1306
3101
  }
1307
3102
  if (matchesKey(data, Key.escape)) {
1308
3103
  if (busy && !picker && !editor.isShowingAutocomplete()) {
@@ -1346,6 +3141,7 @@ export async function runTui(options) {
1346
3141
  const ticker = setInterval(() => {
1347
3142
  clock.now = Date.now();
1348
3143
  if (busy ||
3144
+ btwPanel?.pending ||
1349
3145
  view.items.some((i) => i.kind === "tool" && i.status === "running") ||
1350
3146
  panelNodes().some((n) => n.status === "running")) {
1351
3147
  clock.frame++;
@@ -1392,6 +3188,7 @@ export async function runTui(options) {
1392
3188
  tui.start();
1393
3189
  for (const failure of app.mcpStartupFailures())
1394
3190
  notice(failure);
3191
+ void announceNews().catch(() => { });
1395
3192
  sync();
1396
3193
  refreshEstimate();
1397
3194
  // Discover context windows in the background; failures only mean "unknown".
@@ -1402,6 +3199,7 @@ export async function runTui(options) {
1402
3199
  }
1403
3200
  finally {
1404
3201
  clearInterval(ticker);
3202
+ clearInterval(branchTimer);
1405
3203
  clearTimeout(hintTimer);
1406
3204
  process.off("SIGTERM", onSignal);
1407
3205
  process.off("SIGHUP", onSignal);