@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.
@@ -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
+ }