@alisio/alisio-code 0.1.0-alpha.3
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/LICENSE +21 -0
- package/README.md +23 -0
- package/dist/banner.d.ts +56 -0
- package/dist/banner.js +69 -0
- package/dist/builtin.d.ts +8 -0
- package/dist/builtin.js +49 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +476 -0
- package/dist/prompts/index.d.ts +5 -0
- package/dist/prompts/index.js +3 -0
- package/dist/prompts/init.d.ts +2 -0
- package/dist/prompts/init.js +57 -0
- package/dist/tui/app.d.ts +8 -0
- package/dist/tui/app.js +1313 -0
- package/dist/tui/attachments.d.ts +68 -0
- package/dist/tui/attachments.js +132 -0
- package/dist/tui/clipboard.d.ts +30 -0
- package/dist/tui/clipboard.js +57 -0
- package/dist/tui/components.d.ts +159 -0
- package/dist/tui/components.js +487 -0
- package/dist/tui/connect-input.d.ts +35 -0
- package/dist/tui/connect-input.js +104 -0
- package/dist/tui/panel.d.ts +54 -0
- package/dist/tui/panel.js +140 -0
- package/dist/tui/questions.d.ts +56 -0
- package/dist/tui/questions.js +113 -0
- package/dist/tui/queue.d.ts +35 -0
- package/dist/tui/queue.js +79 -0
- package/dist/tui/skills-manager.d.ts +67 -0
- package/dist/tui/skills-manager.js +200 -0
- package/dist/tui/state.d.ts +189 -0
- package/dist/tui/state.js +546 -0
- package/dist/tui/theme.d.ts +23 -0
- package/dist/tui/theme.js +49 -0
- package/package.json +66 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { CombinedAutocompleteProvider, Container, Editor, getImageDimensions, getNativeClipboard, Key, matchesKey, ProcessTerminal, ScrollView, SelectList, TuiAltScreen, truncateToWidth, VStack, } from "@earendil-works/pi-tui";
|
|
3
|
+
import { MAX_ATTACHMENTS_PER_MESSAGE, MAX_IMAGE_BYTES, pasteImageFromClipboard, removeLastAttachment, toApiAttachment, } from "./attachments.js";
|
|
4
|
+
import { copyText, nodeSpawn } from "./clipboard.js";
|
|
5
|
+
import { AttachmentsBar, BannerBlock, clock, Footer, Header, QuestionPanel, Switch, TranscriptSync, TreePanel, } from "./components.js";
|
|
6
|
+
import { ConnectInputPrompt } from "./connect-input.js";
|
|
7
|
+
import { initialPanelState, reducePanel, visibleRows } from "./panel.js";
|
|
8
|
+
import { summarizeAnswers } from "./questions.js";
|
|
9
|
+
import { InteractiveQueue } from "./queue.js";
|
|
10
|
+
import { SkillsManager } from "./skills-manager.js";
|
|
11
|
+
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";
|
|
12
|
+
import { editorTheme, selectListTheme, style } from "./theme.js";
|
|
13
|
+
const VERSION = "0.1.0-alpha.1";
|
|
14
|
+
/** Inline selection list with type-to-filter, rendered above the editor. */
|
|
15
|
+
class Picker {
|
|
16
|
+
title;
|
|
17
|
+
filterable;
|
|
18
|
+
detail;
|
|
19
|
+
list;
|
|
20
|
+
filter = "";
|
|
21
|
+
constructor(title, items, onSelect, onCancel, filterable = false, detail) {
|
|
22
|
+
this.title = title;
|
|
23
|
+
this.filterable = filterable;
|
|
24
|
+
this.detail = detail;
|
|
25
|
+
this.list = new SelectList(items, Math.min(10, Math.max(1, items.length)), selectListTheme);
|
|
26
|
+
this.list.onSelect = onSelect;
|
|
27
|
+
this.list.onCancel = onCancel;
|
|
28
|
+
}
|
|
29
|
+
invalidate() {
|
|
30
|
+
this.list.invalidate();
|
|
31
|
+
}
|
|
32
|
+
handleInput(data) {
|
|
33
|
+
if (this.filterable && matchesKey(data, Key.backspace)) {
|
|
34
|
+
this.filter = this.filter.slice(0, -1);
|
|
35
|
+
this.list.setFilter(this.filter);
|
|
36
|
+
}
|
|
37
|
+
else if (this.filterable && data.length === 1 && data >= " " && data <= "~") {
|
|
38
|
+
this.filter += data;
|
|
39
|
+
this.list.setFilter(this.filter);
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
this.list.handleInput(data);
|
|
43
|
+
}
|
|
44
|
+
render(width) {
|
|
45
|
+
const hint = this.filterable
|
|
46
|
+
? ` ${this.filter ? `filter: ${this.filter}` : "type to filter"} · ↑↓ · Enter · Esc`
|
|
47
|
+
: " ↑↓ · Enter · Esc";
|
|
48
|
+
return [
|
|
49
|
+
truncateToWidth(style.bold(style.yellow(this.title)), width),
|
|
50
|
+
...(this.detail
|
|
51
|
+
? this.detail
|
|
52
|
+
.split("\n")
|
|
53
|
+
.flatMap((line) => {
|
|
54
|
+
const words = line.split(/\s+/);
|
|
55
|
+
const rows = [];
|
|
56
|
+
let row = "";
|
|
57
|
+
for (const word of words) {
|
|
58
|
+
if (row && [...`${row} ${word}`].length > width) {
|
|
59
|
+
rows.push(row);
|
|
60
|
+
row = word;
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
row = row ? `${row} ${word}` : word;
|
|
64
|
+
}
|
|
65
|
+
if (row)
|
|
66
|
+
rows.push(row);
|
|
67
|
+
return rows;
|
|
68
|
+
})
|
|
69
|
+
.map((line) => truncateToWidth(style.dim(line), width))
|
|
70
|
+
: []),
|
|
71
|
+
...this.list.render(width),
|
|
72
|
+
truncateToWidth(style.dim(hint), width),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function runTui(options) {
|
|
77
|
+
const { createApplication } = await import("@alisio/core");
|
|
78
|
+
const { BUILTIN_PLUGINS } = await import("../builtin.js");
|
|
79
|
+
const { BUILTIN_PROMPTS } = await import("../prompts/index.js");
|
|
80
|
+
let dispatch = () => { };
|
|
81
|
+
let approve = async () => "deny";
|
|
82
|
+
const app = await createApplication({
|
|
83
|
+
builtins: BUILTIN_PLUGINS,
|
|
84
|
+
builtinPrompts: BUILTIN_PROMPTS,
|
|
85
|
+
reservedPromptNames: reservedCommandNames(),
|
|
86
|
+
...options,
|
|
87
|
+
onEvent: (event) => dispatch(event),
|
|
88
|
+
approve: (request) => approve(request),
|
|
89
|
+
});
|
|
90
|
+
let activeProvider = app.providerInfo;
|
|
91
|
+
let session = options.session ?? app.store.create(app.workspace, app.provider.id, app.provider.model).id;
|
|
92
|
+
if (options.session && options.model && app.store.get(session).model !== options.model)
|
|
93
|
+
app.runner.setModel(session, options.model);
|
|
94
|
+
let view = initialViewState(app.store.get(session).model);
|
|
95
|
+
if (options.session)
|
|
96
|
+
view = { ...view, items: itemsFromHistory(app.store.messages(session)) };
|
|
97
|
+
let pendingAttachments = [];
|
|
98
|
+
let modelList;
|
|
99
|
+
/** Cached active-provider catalog for completion and context-window discovery. */
|
|
100
|
+
const models = () => (modelList ??= app.loadModels(AbortSignal.timeout(10_000)).catch((e) => {
|
|
101
|
+
modelList = undefined;
|
|
102
|
+
throw e;
|
|
103
|
+
}));
|
|
104
|
+
const terminal = new ProcessTerminal();
|
|
105
|
+
const copy = async (text) => copyText(text, {
|
|
106
|
+
platform: process.platform,
|
|
107
|
+
env: process.env,
|
|
108
|
+
spawn: nodeSpawn,
|
|
109
|
+
writeOsc52: (sequence) => terminal.write(sequence),
|
|
110
|
+
});
|
|
111
|
+
const copyMessage = (result) => result.ok
|
|
112
|
+
? `Copied (${result.method})`
|
|
113
|
+
: result.method === "osc52"
|
|
114
|
+
? "Sent via OSC 52 (unverified: no clipboard tool found; needs terminal support)"
|
|
115
|
+
: "Copy failed: no clipboard tool available";
|
|
116
|
+
const tui = new TuiAltScreen(terminal, false, undefined, {
|
|
117
|
+
mouse: true,
|
|
118
|
+
copyOnSelect: true,
|
|
119
|
+
copySelection: async (text) => {
|
|
120
|
+
const result = await copy(text);
|
|
121
|
+
return result.ok ? true : copyMessage(result);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const main = new TranscriptSync();
|
|
125
|
+
const transcript = main.container;
|
|
126
|
+
// Read-only views of child sessions (subagents), fed by their events.
|
|
127
|
+
const childViews = new Map();
|
|
128
|
+
const childView = new TranscriptSync();
|
|
129
|
+
let panelState = initialPanelState();
|
|
130
|
+
const startupDiagnostics = [];
|
|
131
|
+
let hint;
|
|
132
|
+
let hintTimer;
|
|
133
|
+
const flashHint = (text, ms = 2500) => {
|
|
134
|
+
hint = text;
|
|
135
|
+
clearTimeout(hintTimer);
|
|
136
|
+
hintTimer = setTimeout(() => {
|
|
137
|
+
hint = undefined;
|
|
138
|
+
tui.requestRender();
|
|
139
|
+
}, ms);
|
|
140
|
+
tui.requestRender();
|
|
141
|
+
};
|
|
142
|
+
const headerInfo = () => {
|
|
143
|
+
const policy = app.runner.policy, ask = app.runner.approvals;
|
|
144
|
+
return {
|
|
145
|
+
version: VERSION,
|
|
146
|
+
host: hostOf(String(activeProvider?.profile.baseURL ?? app.config.provider.baseURL)),
|
|
147
|
+
apiMode: String(activeProvider?.profile.apiMode ?? app.config.provider.apiMode),
|
|
148
|
+
provider: app.providers.get(activeProvider?.id ?? "")?.name,
|
|
149
|
+
cwd: shortenPath(app.workspace, homedir()),
|
|
150
|
+
session: shortId(session),
|
|
151
|
+
write: policy.write ? "on" : ask ? "ask" : "off",
|
|
152
|
+
process: policy.process ? "on" : ask ? "ask" : "off",
|
|
153
|
+
mcp: app.mcpRuntimePermission() === "granted",
|
|
154
|
+
readOnly: !!options.readOnly,
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
const header = new Header(headerInfo, () => view);
|
|
158
|
+
const panelEntry = () => [...app.plugins.panels.values()][0];
|
|
159
|
+
const panelNodes = () => {
|
|
160
|
+
try {
|
|
161
|
+
const nodes = panelEntry()?.provider.nodes({ sessionId: session }) ?? [];
|
|
162
|
+
// Display-only override: a session blocked on ask_user_question or an approval prompt shows
|
|
163
|
+
// as "waiting" rather than "running", so the tree panel explains why it looks stalled.
|
|
164
|
+
return nodes.map((n) => n.status === "running" && n.sessionId && interactiveQueue.isWaiting(n.sessionId)
|
|
165
|
+
? { ...n, status: "waiting" }
|
|
166
|
+
: n);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const viewedState = () => panelState.focus === "view" && panelState.viewing
|
|
173
|
+
? childViews.get(panelState.viewing)
|
|
174
|
+
: undefined;
|
|
175
|
+
const viewHint = () => {
|
|
176
|
+
const nodes = panelNodes();
|
|
177
|
+
const node = nodes.find((n) => n.id === panelState.viewing);
|
|
178
|
+
if (!node)
|
|
179
|
+
return "Esc back";
|
|
180
|
+
const path = [];
|
|
181
|
+
for (let cur = node; cur; cur = nodes.find((n) => n.id === cur?.parentId))
|
|
182
|
+
path.unshift(cur.label);
|
|
183
|
+
const siblings = nodes.filter((n) => n.parentId === node.parentId);
|
|
184
|
+
const child = viewedState();
|
|
185
|
+
const window = app.contextWindow(child?.model ?? view.model);
|
|
186
|
+
const pct = child?.context && window ? ` · ctx ${Math.round((child.context.used / window) * 100)}%` : "";
|
|
187
|
+
const tokens = child
|
|
188
|
+
? ` · ↑${formatTokens(child.stats.input)} ↓${formatTokens(child.stats.output)}`
|
|
189
|
+
: "";
|
|
190
|
+
return `viewing ${path.join(" › ")} · ${siblings.indexOf(node) + 1}/${siblings.length}${pct}${tokens} · ↑ parent ↓ child ←→ siblings · Esc back · Ctrl+K cancel`;
|
|
191
|
+
};
|
|
192
|
+
const panelHint = () => panelState.focus === "panel"
|
|
193
|
+
? "agents: ↑↓ move · → expand/enter · ← collapse/parent · Enter open · Ctrl+K cancel · Esc/Tab editor"
|
|
194
|
+
: panelState.focus === "view"
|
|
195
|
+
? viewHint()
|
|
196
|
+
: undefined;
|
|
197
|
+
const footer = new Footer(() => viewedState() ?? view, () => app.contextWindow((viewedState() ?? view).model), () => panelHint() ?? hint, () => [...app.plugins.status.values()].map((s) => s.text));
|
|
198
|
+
const treePanel = new TreePanel(() => {
|
|
199
|
+
const entry = panelEntry();
|
|
200
|
+
if (!entry)
|
|
201
|
+
return undefined;
|
|
202
|
+
const nodes = panelNodes();
|
|
203
|
+
return {
|
|
204
|
+
title: entry.provider.title,
|
|
205
|
+
rows: visibleRows(nodes, panelState.collapsed).map((r) => ({
|
|
206
|
+
...r,
|
|
207
|
+
collapsed: panelState.collapsed.has(r.node.id),
|
|
208
|
+
})),
|
|
209
|
+
total: nodes,
|
|
210
|
+
focused: panelState.focus !== "editor",
|
|
211
|
+
...(panelState.selected ? { selected: panelState.selected } : {}),
|
|
212
|
+
...(panelState.confirm ? { confirm: panelState.confirm } : {}),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
app.plugins.onStatusChange = () => tui.requestRender();
|
|
216
|
+
const pickerSlot = new Container();
|
|
217
|
+
const attachmentsBar = new AttachmentsBar(() => pendingAttachments);
|
|
218
|
+
const editor = new Editor(tui, editorTheme, { paddingX: 1 });
|
|
219
|
+
const bottom = new Container();
|
|
220
|
+
bottom.addChild(pickerSlot);
|
|
221
|
+
bottom.addChild(attachmentsBar);
|
|
222
|
+
bottom.addChild(editor);
|
|
223
|
+
bottom.addChild(treePanel);
|
|
224
|
+
bottom.addChild(footer);
|
|
225
|
+
tui.setLayoutRoot(new VStack([
|
|
226
|
+
{ component: header, basis: "auto" },
|
|
227
|
+
{
|
|
228
|
+
component: new ScrollView(new Switch(() => (viewedState() ? childView.container : transcript)), {
|
|
229
|
+
follow: "end",
|
|
230
|
+
primary: true,
|
|
231
|
+
overscroll: "chain",
|
|
232
|
+
}),
|
|
233
|
+
basis: 0,
|
|
234
|
+
grow: 1,
|
|
235
|
+
minSize: 1,
|
|
236
|
+
},
|
|
237
|
+
{ component: bottom, basis: "auto", shrink: 1, minSize: 3 },
|
|
238
|
+
]));
|
|
239
|
+
const sync = () => {
|
|
240
|
+
main.sync(view.items);
|
|
241
|
+
tui.requestRender();
|
|
242
|
+
};
|
|
243
|
+
const reset = (next) => {
|
|
244
|
+
view = next;
|
|
245
|
+
main.reset();
|
|
246
|
+
sync();
|
|
247
|
+
};
|
|
248
|
+
const openChild = (sessionId) => {
|
|
249
|
+
if (!childViews.has(sessionId)) {
|
|
250
|
+
let base = initialViewState(view.model);
|
|
251
|
+
try {
|
|
252
|
+
base = { ...base, items: itemsFromHistory(app.store.messages(sessionId)) };
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* unknown session */
|
|
256
|
+
}
|
|
257
|
+
childViews.set(sessionId, base);
|
|
258
|
+
}
|
|
259
|
+
childView.reset();
|
|
260
|
+
childView.sync(childViews.get(sessionId)?.items ?? []);
|
|
261
|
+
tui.requestRender();
|
|
262
|
+
};
|
|
263
|
+
const applyPanel = (result) => {
|
|
264
|
+
panelState = result.state;
|
|
265
|
+
const effect = result.effect;
|
|
266
|
+
if (effect?.type === "open")
|
|
267
|
+
openChild(effect.sessionId);
|
|
268
|
+
if (effect?.type === "cancel")
|
|
269
|
+
void panelEntry()?.provider.action?.("cancel", effect.id, { sessionId: session });
|
|
270
|
+
if (effect?.type === "cancel")
|
|
271
|
+
flashHint("Cancelling agent and its descendants…");
|
|
272
|
+
tui.requestRender();
|
|
273
|
+
};
|
|
274
|
+
const push = (item) => {
|
|
275
|
+
view = addItem(view, item);
|
|
276
|
+
sync();
|
|
277
|
+
};
|
|
278
|
+
const notice = (text) => push({ kind: "notice", text });
|
|
279
|
+
const error = (e) => push({ kind: "error", text: e instanceof Error ? e.message : String(e) });
|
|
280
|
+
const info = (text) => push({ kind: "info", text });
|
|
281
|
+
let terminalEvent = false;
|
|
282
|
+
dispatch = (event) => {
|
|
283
|
+
if (event.sessionId !== session) {
|
|
284
|
+
// Child session (e.g. a subagent): keep a view model for its read-only view.
|
|
285
|
+
const child = childViews.get(event.sessionId) ?? initialViewState(view.model);
|
|
286
|
+
childViews.set(event.sessionId, reduceEvent(child, event));
|
|
287
|
+
if (panelState.viewing === event.sessionId)
|
|
288
|
+
childView.sync(childViews.get(event.sessionId)?.items ?? []);
|
|
289
|
+
tui.requestRender();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (["run_completed", "run_failed", "run_cancelled", "compaction_failed"].includes(event.type))
|
|
293
|
+
terminalEvent = true;
|
|
294
|
+
view = reduceEvent(view, event);
|
|
295
|
+
sync();
|
|
296
|
+
};
|
|
297
|
+
const refreshEstimate = () => {
|
|
298
|
+
const current = session;
|
|
299
|
+
app.runner
|
|
300
|
+
.estimateContext(current)
|
|
301
|
+
.then((used) => {
|
|
302
|
+
if (current !== session)
|
|
303
|
+
return;
|
|
304
|
+
view = { ...view, context: { used, estimated: true } };
|
|
305
|
+
tui.requestRender();
|
|
306
|
+
})
|
|
307
|
+
.catch(() => { });
|
|
308
|
+
};
|
|
309
|
+
let busy = false, controller, pending, picker;
|
|
310
|
+
const showPicker = (next) => {
|
|
311
|
+
picker = next;
|
|
312
|
+
pickerSlot.clear();
|
|
313
|
+
pickerSlot.addChild(next);
|
|
314
|
+
tui.setFocus(next);
|
|
315
|
+
tui.requestRender();
|
|
316
|
+
};
|
|
317
|
+
const closePicker = () => {
|
|
318
|
+
picker = undefined;
|
|
319
|
+
pickerSlot.clear();
|
|
320
|
+
tui.setFocus(editor);
|
|
321
|
+
tui.requestRender();
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Single serialized queue for every cross-session interactive prompt: write/process approvals,
|
|
325
|
+
* `/model`'s `select`, and `ask_user_question`/`/ask`'s question panel — at most one of these is
|
|
326
|
+
* ever on screen, regardless of how many sessions (root or nested subagents) ask at once. FIFO by
|
|
327
|
+
* arrival (no root-over-subagent priority): simple, fair, and deterministic. `/model` and
|
|
328
|
+
* `/resume`'s own command-triggered pickers are intentionally NOT routed through this queue: they
|
|
329
|
+
* are user-command-driven (never concurrent with a subagent) and `chooseModel()` does not await
|
|
330
|
+
* picker resolution today, so folding them in would need an unrelated restructuring.
|
|
331
|
+
*/
|
|
332
|
+
const interactiveQueue = new InteractiveQueue();
|
|
333
|
+
approve = (request) => interactiveQueue.submit({
|
|
334
|
+
sessionId: request.session,
|
|
335
|
+
label: request.label,
|
|
336
|
+
signal: request.signal,
|
|
337
|
+
onWithdrawn: () => "deny",
|
|
338
|
+
run: () => new Promise((resolve) => {
|
|
339
|
+
let settled = false;
|
|
340
|
+
const finish = (decision) => {
|
|
341
|
+
if (settled)
|
|
342
|
+
return;
|
|
343
|
+
settled = true;
|
|
344
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
345
|
+
closePicker();
|
|
346
|
+
resolve(decision);
|
|
347
|
+
};
|
|
348
|
+
const onAbort = () => finish("deny");
|
|
349
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
350
|
+
showPicker(new Picker(`${request.label ? `[${request.label}] ` : ""}Allow ${request.call.name} (${request.effect}): ${summarizeToolArgs(request.call.name, request.call.arguments)}?`, [
|
|
351
|
+
{ value: "once", label: "Allow once" },
|
|
352
|
+
{ value: "session", label: `Always allow ${request.effect} in this session` },
|
|
353
|
+
{ value: "deny", label: "Deny" },
|
|
354
|
+
], (item) => finish(item.value), () => finish("deny")));
|
|
355
|
+
}),
|
|
356
|
+
});
|
|
357
|
+
/**
|
|
358
|
+
* `ask_user_question` / `/ask`: routed through the same `interactiveQueue` as `approve`/`select`,
|
|
359
|
+
* so a root or subagent question never races another prompt for the screen. A question withdrawn
|
|
360
|
+
* while queued or displayed (its session cancelled) resolves every question undefined and prints
|
|
361
|
+
* a notice; it never leaves a stale prompt on screen or blocks the next queued item.
|
|
362
|
+
*/
|
|
363
|
+
const askQuestions = (request) => {
|
|
364
|
+
const specs = request.questions.map((q) => ({
|
|
365
|
+
header: q.header,
|
|
366
|
+
question: q.question,
|
|
367
|
+
...(q.multiSelect ? { multiSelect: true } : {}),
|
|
368
|
+
options: q.options.map((o) => ({
|
|
369
|
+
label: o.label,
|
|
370
|
+
...(o.description ? { description: o.description } : {}),
|
|
371
|
+
...(o.recommended ? { recommended: true } : {}),
|
|
372
|
+
})),
|
|
373
|
+
}));
|
|
374
|
+
const withdrawnResult = () => Object.fromEntries(request.questions.map((q) => [q.id, undefined]));
|
|
375
|
+
const toResult = (answers) => {
|
|
376
|
+
const result = {};
|
|
377
|
+
request.questions.forEach((q, i) => {
|
|
378
|
+
const a = answers[i];
|
|
379
|
+
if (!a || a.skipped) {
|
|
380
|
+
result[q.id] = undefined;
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const values = a.indices.map((idx) => q.options[idx]?.value ?? "");
|
|
384
|
+
result[q.id] = q.multiSelect ? values : values[0];
|
|
385
|
+
});
|
|
386
|
+
return result;
|
|
387
|
+
};
|
|
388
|
+
return interactiveQueue.submit({
|
|
389
|
+
sessionId: request.session,
|
|
390
|
+
label: request.label,
|
|
391
|
+
signal: request.signal,
|
|
392
|
+
onWithdrawn: () => {
|
|
393
|
+
notice(`${request.label ? `[${request.label}] ` : ""}Question withdrawn: the asking agent was cancelled.`);
|
|
394
|
+
return withdrawnResult();
|
|
395
|
+
},
|
|
396
|
+
run: () => new Promise((resolve) => {
|
|
397
|
+
let settled = false;
|
|
398
|
+
const finish = (result) => {
|
|
399
|
+
if (settled)
|
|
400
|
+
return;
|
|
401
|
+
settled = true;
|
|
402
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
403
|
+
closePicker();
|
|
404
|
+
resolve(result);
|
|
405
|
+
};
|
|
406
|
+
const onAbort = () => {
|
|
407
|
+
notice(`${request.label ? `[${request.label}] ` : ""}Question withdrawn: the asking agent was cancelled.`);
|
|
408
|
+
finish(withdrawnResult());
|
|
409
|
+
};
|
|
410
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
411
|
+
showPicker(new QuestionPanel(specs, (answers) => {
|
|
412
|
+
info(summarizeAnswers(specs, answers));
|
|
413
|
+
finish(toResult(answers));
|
|
414
|
+
}, request.label));
|
|
415
|
+
}),
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
const task = async (work) => {
|
|
419
|
+
busy = true;
|
|
420
|
+
terminalEvent = false;
|
|
421
|
+
controller = new AbortController();
|
|
422
|
+
const signal = controller.signal;
|
|
423
|
+
const promise = work(signal);
|
|
424
|
+
pending = promise;
|
|
425
|
+
try {
|
|
426
|
+
await promise;
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
if (!terminalEvent)
|
|
430
|
+
error(e);
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
busy = false;
|
|
434
|
+
controller = undefined;
|
|
435
|
+
pending = undefined;
|
|
436
|
+
view = { ...view, streaming: false, compacting: false };
|
|
437
|
+
if (!view.context || view.context.estimated)
|
|
438
|
+
refreshEstimate();
|
|
439
|
+
tui.requestRender();
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const runPrompt = (display, prompt, persistDisplay) => {
|
|
443
|
+
// Any pending clipboard-pasted images ride along with the very next turn, then are cleared.
|
|
444
|
+
const attachments = pendingAttachments.map(toApiAttachment);
|
|
445
|
+
pendingAttachments = [];
|
|
446
|
+
attachmentsBar.invalidate();
|
|
447
|
+
push({ kind: "user", text: display });
|
|
448
|
+
return task((signal) => app.runner.run(session, prompt, signal, {
|
|
449
|
+
...(persistDisplay ? { display: persistDisplay } : {}),
|
|
450
|
+
...(attachments.length ? { attachments } : {}),
|
|
451
|
+
}));
|
|
452
|
+
};
|
|
453
|
+
let resolveExit = () => { };
|
|
454
|
+
const exited = new Promise((resolve) => {
|
|
455
|
+
resolveExit = resolve;
|
|
456
|
+
});
|
|
457
|
+
let stopping = false;
|
|
458
|
+
/** Session-end plugin hooks (e.g. memory summary), bounded by the host timeout. */
|
|
459
|
+
const endSession = async (reason) => {
|
|
460
|
+
if (!view.stats.runs || !app.plugins.hasSessionEndHooks)
|
|
461
|
+
return;
|
|
462
|
+
flashHint("Running session-end plugin hooks (bounded by pluginHooks.sessionEndTimeoutMs)…", 60_000);
|
|
463
|
+
tui.renderNow?.();
|
|
464
|
+
try {
|
|
465
|
+
const { failures } = await app.endSession(session, reason);
|
|
466
|
+
for (const f of failures)
|
|
467
|
+
notice(`Plugin ${f.source} ${f.hook} failed: ${f.error}`);
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
error(e);
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
hint = undefined;
|
|
474
|
+
tui.requestRender();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const shutdown = async () => {
|
|
478
|
+
if (stopping)
|
|
479
|
+
return;
|
|
480
|
+
stopping = true;
|
|
481
|
+
controller?.abort(new Error("Exiting"));
|
|
482
|
+
await Promise.race([pending?.catch(() => { }), new Promise((r) => setTimeout(r, 3000))]);
|
|
483
|
+
await endSession("exit");
|
|
484
|
+
resolveExit();
|
|
485
|
+
};
|
|
486
|
+
const askInput = (input) => new Promise((resolve) => {
|
|
487
|
+
showPicker(new ConnectInputPrompt({
|
|
488
|
+
...input,
|
|
489
|
+
initial: input.initial ?? "",
|
|
490
|
+
secret: input.secret ?? false,
|
|
491
|
+
onSubmit: (value) => {
|
|
492
|
+
closePicker();
|
|
493
|
+
resolve(value);
|
|
494
|
+
},
|
|
495
|
+
onCancel: () => {
|
|
496
|
+
closePicker();
|
|
497
|
+
resolve(undefined);
|
|
498
|
+
},
|
|
499
|
+
}));
|
|
500
|
+
});
|
|
501
|
+
const askChoice = (title, choices) => new Promise((resolve) => {
|
|
502
|
+
showPicker(new Picker(title, choices, (item) => {
|
|
503
|
+
closePicker();
|
|
504
|
+
resolve(item.value);
|
|
505
|
+
}, () => {
|
|
506
|
+
closePicker();
|
|
507
|
+
resolve(undefined);
|
|
508
|
+
}));
|
|
509
|
+
});
|
|
510
|
+
const connect = async () => {
|
|
511
|
+
const registrations = app.providers.list();
|
|
512
|
+
if (!registrations.length)
|
|
513
|
+
return notice("No provider plugins are registered");
|
|
514
|
+
const providerId = await askChoice("Select provider", registrations.map((p) => ({
|
|
515
|
+
value: p.id,
|
|
516
|
+
label: p.name,
|
|
517
|
+
...(p.description ? { description: p.description } : {}),
|
|
518
|
+
})));
|
|
519
|
+
if (!providerId)
|
|
520
|
+
return;
|
|
521
|
+
const registration = app.providers.get(providerId);
|
|
522
|
+
if (!registration)
|
|
523
|
+
return error(`Provider disappeared: ${providerId}`);
|
|
524
|
+
const prior = await app.providerSettings.resolve(providerId);
|
|
525
|
+
const profile = { ...(prior?.profile.values ?? {}) };
|
|
526
|
+
const credentials = { ...(prior?.credentials ?? {}) };
|
|
527
|
+
const inputFields = registration.fields.filter((field) => field.kind !== "select" && field.kind !== "boolean");
|
|
528
|
+
let inputStep = 0;
|
|
529
|
+
for (const field of registration.fields) {
|
|
530
|
+
if (field.kind === "secret") {
|
|
531
|
+
inputStep++;
|
|
532
|
+
const entered = await askInput({
|
|
533
|
+
provider: registration.name,
|
|
534
|
+
label: field.label,
|
|
535
|
+
secret: true,
|
|
536
|
+
placeholder: credentials[field.key] ? "Leave empty to keep saved value" : "Paste API key",
|
|
537
|
+
hint: field.description ?? "Input is masked and never added to command history",
|
|
538
|
+
step: inputStep,
|
|
539
|
+
steps: inputFields.length,
|
|
540
|
+
});
|
|
541
|
+
if (entered === undefined)
|
|
542
|
+
return;
|
|
543
|
+
if (entered)
|
|
544
|
+
credentials[field.key] = entered;
|
|
545
|
+
if (field.required && !credentials[field.key])
|
|
546
|
+
return error(`${field.label} is required`);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (field.kind === "select" || field.kind === "boolean") {
|
|
550
|
+
const options = field.kind === "boolean"
|
|
551
|
+
? [
|
|
552
|
+
{ value: "true", label: "Yes" },
|
|
553
|
+
{ value: "false", label: "No" },
|
|
554
|
+
]
|
|
555
|
+
: (field.options ?? []);
|
|
556
|
+
const current = String(profile[field.key] ?? field.defaultValue ?? "");
|
|
557
|
+
const selected = await askChoice(field.label, [...options].sort((a, b) => Number(b.value === current) - Number(a.value === current)));
|
|
558
|
+
if (selected === undefined)
|
|
559
|
+
return;
|
|
560
|
+
profile[field.key] = field.kind === "boolean" ? selected === "true" : selected;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const current = String(profile[field.key] ?? field.defaultValue ?? "");
|
|
564
|
+
inputStep++;
|
|
565
|
+
const entered = await askInput({
|
|
566
|
+
provider: registration.name,
|
|
567
|
+
label: field.label,
|
|
568
|
+
initial: current,
|
|
569
|
+
placeholder: field.kind === "url" ? "https://api.example.com/v1" : `Enter ${field.label}`,
|
|
570
|
+
hint: field.description ??
|
|
571
|
+
(field.kind === "url" ? "Paste the complete HTTP(S) URL" : undefined),
|
|
572
|
+
step: inputStep,
|
|
573
|
+
steps: inputFields.length,
|
|
574
|
+
});
|
|
575
|
+
if (entered === undefined)
|
|
576
|
+
return;
|
|
577
|
+
if (field.required && !entered.trim())
|
|
578
|
+
return error(`${field.label} is required`);
|
|
579
|
+
profile[field.key] = entered.trim();
|
|
580
|
+
}
|
|
581
|
+
flashHint("Validating connection and loading models…", 60_000);
|
|
582
|
+
let discovered;
|
|
583
|
+
try {
|
|
584
|
+
discovered = await app.probeProvider(providerId, profile, credentials, AbortSignal.timeout(20_000));
|
|
585
|
+
}
|
|
586
|
+
catch (cause) {
|
|
587
|
+
return error(cause);
|
|
588
|
+
}
|
|
589
|
+
let model;
|
|
590
|
+
if (discovered.length)
|
|
591
|
+
model = await askChoice(`${registration.name} · Select model`, providerModelItems(registration.name, discovered, prior?.profile.model));
|
|
592
|
+
else
|
|
593
|
+
model = await askInput({
|
|
594
|
+
provider: registration.name,
|
|
595
|
+
label: "Model ID",
|
|
596
|
+
initial: prior?.profile.model ?? "",
|
|
597
|
+
placeholder: "provider-model-id",
|
|
598
|
+
hint: "The provider returned no model catalog",
|
|
599
|
+
step: inputFields.length + 1,
|
|
600
|
+
steps: inputFields.length + 1,
|
|
601
|
+
});
|
|
602
|
+
if (!model)
|
|
603
|
+
return;
|
|
604
|
+
await app.activateProvider(providerId, profile, credentials, model);
|
|
605
|
+
activeProvider = app.providerInfo;
|
|
606
|
+
modelList = undefined;
|
|
607
|
+
session = app.store.create(app.workspace, app.provider.id, model).id;
|
|
608
|
+
reset(initialViewState(model));
|
|
609
|
+
notice(`Connected to ${registration.name} with model ${model}. Started a fresh session.`);
|
|
610
|
+
refreshEstimate();
|
|
611
|
+
};
|
|
612
|
+
const chooseModel = async () => {
|
|
613
|
+
const catalogs = await app.configuredProviderCatalogs(AbortSignal.timeout(20_000));
|
|
614
|
+
const choices = configuredProviderModelItems(catalogs, activeProvider?.persisted ? { provider: activeProvider.id, model: view.model } : undefined);
|
|
615
|
+
if (!choices.length)
|
|
616
|
+
return notice("No plugin provider profiles are configured. Use /connect.");
|
|
617
|
+
const items = choices;
|
|
618
|
+
showPicker(new Picker("Select provider and model", items, (item) => {
|
|
619
|
+
closePicker();
|
|
620
|
+
const choice = choices.find((candidate) => candidate.value === item.value);
|
|
621
|
+
if (!choice || choice.unavailable)
|
|
622
|
+
return flashHint("That provider is unavailable");
|
|
623
|
+
const selected = JSON.parse(choice.value);
|
|
624
|
+
void app
|
|
625
|
+
.switchModel(`${selected.provider}/${selected.model}`)
|
|
626
|
+
.then((created) => {
|
|
627
|
+
if (!created)
|
|
628
|
+
return;
|
|
629
|
+
activeProvider = app.providerInfo;
|
|
630
|
+
modelList = undefined;
|
|
631
|
+
session = created.id;
|
|
632
|
+
reset(initialViewState(selected.model));
|
|
633
|
+
notice(`Provider changed to ${app.providers.get(selected.provider)?.name ?? selected.provider} with model ${selected.model}. Started a fresh session.`);
|
|
634
|
+
refreshEstimate();
|
|
635
|
+
})
|
|
636
|
+
.catch(error);
|
|
637
|
+
}, closePicker, true));
|
|
638
|
+
};
|
|
639
|
+
const managePlugins = () => {
|
|
640
|
+
const catalog = app.pluginCatalog();
|
|
641
|
+
const openCatalog = () => {
|
|
642
|
+
const entries = app.pluginCatalog();
|
|
643
|
+
showPicker(new Picker("Plugins", pluginCatalogItems(entries), (item) => {
|
|
644
|
+
const selected = entries.find((entry) => entry.id === item.value);
|
|
645
|
+
if (!selected)
|
|
646
|
+
return openCatalog();
|
|
647
|
+
const action = selected.enabled ? "Disable" : "Enable";
|
|
648
|
+
const detail = [
|
|
649
|
+
selected.description,
|
|
650
|
+
`Status: ${selected.status} · Source: ${selected.builtin ? "built-in" : selected.source}`,
|
|
651
|
+
`Category: ${selected.categories.join(", ") || "general"}`,
|
|
652
|
+
selected.diagnostic ?? "Changes are saved for the next Alisio start.",
|
|
653
|
+
].join("\n");
|
|
654
|
+
showPicker(new Picker(selected.name, [
|
|
655
|
+
...(selected.manageable
|
|
656
|
+
? [
|
|
657
|
+
{
|
|
658
|
+
value: "toggle",
|
|
659
|
+
label: `${action} ${selected.name}`,
|
|
660
|
+
description: "Persist project override (restart required)",
|
|
661
|
+
},
|
|
662
|
+
]
|
|
663
|
+
: []),
|
|
664
|
+
{ value: "back", label: "Back to plugin list" },
|
|
665
|
+
], (choice) => {
|
|
666
|
+
if (choice.value === "back")
|
|
667
|
+
return openCatalog();
|
|
668
|
+
const apply = async () => {
|
|
669
|
+
try {
|
|
670
|
+
const updated = await app.setPluginEnabled(selected.id, !selected.enabled, {
|
|
671
|
+
liveSession: view.stats.runs > 0 || view.streaming,
|
|
672
|
+
});
|
|
673
|
+
notice(`${updated.name}: ${updated.diagnostic ?? "restart required"}.`);
|
|
674
|
+
}
|
|
675
|
+
catch (cause) {
|
|
676
|
+
error(cause);
|
|
677
|
+
}
|
|
678
|
+
openCatalog();
|
|
679
|
+
};
|
|
680
|
+
if (!pluginToggleNeedsConfirmation(selected))
|
|
681
|
+
return void apply();
|
|
682
|
+
showPicker(new Picker(`${action} external plugin?`, [
|
|
683
|
+
{
|
|
684
|
+
value: "confirm",
|
|
685
|
+
label: `Yes, ${action.toLowerCase()} ${selected.name}`,
|
|
686
|
+
description: "External plugins execute with full process privileges in trusted projects",
|
|
687
|
+
},
|
|
688
|
+
{ value: "cancel", label: "Cancel" },
|
|
689
|
+
], (confirmation) => {
|
|
690
|
+
if (confirmation.value === "confirm")
|
|
691
|
+
void apply();
|
|
692
|
+
else
|
|
693
|
+
openCatalog();
|
|
694
|
+
}, openCatalog, false, detail));
|
|
695
|
+
}, openCatalog, false, detail));
|
|
696
|
+
}, closePicker, true));
|
|
697
|
+
};
|
|
698
|
+
if (!catalog.length)
|
|
699
|
+
return notice("No plugins are available");
|
|
700
|
+
openCatalog();
|
|
701
|
+
};
|
|
702
|
+
const manageSkills = () => {
|
|
703
|
+
const entries = app.skillCatalog();
|
|
704
|
+
if (!entries.length)
|
|
705
|
+
return notice("No skills are available");
|
|
706
|
+
showPicker(new SkillsManager({
|
|
707
|
+
entries,
|
|
708
|
+
height: () => Math.max(6, (process.stdout.rows ?? 24) - 7),
|
|
709
|
+
onClose: closePicker,
|
|
710
|
+
onToggle: (id, enabled) => app.setSkillEnabled(id, enabled),
|
|
711
|
+
onError: error,
|
|
712
|
+
onChanged: notice,
|
|
713
|
+
requestRender: () => tui.requestRender(),
|
|
714
|
+
}));
|
|
715
|
+
};
|
|
716
|
+
const manageMcp = () => {
|
|
717
|
+
const openCatalog = () => {
|
|
718
|
+
const entries = app.mcp.list();
|
|
719
|
+
if (!entries.length)
|
|
720
|
+
return notice("No MCP servers are configured");
|
|
721
|
+
showPicker(new Picker(`MCP servers (${entries.length})`, mcpServerItems(entries), (item) => {
|
|
722
|
+
const selected = app.mcp.info(item.value);
|
|
723
|
+
const source = selected.source.kind === "global"
|
|
724
|
+
? "User"
|
|
725
|
+
: selected.source.kind[0]?.toUpperCase() + selected.source.kind.slice(1);
|
|
726
|
+
const endpoint = selected.transport === "stdio"
|
|
727
|
+
? `Command: ${selected.command}`
|
|
728
|
+
: `URL: ${selected.url}`;
|
|
729
|
+
const detail = [
|
|
730
|
+
`Configured: ${selected.enabled ? "enabled" : "disabled"} · Source: ${source}`,
|
|
731
|
+
`Runtime permission: ${selected.runtimePermission.replace("-", " ")}`,
|
|
732
|
+
`Connection: ${selected.status === "disabled" ? "disconnected" : selected.status}`,
|
|
733
|
+
endpoint,
|
|
734
|
+
selected.source.file
|
|
735
|
+
? `Config: ${shortenPath(selected.source.file, homedir(), 72)}`
|
|
736
|
+
: "Config: managed by registration source",
|
|
737
|
+
`Capabilities: ${selected.capabilities.join(", ") || "unknown until connected"}`,
|
|
738
|
+
`Tools: ${selected.counts.tools} · Resources: ${selected.counts.resources} · Prompts: ${selected.counts.prompts}`,
|
|
739
|
+
selected.diagnostic,
|
|
740
|
+
]
|
|
741
|
+
.filter(Boolean)
|
|
742
|
+
.join("\n");
|
|
743
|
+
const connected = selected.status === "connected";
|
|
744
|
+
showPicker(new Picker(selected.displayName, [
|
|
745
|
+
...(selected.counts.tools
|
|
746
|
+
? [
|
|
747
|
+
{
|
|
748
|
+
value: "tools",
|
|
749
|
+
label: "View tools",
|
|
750
|
+
description: `${selected.counts.tools} available`,
|
|
751
|
+
},
|
|
752
|
+
]
|
|
753
|
+
: []),
|
|
754
|
+
...(selected.status !== "disabled"
|
|
755
|
+
? [
|
|
756
|
+
{
|
|
757
|
+
value: "connect",
|
|
758
|
+
label: connected ? "Reconnect" : "Connect",
|
|
759
|
+
description: "Start a process or network connection",
|
|
760
|
+
},
|
|
761
|
+
]
|
|
762
|
+
: []),
|
|
763
|
+
{
|
|
764
|
+
value: "toggle",
|
|
765
|
+
label: selected.status === "disabled" ? "Enable" : "Disable",
|
|
766
|
+
description: `Persist in ${source.toLowerCase()} configuration`,
|
|
767
|
+
},
|
|
768
|
+
{ value: "back", label: "Back" },
|
|
769
|
+
], (action) => {
|
|
770
|
+
if (action.value === "back")
|
|
771
|
+
return openCatalog();
|
|
772
|
+
if (action.value === "tools") {
|
|
773
|
+
const tools = app.mcp.tools(selected.name);
|
|
774
|
+
return showPicker(new Picker(`${selected.displayName} tools (${tools.length})`, mcpToolItems(tools), () => { }, () => manageMcp(), true, "Annotations are server-declared. Unannotated tools are not assumed destructive."));
|
|
775
|
+
}
|
|
776
|
+
if (action.value === "connect") {
|
|
777
|
+
withMcpConsent(() => app.mcp
|
|
778
|
+
.reconnect(selected.name, AbortSignal.timeout(15_000))
|
|
779
|
+
.then(() => notice(`${selected.displayName} connected.`)));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const enabling = !selected.enabled;
|
|
783
|
+
const applyToggle = () => app
|
|
784
|
+
.setMcpEnabled(selected.name, enabling, enabling)
|
|
785
|
+
.then((updated) => notice(`${updated.displayName}: configured ${updated.enabled ? "enabled" : "disabled"}; ${updated.status.replace("-", " ")}.`));
|
|
786
|
+
if (enabling)
|
|
787
|
+
withMcpConsent(applyToggle);
|
|
788
|
+
else
|
|
789
|
+
void applyToggle().catch(error).finally(openCatalog);
|
|
790
|
+
}, openCatalog, false, detail));
|
|
791
|
+
}, closePicker, true, "[x] connected · [-] disconnected · [ ] disabled · [!] failed · [?] needs authentication · [*] restart required"));
|
|
792
|
+
};
|
|
793
|
+
const withMcpConsent = (action) => {
|
|
794
|
+
if (options.readOnly) {
|
|
795
|
+
error("MCP is unavailable under --read-only");
|
|
796
|
+
return openCatalog();
|
|
797
|
+
}
|
|
798
|
+
const run = () => void action().catch(error).finally(openCatalog);
|
|
799
|
+
if (app.mcpRuntimePermission() === "granted")
|
|
800
|
+
return run();
|
|
801
|
+
showPicker(new Picker("Grant MCP access for this session?", [
|
|
802
|
+
{
|
|
803
|
+
value: "grant",
|
|
804
|
+
label: "Grant and continue",
|
|
805
|
+
description: "May start the configured process or network connection",
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
value: "cancel",
|
|
809
|
+
label: "Cancel",
|
|
810
|
+
description: "Make no permission or server changes",
|
|
811
|
+
},
|
|
812
|
+
], (choice) => {
|
|
813
|
+
if (choice.value !== "grant")
|
|
814
|
+
return openCatalog();
|
|
815
|
+
try {
|
|
816
|
+
app.grantMcpRuntimePermission({ source: "interactive-tui", confirmed: true });
|
|
817
|
+
notice("MCP process/network access granted for this Alisio session only.");
|
|
818
|
+
run();
|
|
819
|
+
}
|
|
820
|
+
catch (cause) {
|
|
821
|
+
error(cause);
|
|
822
|
+
openCatalog();
|
|
823
|
+
}
|
|
824
|
+
}, openCatalog, false, "This grant is not saved. Configured server enablement is persisted separately. MCP servers and their tools run with your user privileges."));
|
|
825
|
+
};
|
|
826
|
+
openCatalog();
|
|
827
|
+
};
|
|
828
|
+
const workspaceSessions = () => app.store.list().filter((s) => s.workspace === app.workspace && s.provider === app.provider.id);
|
|
829
|
+
const firstPrompt = (id) => {
|
|
830
|
+
const first = app.store.messages(id).find((m) => m.role === "user" && !m.summary);
|
|
831
|
+
return first?.role === "user" ? first.text.replace(/\s+/g, " ").slice(0, 60) : "";
|
|
832
|
+
};
|
|
833
|
+
const resume = (target) => {
|
|
834
|
+
const matches = workspaceSessions().filter((s) => s.id.startsWith(target));
|
|
835
|
+
if (matches.length !== 1)
|
|
836
|
+
return error(matches.length
|
|
837
|
+
? `Ambiguous session prefix: ${target}`
|
|
838
|
+
: `No session in this workspace matches ${target}`);
|
|
839
|
+
const found = matches[0];
|
|
840
|
+
if (!found)
|
|
841
|
+
return;
|
|
842
|
+
session = found.id;
|
|
843
|
+
reset({
|
|
844
|
+
...initialViewState(found.model),
|
|
845
|
+
items: itemsFromHistory(app.store.messages(found.id)),
|
|
846
|
+
});
|
|
847
|
+
notice(`Resumed session ${found.id}`);
|
|
848
|
+
refreshEstimate();
|
|
849
|
+
};
|
|
850
|
+
const toolsReport = () => {
|
|
851
|
+
const policy = app.runner.policy;
|
|
852
|
+
const rows = app.registry
|
|
853
|
+
.list()
|
|
854
|
+
.map((t) => {
|
|
855
|
+
const effect = t.effect ?? "external";
|
|
856
|
+
const state = effect === "read" || effect === "internal" || policy[effect]
|
|
857
|
+
? "enabled"
|
|
858
|
+
: app.runner.approvals && (effect === "write" || effect === "process")
|
|
859
|
+
? "ask"
|
|
860
|
+
: "disabled";
|
|
861
|
+
return `| \`${t.name}\` | ${effect} | ${state} |`;
|
|
862
|
+
})
|
|
863
|
+
.join("\n");
|
|
864
|
+
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.`;
|
|
865
|
+
};
|
|
866
|
+
const statsReport = () => {
|
|
867
|
+
const s = view.stats;
|
|
868
|
+
const window = app.contextWindow(view.model);
|
|
869
|
+
const tools = Object.entries(s.tools)
|
|
870
|
+
.map(([name, t]) => `| \`${name}\` | ${t.calls} | ${t.errors} |`)
|
|
871
|
+
.join("\n");
|
|
872
|
+
return [
|
|
873
|
+
"**Session statistics**",
|
|
874
|
+
"",
|
|
875
|
+
`- Session: \`${session}\``,
|
|
876
|
+
`- Model: \`${view.model}\`${s.models.length > 1 ? ` (used: ${s.models.join(", ")})` : ""}`,
|
|
877
|
+
`- Tokens: in ${formatTokens(s.input)} · out ${formatTokens(s.output)} · cached ${formatTokens(s.cached)}`,
|
|
878
|
+
`- Runs: ${s.runs} · turns: ${s.turns}${s.lastRunMs !== undefined ? ` · last run ${formatDuration(s.lastRunMs)}` : ""}`,
|
|
879
|
+
`- Duration: ${formatDuration(Date.now() - s.startedAt)}`,
|
|
880
|
+
`- Context: ${formatContext(view.context?.used ?? 0, window, view.context?.estimated ?? true)}`,
|
|
881
|
+
"",
|
|
882
|
+
tools ? `| Tool | Calls | Errors |\n| --- | --- | --- |\n${tools}` : "No tool calls yet.",
|
|
883
|
+
"",
|
|
884
|
+
"**Extensions**",
|
|
885
|
+
"",
|
|
886
|
+
`- mascot: ${app.plugins.extensions.resolve("mascot")?.provider.id ?? "alisio.default"} · startup screen: ${app.plugins.extensions.resolve("startup-screen")?.provider.id ?? "alisio.default"}`,
|
|
887
|
+
...[...app.plugins.extensions.conflicts(), ...startupDiagnostics].map((d) => `- ${"winner" in d ? `extension_conflict ${d.point}: ${d.winner} over ${d.losers.join(", ")}` : `${d.type} ${d.source} ${d.hook}: ${d.error}`}`),
|
|
888
|
+
`- prompt templates: ${app.prompts.templates.size}`,
|
|
889
|
+
...app.prompts.diagnostics.map((d) => `- ${d.type} ${JSON.stringify(d).slice(0, 160)}`),
|
|
890
|
+
"",
|
|
891
|
+
...(app.plugins.status.size
|
|
892
|
+
? [
|
|
893
|
+
"**Plugins**",
|
|
894
|
+
"",
|
|
895
|
+
...[...app.plugins.status.values()].map((x) => `- ${x.plugin}: ${x.detail ?? x.text}`),
|
|
896
|
+
"",
|
|
897
|
+
]
|
|
898
|
+
: []),
|
|
899
|
+
"Token counts cover this TUI process only and depend on provider usage reports.",
|
|
900
|
+
].join("\n");
|
|
901
|
+
};
|
|
902
|
+
const helpReport = () => [
|
|
903
|
+
"**Commands**",
|
|
904
|
+
"",
|
|
905
|
+
...COMMANDS.map((c) => `- \`/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""}\` — ${c.description}${c.aliases ? ` (alias: ${c.aliases.map((a) => `/${a}`).join(", ")})` : ""}`),
|
|
906
|
+
...[...app.plugins.commandInfo.entries()]
|
|
907
|
+
.filter(([name]) => !resolveCommand(name))
|
|
908
|
+
.map(([name, c]) => `- \`/${name}${c.argumentHint ? ` ${c.argumentHint}` : ""}\` — ${c.description ?? "plugin command"} (plugin ${c.plugin})`),
|
|
909
|
+
...(app.prompts.templates.size
|
|
910
|
+
? [
|
|
911
|
+
"",
|
|
912
|
+
"**Prompt templates** (rendered and sent as your message)",
|
|
913
|
+
"",
|
|
914
|
+
...[...app.prompts.templates.values()].map((t) => `- \`/${t.name}${t.argumentHint ? ` ${t.argumentHint}` : ""}\` — ${t.description} (${t.source}${t.requires.length ? `, needs ${t.requires.join("+")}` : ""})`),
|
|
915
|
+
"",
|
|
916
|
+
]
|
|
917
|
+
: []),
|
|
918
|
+
"- `/skill:name request` — load a skill and send the request",
|
|
919
|
+
"- `/init` (above) writes AGENTS.md; the shell command `alisio setup` only scaffolds `.alisio/config.json`",
|
|
920
|
+
"- `/command plugin.id:name args` — run a plugin command",
|
|
921
|
+
"",
|
|
922
|
+
"**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",
|
|
923
|
+
`**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`,
|
|
924
|
+
].join("\n");
|
|
925
|
+
const mutating = new Set([
|
|
926
|
+
"connect",
|
|
927
|
+
"model",
|
|
928
|
+
"plugins",
|
|
929
|
+
"skills",
|
|
930
|
+
"mcp",
|
|
931
|
+
"compact",
|
|
932
|
+
"clear",
|
|
933
|
+
"resume",
|
|
934
|
+
]);
|
|
935
|
+
const handleSubmit = async (raw) => {
|
|
936
|
+
const text = raw.trim();
|
|
937
|
+
if (!text)
|
|
938
|
+
return;
|
|
939
|
+
editor.addToHistory(raw);
|
|
940
|
+
const parsed = parseCommand(text);
|
|
941
|
+
const name = parsed ? resolveCommand(parsed.name) : undefined;
|
|
942
|
+
const isTemplate = !!parsed && !name && app.prompts.templates.has(parsed.name);
|
|
943
|
+
if (busy &&
|
|
944
|
+
(!parsed || (name && mutating.has(name)) || parsed.name.startsWith("skill:") || isTemplate)) {
|
|
945
|
+
editor.setText(raw);
|
|
946
|
+
flashHint("A turn is running: press Esc to interrupt, or wait for it to finish");
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
if (!parsed)
|
|
951
|
+
return await runPrompt(raw, raw);
|
|
952
|
+
if (isTemplate) {
|
|
953
|
+
const expanded = app.expandPrompt(text);
|
|
954
|
+
if (expanded)
|
|
955
|
+
return await runPrompt(expanded.display, expanded.text, expanded.display);
|
|
956
|
+
}
|
|
957
|
+
if (parsed.name.startsWith("skill:")) {
|
|
958
|
+
const skill = await app.skills.load(parsed.name.slice(6));
|
|
959
|
+
return await runPrompt(text, `${skill}\n\nUser request: ${parsed.args}`);
|
|
960
|
+
}
|
|
961
|
+
if (parsed.name === "command") {
|
|
962
|
+
const [command, ...rest] = parsed.args.split(" ");
|
|
963
|
+
const handler = app.plugins.commands.get(command ?? "");
|
|
964
|
+
if (!handler)
|
|
965
|
+
return error(`Unknown plugin command: ${command ?? ""}`);
|
|
966
|
+
return info(await handler(rest.join(" ")));
|
|
967
|
+
}
|
|
968
|
+
switch (name) {
|
|
969
|
+
case "help":
|
|
970
|
+
return info(helpReport());
|
|
971
|
+
case "exit":
|
|
972
|
+
return void (await shutdown());
|
|
973
|
+
case "stats":
|
|
974
|
+
return info(statsReport());
|
|
975
|
+
case "tools":
|
|
976
|
+
return info(toolsReport());
|
|
977
|
+
case "connect":
|
|
978
|
+
return await connect();
|
|
979
|
+
case "model":
|
|
980
|
+
return await chooseModel();
|
|
981
|
+
case "plugins":
|
|
982
|
+
return managePlugins();
|
|
983
|
+
case "skills":
|
|
984
|
+
return manageSkills();
|
|
985
|
+
case "mcp":
|
|
986
|
+
return manageMcp();
|
|
987
|
+
case "compact":
|
|
988
|
+
return await task((signal) => app.runner.compact(session, { focus: parsed.args || undefined, signal }));
|
|
989
|
+
case "copy": {
|
|
990
|
+
const last = lastAssistantText(view.items);
|
|
991
|
+
if (!last)
|
|
992
|
+
return notice("No assistant response to copy yet");
|
|
993
|
+
const result = await copy(last);
|
|
994
|
+
const message = copyMessage(result);
|
|
995
|
+
tui.flash(message);
|
|
996
|
+
return result.ok ? undefined : notice(message);
|
|
997
|
+
}
|
|
998
|
+
case "ask": {
|
|
999
|
+
if (!parsed.args)
|
|
1000
|
+
return notice("Usage: /ask <question>");
|
|
1001
|
+
return await runPrompt(`/ask ${parsed.args}`, `The user has a question of their own and wants help turning it into a multiple-choice ` +
|
|
1002
|
+
`question: "${parsed.args}"\n\nPropose 2-4 concrete, mutually distinct options that ` +
|
|
1003
|
+
`would resolve it. Mark at most one option "recommended" only if you have a clear, ` +
|
|
1004
|
+
`well-justified opinion; it is a suggestion, never forced on the user. Then ` +
|
|
1005
|
+
`immediately call ask_user_question with exactly one question built from this ` +
|
|
1006
|
+
`(reuse the user's own wording for the question text). Do not answer in plain text ` +
|
|
1007
|
+
`first; call the tool right away.`);
|
|
1008
|
+
}
|
|
1009
|
+
case "clear": {
|
|
1010
|
+
await endSession("clear");
|
|
1011
|
+
session = app.store.create(app.workspace, app.provider.id, view.model).id;
|
|
1012
|
+
reset(initialViewState(view.model));
|
|
1013
|
+
notice(`New session ${session}`);
|
|
1014
|
+
void app.herdr.report("idle", session);
|
|
1015
|
+
return refreshEstimate();
|
|
1016
|
+
}
|
|
1017
|
+
case "sessions": {
|
|
1018
|
+
const list = workspaceSessions().slice(0, 20);
|
|
1019
|
+
if (!list.length)
|
|
1020
|
+
return notice("No sessions in this workspace");
|
|
1021
|
+
return info([
|
|
1022
|
+
"**Recent sessions** (use `/resume <id-prefix>`)",
|
|
1023
|
+
"",
|
|
1024
|
+
...list.map((s) => `- \`${s.id}\` ${s.id === session ? "**(current)** " : ""}${s.model} · ${app.store.messages(s.id).length} msgs · ${firstPrompt(s.id) || "_empty_"}`),
|
|
1025
|
+
].join("\n"));
|
|
1026
|
+
}
|
|
1027
|
+
case "resume":
|
|
1028
|
+
if (parsed.args)
|
|
1029
|
+
return resume(parsed.args);
|
|
1030
|
+
return showPicker(new Picker("Resume session", workspaceSessions()
|
|
1031
|
+
.slice(0, 50)
|
|
1032
|
+
.map((s) => ({
|
|
1033
|
+
value: s.id,
|
|
1034
|
+
label: `${shortId(s.id)}${s.id === session ? " (current)" : ""}`,
|
|
1035
|
+
description: `${s.model} · ${firstPrompt(s.id) || "empty"}`,
|
|
1036
|
+
})), (item) => {
|
|
1037
|
+
closePicker();
|
|
1038
|
+
resume(item.value);
|
|
1039
|
+
}, closePicker, true));
|
|
1040
|
+
default:
|
|
1041
|
+
// Plugin commands are routed generically (built-ins unprefixed, others `id:name`).
|
|
1042
|
+
if (app.plugins.commands.has(parsed.name))
|
|
1043
|
+
return info(await (app.plugins.commands.get(parsed.name)?.(parsed.args, { sessionId: session }) ??
|
|
1044
|
+
""));
|
|
1045
|
+
return error(`Unknown command /${parsed.name}. Type /help.`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
catch (e) {
|
|
1049
|
+
error(e);
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
editor.onSubmit = (text) => {
|
|
1053
|
+
void handleSubmit(text);
|
|
1054
|
+
};
|
|
1055
|
+
editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
|
|
1056
|
+
...COMMANDS,
|
|
1057
|
+
...[...app.prompts.templates.values()].map((t) => ({
|
|
1058
|
+
name: t.name,
|
|
1059
|
+
description: `${t.description} (template)`,
|
|
1060
|
+
...(t.argumentHint ? { argumentHint: t.argumentHint } : {}),
|
|
1061
|
+
})),
|
|
1062
|
+
...[...app.plugins.commandInfo.entries()]
|
|
1063
|
+
.filter(([name]) => !resolveCommand(name))
|
|
1064
|
+
.map(([name, c]) => ({
|
|
1065
|
+
name,
|
|
1066
|
+
description: c.description ?? `plugin ${c.plugin}`,
|
|
1067
|
+
...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
|
|
1068
|
+
})),
|
|
1069
|
+
].map((c) => ({
|
|
1070
|
+
name: c.name,
|
|
1071
|
+
description: c.description,
|
|
1072
|
+
...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
|
|
1073
|
+
...(c.name === "resume"
|
|
1074
|
+
? {
|
|
1075
|
+
getArgumentCompletions: (prefix) => workspaceSessions()
|
|
1076
|
+
.filter((s) => s.id.startsWith(prefix))
|
|
1077
|
+
.slice(0, 20)
|
|
1078
|
+
.map((s) => ({ value: s.id, label: shortId(s.id), description: s.model })),
|
|
1079
|
+
}
|
|
1080
|
+
: {}),
|
|
1081
|
+
})), app.workspace));
|
|
1082
|
+
// Interactive services for plugins: choices (e.g. worktree isolation) and session views.
|
|
1083
|
+
app.plugins.setInteractiveUI({
|
|
1084
|
+
// No session/label/signal on SelectRequest: queued FIFO like everything else, never withdrawn
|
|
1085
|
+
// early. Routed through the same queue as approve/askQuestions since plugin-subagents can call
|
|
1086
|
+
// `select` concurrently with an approval, which previously raced on the shared picker slot.
|
|
1087
|
+
select: (request) => interactiveQueue.submit({
|
|
1088
|
+
onWithdrawn: () => undefined,
|
|
1089
|
+
run: () => new Promise((resolve) => {
|
|
1090
|
+
showPicker(new Picker(request.title, request.options.map((o) => ({
|
|
1091
|
+
value: o.value,
|
|
1092
|
+
label: o.label,
|
|
1093
|
+
...(o.description ? { description: o.description } : {}),
|
|
1094
|
+
})), (item) => {
|
|
1095
|
+
closePicker();
|
|
1096
|
+
resolve(item.value);
|
|
1097
|
+
}, () => {
|
|
1098
|
+
closePicker();
|
|
1099
|
+
resolve(undefined);
|
|
1100
|
+
}));
|
|
1101
|
+
}),
|
|
1102
|
+
}),
|
|
1103
|
+
askQuestions: (request) => askQuestions(request),
|
|
1104
|
+
open: (sessionId) => {
|
|
1105
|
+
const node = panelNodes().find((n) => n.sessionId === sessionId);
|
|
1106
|
+
panelState = {
|
|
1107
|
+
...panelState,
|
|
1108
|
+
focus: "view",
|
|
1109
|
+
viewing: node?.id ?? sessionId,
|
|
1110
|
+
selected: node?.id ?? sessionId,
|
|
1111
|
+
};
|
|
1112
|
+
openChild(sessionId);
|
|
1113
|
+
return true;
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
const panelKey = (data) => {
|
|
1117
|
+
if (matchesKey(data, Key.up))
|
|
1118
|
+
return "up";
|
|
1119
|
+
if (matchesKey(data, Key.down))
|
|
1120
|
+
return "down";
|
|
1121
|
+
if (matchesKey(data, Key.left))
|
|
1122
|
+
return "left";
|
|
1123
|
+
if (matchesKey(data, Key.right))
|
|
1124
|
+
return "right";
|
|
1125
|
+
if (matchesKey(data, Key.enter))
|
|
1126
|
+
return "enter";
|
|
1127
|
+
if (matchesKey(data, Key.escape))
|
|
1128
|
+
return "escape";
|
|
1129
|
+
if (matchesKey(data, Key.tab))
|
|
1130
|
+
return "tab";
|
|
1131
|
+
if (matchesKey(data, Key.ctrl("k")))
|
|
1132
|
+
return "cancel";
|
|
1133
|
+
if (data === "y" || data === "Y")
|
|
1134
|
+
return "yes";
|
|
1135
|
+
if (data === "n" || data === "N")
|
|
1136
|
+
return "no";
|
|
1137
|
+
return undefined;
|
|
1138
|
+
};
|
|
1139
|
+
// Ctrl+V: attach a clipboard image (Ctrl+R removes the most recently attached one). Text
|
|
1140
|
+
// paste needs no wiring here: pi-tui's Editor already handles bracketed paste atomically.
|
|
1141
|
+
const attachmentLimits = {
|
|
1142
|
+
maxBytes: MAX_IMAGE_BYTES,
|
|
1143
|
+
maxCount: MAX_ATTACHMENTS_PER_MESSAGE,
|
|
1144
|
+
dimensions: getImageDimensions,
|
|
1145
|
+
};
|
|
1146
|
+
const pasteImage = async () => {
|
|
1147
|
+
const result = await pasteImageFromClipboard(getNativeClipboard(), pendingAttachments, attachmentLimits);
|
|
1148
|
+
pendingAttachments = result.list;
|
|
1149
|
+
if (result.message)
|
|
1150
|
+
flashHint(result.message);
|
|
1151
|
+
attachmentsBar.invalidate();
|
|
1152
|
+
tui.requestRender();
|
|
1153
|
+
};
|
|
1154
|
+
const removeAttachment = () => {
|
|
1155
|
+
const { list, removed } = removeLastAttachment(pendingAttachments);
|
|
1156
|
+
pendingAttachments = list;
|
|
1157
|
+
if (removed)
|
|
1158
|
+
flashHint(`Removed attachment: ${removed.mimeType}`);
|
|
1159
|
+
attachmentsBar.invalidate();
|
|
1160
|
+
tui.requestRender();
|
|
1161
|
+
};
|
|
1162
|
+
let lastCtrlC = 0;
|
|
1163
|
+
tui.addInputListener((data) => {
|
|
1164
|
+
if (!picker && panelState.focus === "editor") {
|
|
1165
|
+
if (matchesKey(data, Key.ctrl("v"))) {
|
|
1166
|
+
void pasteImage();
|
|
1167
|
+
return { consume: true };
|
|
1168
|
+
}
|
|
1169
|
+
if (matchesKey(data, Key.ctrl("r")) && pendingAttachments.length) {
|
|
1170
|
+
removeAttachment();
|
|
1171
|
+
return { consume: true };
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (!picker) {
|
|
1175
|
+
if (matchesKey(data, Key.ctrl("x"))) {
|
|
1176
|
+
const r = reducePanel(panelState, { type: "ctrlX", now: Date.now() }, panelNodes());
|
|
1177
|
+
if (!r.handled)
|
|
1178
|
+
flashHint("No agents to navigate yet");
|
|
1179
|
+
else
|
|
1180
|
+
applyPanel(r);
|
|
1181
|
+
return { consume: true };
|
|
1182
|
+
}
|
|
1183
|
+
if (matchesKey(data, Key.ctrl("b")) && busy) {
|
|
1184
|
+
void panelEntry()?.provider.action?.("background", undefined, { sessionId: session });
|
|
1185
|
+
flashHint("Moved running foreground agents to the background");
|
|
1186
|
+
return { consume: true };
|
|
1187
|
+
}
|
|
1188
|
+
if (panelState.focus !== "editor" || panelState.confirm) {
|
|
1189
|
+
const key = panelKey(data);
|
|
1190
|
+
if (key || panelState.focus === "view") {
|
|
1191
|
+
const r = reducePanel(panelState, { type: "key", key: key ?? "other", now: Date.now() }, panelNodes());
|
|
1192
|
+
applyPanel(r);
|
|
1193
|
+
if (r.handled || panelState.focus === "view")
|
|
1194
|
+
return { consume: true };
|
|
1195
|
+
return undefined;
|
|
1196
|
+
}
|
|
1197
|
+
// Typing returns focus to the editor.
|
|
1198
|
+
panelState = { ...panelState, focus: "editor" };
|
|
1199
|
+
tui.requestRender();
|
|
1200
|
+
return undefined;
|
|
1201
|
+
}
|
|
1202
|
+
if (matchesKey(data, Key.down) && !editor.getText() && !editor.isShowingAutocomplete()) {
|
|
1203
|
+
const r = reducePanel(panelState, { type: "key", key: "down", now: Date.now(), editorEmpty: true }, panelNodes());
|
|
1204
|
+
if (r.handled) {
|
|
1205
|
+
applyPanel(r);
|
|
1206
|
+
return { consume: true };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
if (matchesKey(data, Key.escape)) {
|
|
1211
|
+
if (busy && !picker && !editor.isShowingAutocomplete()) {
|
|
1212
|
+
controller?.abort(new Error("Interrupted by user"));
|
|
1213
|
+
flashHint("Interrupting…");
|
|
1214
|
+
return { consume: true };
|
|
1215
|
+
}
|
|
1216
|
+
return undefined;
|
|
1217
|
+
}
|
|
1218
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
1219
|
+
if (tui.hasActiveSelection())
|
|
1220
|
+
return undefined;
|
|
1221
|
+
if (picker) {
|
|
1222
|
+
picker.handleInput?.("\x1b");
|
|
1223
|
+
return { consume: true };
|
|
1224
|
+
}
|
|
1225
|
+
if (editor.getText()) {
|
|
1226
|
+
editor.setText("");
|
|
1227
|
+
tui.requestRender();
|
|
1228
|
+
return { consume: true };
|
|
1229
|
+
}
|
|
1230
|
+
if (busy) {
|
|
1231
|
+
controller?.abort(new Error("Interrupted by user"));
|
|
1232
|
+
return { consume: true };
|
|
1233
|
+
}
|
|
1234
|
+
const now = Date.now();
|
|
1235
|
+
if (now - lastCtrlC < 1500)
|
|
1236
|
+
void shutdown();
|
|
1237
|
+
else {
|
|
1238
|
+
lastCtrlC = now;
|
|
1239
|
+
flashHint("Press Ctrl+C again to exit", 1500);
|
|
1240
|
+
}
|
|
1241
|
+
return { consume: true };
|
|
1242
|
+
}
|
|
1243
|
+
if (matchesKey(data, Key.ctrl("d")) && !editor.getText() && !picker) {
|
|
1244
|
+
void shutdown();
|
|
1245
|
+
return { consume: true };
|
|
1246
|
+
}
|
|
1247
|
+
return undefined;
|
|
1248
|
+
});
|
|
1249
|
+
const ticker = setInterval(() => {
|
|
1250
|
+
clock.now = Date.now();
|
|
1251
|
+
if (busy ||
|
|
1252
|
+
view.items.some((i) => i.kind === "tool" && i.status === "running") ||
|
|
1253
|
+
panelNodes().some((n) => n.status === "running")) {
|
|
1254
|
+
clock.frame++;
|
|
1255
|
+
tui.requestRender();
|
|
1256
|
+
}
|
|
1257
|
+
}, 100);
|
|
1258
|
+
const onSignal = () => void shutdown();
|
|
1259
|
+
process.on("SIGTERM", onSignal);
|
|
1260
|
+
process.on("SIGHUP", onSignal);
|
|
1261
|
+
try {
|
|
1262
|
+
await app.herdr.report("idle", session);
|
|
1263
|
+
tui.setFocus(editor);
|
|
1264
|
+
const { bannerPolicy, startupInput, terminalCapabilities } = await import("../banner.js");
|
|
1265
|
+
if (bannerPolicy({
|
|
1266
|
+
mode: "tui",
|
|
1267
|
+
...options,
|
|
1268
|
+
stdoutTTY: !!process.stdout.isTTY,
|
|
1269
|
+
stderrTTY: !!process.stderr.isTTY,
|
|
1270
|
+
env: process.env,
|
|
1271
|
+
})) {
|
|
1272
|
+
const { renderStartup } = await import("@alisio/core");
|
|
1273
|
+
const banner = new BannerBlock((width) => {
|
|
1274
|
+
const result = renderStartup(app.plugins, startupInput(app, {
|
|
1275
|
+
version: VERSION,
|
|
1276
|
+
model: view.model,
|
|
1277
|
+
readOnly: !!options.readOnly,
|
|
1278
|
+
terminal: terminalCapabilities({ env: process.env, columns: width, tty: true }),
|
|
1279
|
+
}));
|
|
1280
|
+
// Report provider problems once; later renders (resize) reuse the same resolution.
|
|
1281
|
+
if (!startupDiagnostics.length && result.diagnostics.length) {
|
|
1282
|
+
startupDiagnostics.push(...result.diagnostics);
|
|
1283
|
+
queueMicrotask(() => {
|
|
1284
|
+
for (const d of result.diagnostics)
|
|
1285
|
+
notice(d.type === "extension_conflict"
|
|
1286
|
+
? `Extension conflict on ${d.point}: ${d.winner} chosen over ${d.losers.join(", ")}`
|
|
1287
|
+
: `Plugin ${d.source} ${d.hook} failed: ${d.error} (default used)`);
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
return result.lines;
|
|
1291
|
+
});
|
|
1292
|
+
// First block of the conversation; it scrolls away naturally.
|
|
1293
|
+
transcript.addChild(banner);
|
|
1294
|
+
}
|
|
1295
|
+
tui.start();
|
|
1296
|
+
sync();
|
|
1297
|
+
refreshEstimate();
|
|
1298
|
+
// Discover context windows in the background; failures only mean "unknown".
|
|
1299
|
+
models()
|
|
1300
|
+
.then(() => tui.requestRender())
|
|
1301
|
+
.catch(() => { });
|
|
1302
|
+
await exited;
|
|
1303
|
+
}
|
|
1304
|
+
finally {
|
|
1305
|
+
clearInterval(ticker);
|
|
1306
|
+
clearTimeout(hintTimer);
|
|
1307
|
+
process.off("SIGTERM", onSignal);
|
|
1308
|
+
process.off("SIGHUP", onSignal);
|
|
1309
|
+
tui.stop();
|
|
1310
|
+
await app.close();
|
|
1311
|
+
process.stdout.write(`\nSession: ${session}\n`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|