@alisio/alisio-code 0.1.0-alpha.9 → 0.1.0
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.
- package/README.md +95 -16
- package/dist/analysis.d.ts +27 -0
- package/dist/analysis.js +145 -0
- package/dist/banner.js +2 -1
- package/dist/builtin.d.ts +3 -1
- package/dist/builtin.js +0 -24
- package/dist/main.js +133 -6
- package/dist/serve.d.ts +15 -0
- package/dist/serve.js +99 -0
- package/dist/tui/agent-manager.d.ts +105 -0
- package/dist/tui/agent-manager.js +269 -0
- package/dist/tui/agents.d.ts +5 -0
- package/dist/tui/agents.js +5 -0
- package/dist/tui/app.d.ts +42 -1
- package/dist/tui/app.js +1895 -97
- package/dist/tui/artifact-preview.d.ts +52 -0
- package/dist/tui/artifact-preview.js +212 -0
- package/dist/tui/artifacts.d.ts +54 -0
- package/dist/tui/artifacts.js +207 -0
- package/dist/tui/btw.d.ts +57 -0
- package/dist/tui/btw.js +76 -0
- package/dist/tui/components.d.ts +174 -12
- package/dist/tui/components.js +962 -66
- package/dist/tui/connect-input.d.ts +2 -0
- package/dist/tui/connect-input.js +1 -1
- package/dist/tui/exit.d.ts +10 -0
- package/dist/tui/exit.js +15 -0
- package/dist/tui/git-branch.d.ts +48 -0
- package/dist/tui/git-branch.js +99 -0
- package/dist/tui/goal.d.ts +75 -0
- package/dist/tui/goal.js +171 -0
- package/dist/tui/modes.d.ts +71 -0
- package/dist/tui/modes.js +90 -0
- package/dist/tui/open-path.d.ts +38 -0
- package/dist/tui/open-path.js +80 -0
- package/dist/tui/plan-review.d.ts +48 -0
- package/dist/tui/plan-review.js +76 -0
- package/dist/tui/queue.d.ts +2 -0
- package/dist/tui/queue.js +5 -1
- package/dist/tui/scroll.d.ts +8 -0
- package/dist/tui/scroll.js +12 -0
- package/dist/tui/settings-menu.d.ts +40 -0
- package/dist/tui/settings-menu.js +132 -0
- package/dist/tui/settings.d.ts +194 -0
- package/dist/tui/settings.js +482 -0
- package/dist/tui/state.d.ts +343 -7
- package/dist/tui/state.js +823 -54
- package/dist/tui/tasks.d.ts +123 -0
- package/dist/tui/tasks.js +369 -0
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/dist/tui/viewer-state.d.ts +7 -0
- package/dist/tui/viewer-state.js +36 -0
- package/dist/version.d.ts +7 -5
- package/dist/version.js +44 -8
- package/package.json +7 -9
package/dist/tui/app.js
CHANGED
|
@@ -1,46 +1,127 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
|
|
47
|
+
match;
|
|
48
|
+
list = new SelectList([], 1, selectListTheme);
|
|
21
49
|
filter = "";
|
|
22
|
-
|
|
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.
|
|
27
|
-
this.
|
|
28
|
-
this.
|
|
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.
|
|
103
|
+
this.resetList();
|
|
37
104
|
}
|
|
38
105
|
else if (this.filterable && data.length === 1 && data >= " " && data <= "~") {
|
|
39
106
|
this.filter += data;
|
|
40
|
-
this.
|
|
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
|
-
|
|
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(() =>
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
.
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
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);
|