@visual-z/xeno 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +55 -16
- package/docs/app-server.md +86 -0
- package/docs/install.md +1 -1
- package/docs/providers.md +20 -6
- package/package.json +3 -2
- package/src/apps/app-server.ts +28 -0
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +10 -6
- package/src/apps/completions.ts +15 -13
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/preferences.ts +20 -0
- package/src/apps/provider-connect.ts +4 -4
- package/src/apps/shell.ts +73 -50
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +52 -19
- package/src/apps/ui/app.ts +210 -56
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +38 -29
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/event-validation.ts +1 -1
- package/src/core/events.ts +2 -0
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/permissions.ts +13 -7
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +25 -1
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/settings.ts +8 -1
- package/src/core/tool-input.ts +6 -0
- package/src/runtime/auth.ts +10 -3
- package/src/runtime/catalog.ts +21 -4
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/harness-eval.ts +3 -1
- package/src/runtime/loop.ts +72 -19
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval.ts +6 -3
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +2 -2
- package/src/runtime/provider-presets.ts +1 -1
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tools.ts +11 -9
- package/src/store/resources.ts +13 -22
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/composer.ts +3 -2
- package/src/tui/ui/control-panel.ts +25 -0
- package/src/tui/ui/control-sidebar.ts +49 -0
- package/src/tui/ui/mascot.ts +1 -1
- package/src/tui/ui/screen.ts +107 -11
- package/src/tui/ui/status.ts +8 -0
package/src/apps/ui/app.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { pickerRows } from "../../core/model-picker.ts";
|
|
|
25
25
|
import { modelKey } from "../../core/session-position.ts";
|
|
26
26
|
import { flattenTree, type TreeFilter, type TreeNode } from "../../core/session-tree.ts";
|
|
27
27
|
import { selectSessions, type SessionSort } from "../../core/session-browser.ts";
|
|
28
|
-
import { DISPLAY_SETTINGS,
|
|
28
|
+
import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settings.ts";
|
|
29
29
|
import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
|
|
30
30
|
import { builtinTheme } from "../../core/theme.ts";
|
|
31
31
|
import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
|
|
@@ -36,7 +36,7 @@ import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
|
|
|
36
36
|
import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
|
|
37
37
|
import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
|
|
38
38
|
import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
|
|
39
|
-
import { SUBSCRIPTION_OPTIONS, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
|
|
39
|
+
import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
|
|
40
40
|
import { reportedCost } from "../../runtime/usage.ts";
|
|
41
41
|
import { fetchLatestVersion } from "../../runtime/update-check.ts";
|
|
42
42
|
import { installKind, updateNotice, updatePlan } from "../../core/update-check.ts";
|
|
@@ -50,7 +50,8 @@ import { projectTrust } from "../../store/trust.ts";
|
|
|
50
50
|
import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
|
|
51
51
|
import { nextResourceOverride } from "../../core/resource-selection.ts";
|
|
52
52
|
import { archiveSession, renameSession, scanSessionRoots, sessionRoots } from "../../store/session-browser.ts";
|
|
53
|
-
import { discoverThemes, removeModelThinkingOverride, selectedThemePath, watchTheme,
|
|
53
|
+
import { discoverThemes, removeModelThinkingOverride, selectedThemePath, watchTheme, loadUiResources } from "../../store/resources.ts";
|
|
54
|
+
import { commitUserSettings } from "../preferences.ts";
|
|
54
55
|
import { fuzzyMatch } from "../../tui/fuzzy.ts";
|
|
55
56
|
import type { DialogSpec, ListAction } from "../../tui/ui/dialog.ts";
|
|
56
57
|
import { keyPress, bound, type KeyPress } from "../../tui/ui/keys.ts";
|
|
@@ -64,6 +65,7 @@ import { displayPath } from "../../tui/ui/paths.ts";
|
|
|
64
65
|
import { approvalHeading, approvalItems, choiceFromValue } from "../approve.ts";
|
|
65
66
|
import { completionAt, completionSources, commandEntries } from "../completions.ts";
|
|
66
67
|
import { commandDefinition } from "../commands.ts";
|
|
68
|
+
import { completionAction } from "../../core/completion-action.ts";
|
|
67
69
|
import { expandInput } from "../input.ts";
|
|
68
70
|
import type { TrustChoice } from "../project-trust.ts";
|
|
69
71
|
import { describeError, firstLine } from "../render.ts";
|
|
@@ -79,6 +81,11 @@ import type { OAuthEffects, Ui } from "./surface.ts";
|
|
|
79
81
|
import { styleTokens, terminalPalette, uiColors } from "./theme.ts";
|
|
80
82
|
import { ImageCache } from "./image-cache.ts";
|
|
81
83
|
import { ImageDraft } from "../image-draft.ts";
|
|
84
|
+
import { sessionControl, type SessionControl, type ControlSnapshot } from "../../runtime/session-control.ts";
|
|
85
|
+
import { controlModel } from "./control-model.ts";
|
|
86
|
+
import type { ControlTab } from "../../tui/ui/control-panel.ts";
|
|
87
|
+
import { programStatus } from "../../core/program-status.ts";
|
|
88
|
+
import { ProgramStatusReporter } from "../../runtime/program-status.ts";
|
|
82
89
|
import type { PictureSource } from "../../tui/ui/pictures.ts";
|
|
83
90
|
import { DEFAULT_CELL } from "../../tui/ui/image-size.ts";
|
|
84
91
|
import type { TerminalPalette } from "../../tui/ui/ink.ts";
|
|
@@ -110,7 +117,7 @@ export class InteractiveApp implements Ui {
|
|
|
110
117
|
oauthEffects: OAuthEffects = { ...loginEffects };
|
|
111
118
|
clipboardWriter: (text: string) => boolean | Promise<boolean> = writeClipboard;
|
|
112
119
|
clipboardReader: () => Promise<ClipboardContent> = readClipboard;
|
|
113
|
-
/** Test seam for /
|
|
120
|
+
/** Test seam for /provider custom endpoints. */
|
|
114
121
|
connectFetch: ((input: string, init?: RequestInit) => Promise<Response>) | undefined;
|
|
115
122
|
sessionName: string | null = null;
|
|
116
123
|
queue: QueuedMessage[] = [];
|
|
@@ -138,6 +145,12 @@ export class InteractiveApp implements Ui {
|
|
|
138
145
|
private paintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
139
146
|
/** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
|
|
140
147
|
private derived: ReturnType<typeof handoffStatus> | undefined;
|
|
148
|
+
private control: SessionControl | undefined;
|
|
149
|
+
private stopControl: (() => void) | undefined;
|
|
150
|
+
private controlSnapshot: ControlSnapshot | undefined;
|
|
151
|
+
private readonly program: ProgramStatusReporter | undefined;
|
|
152
|
+
private readonly stopOsc: (() => void) | undefined;
|
|
153
|
+
private programResult = false;
|
|
141
154
|
private derivedKey = "";
|
|
142
155
|
private derivedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
143
156
|
private derivedRun: Promise<void> | null = null;
|
|
@@ -191,10 +204,12 @@ export class InteractiveApp implements Ui {
|
|
|
191
204
|
/** Set once the renderer is gone; late async work (timers, warmed grammars) must not touch it. */
|
|
192
205
|
private disposed = false;
|
|
193
206
|
|
|
194
|
-
constructor(app: App, hooks: Hooks, renderer: CliRenderer) {
|
|
207
|
+
constructor(app: App, hooks: Hooks, renderer: CliRenderer, program?: ProgramStatusReporter) {
|
|
195
208
|
this.app = app;
|
|
196
209
|
this.hooks = hooks;
|
|
197
210
|
this.renderer = renderer;
|
|
211
|
+
this.program = program;
|
|
212
|
+
this.stopOsc = program ? renderer.subscribeOsc(sequence => program.receive(sequence)) : undefined;
|
|
198
213
|
this.closed = new Promise(resolve => { this.closedResolve = resolve; });
|
|
199
214
|
this.branch = currentBranch(app.workspace);
|
|
200
215
|
this.sessionName = app.args.sessionName || null;
|
|
@@ -202,6 +217,10 @@ export class InteractiveApp implements Ui {
|
|
|
202
217
|
this.screen = new Screen(renderer, this.colors(), () => this.keymap().byAction, {
|
|
203
218
|
codeBlock: (code, language) => highlightCode(code, language, (token, text) => this.app.ui?.style.token?.(token, text) ?? ANSI.token?.(token, text) ?? text),
|
|
204
219
|
});
|
|
220
|
+
this.screen.onControlAction = action => {
|
|
221
|
+
if (action === "interrupt") this.interrupt();
|
|
222
|
+
else this.track(async () => { if (action === "refresh") await this.refreshControl(); else this.acceptControlCriterion(); });
|
|
223
|
+
};
|
|
205
224
|
this.imageDraft = new ImageDraft(this.screen.composer);
|
|
206
225
|
this.extensions = new ExtensionHost({
|
|
207
226
|
dialog: spec => this.screen.dialogs.push(spec),
|
|
@@ -274,22 +293,28 @@ export class InteractiveApp implements Ui {
|
|
|
274
293
|
redraw(): void { this.paint(); }
|
|
275
294
|
|
|
276
295
|
askSecret(title: string, label: string, hint: string): Promise<string | undefined> {
|
|
296
|
+
const control = sessionControl(this.app.session); control.setWaiting("auth");
|
|
277
297
|
return new Promise(resolve => this.screen.dialogs.push({ kind: "form", title, label, secret: true, hint,
|
|
278
|
-
onSubmit: value =>
|
|
298
|
+
onSubmit: value => { control.setWaiting(null); resolve(value); },
|
|
299
|
+
onCancel: () => { control.setWaiting(null); resolve(undefined); } }));
|
|
279
300
|
}
|
|
280
301
|
confirm(title: string, message: string): Promise<boolean> {
|
|
281
|
-
|
|
302
|
+
const control = sessionControl(this.app.session); control.setWaiting("question");
|
|
303
|
+
return new Promise(resolve => this.screen.dialogs.push({ kind: "confirm", title, message, onAnswer: yes => { control.setWaiting(null); resolve(yes); } }));
|
|
282
304
|
}
|
|
283
305
|
waiting(title: string, lines: readonly string[], onCancel: () => void, pasteLabel?: string, onPaste?: (value: string) => void): () => void {
|
|
306
|
+
const control = sessionControl(this.app.session); control.setWaiting("auth");
|
|
307
|
+
const cancel = () => { control.setWaiting(null); onCancel(); };
|
|
284
308
|
const spec: DialogSpec = pasteLabel && onPaste
|
|
285
|
-
? { kind: "form", title, label: pasteLabel, secret: true, intro: lines, hint: "waiting for the browser · paste the redirect URL + enter · esc cancel", onSubmit: onPaste, onCancel }
|
|
286
|
-
: { kind: "busy", title, message: lines.join("\n"), onCancel };
|
|
309
|
+
? { kind: "form", title, label: pasteLabel, secret: true, intro: lines, hint: "waiting for the browser · paste the redirect URL + enter · esc cancel", onSubmit: onPaste, onCancel: cancel }
|
|
310
|
+
: { kind: "busy", title, message: lines.join("\n"), onCancel: cancel };
|
|
287
311
|
this.screen.dialogs.push(spec);
|
|
288
|
-
return () => this.screen.dialogs.remove(spec);
|
|
312
|
+
return () => { this.screen.dialogs.remove(spec); control.setWaiting(null); };
|
|
289
313
|
}
|
|
290
314
|
|
|
291
315
|
// ----------------------------------------------------------------- lifecycle
|
|
292
316
|
async start(): Promise<void> {
|
|
317
|
+
this.program?.start();
|
|
293
318
|
this.applyConfiguration();
|
|
294
319
|
this.followTheme();
|
|
295
320
|
void this.detectPalette();
|
|
@@ -341,7 +366,12 @@ export class InteractiveApp implements Ui {
|
|
|
341
366
|
this.abort?.abort();
|
|
342
367
|
this.closedResolve();
|
|
343
368
|
}
|
|
344
|
-
interrupt(): void {
|
|
369
|
+
interrupt(): void {
|
|
370
|
+
this.abort?.abort();
|
|
371
|
+
const control = sessionControl(this.app.session);
|
|
372
|
+
if (control.activeTurnId) control.interrupt(control.activeTurnId);
|
|
373
|
+
this.returnQueueToEditor();
|
|
374
|
+
}
|
|
345
375
|
|
|
346
376
|
async settle(): Promise<void> {
|
|
347
377
|
await this.hooks.cancelPendingOAuth(this.app);
|
|
@@ -356,6 +386,8 @@ export class InteractiveApp implements Ui {
|
|
|
356
386
|
|
|
357
387
|
dispose(): void {
|
|
358
388
|
this.disposed = true;
|
|
389
|
+
this.stopControl?.();
|
|
390
|
+
this.stopOsc?.(); this.program?.close();
|
|
359
391
|
clearTimeout(this.paintTimer); clearInterval(this.retryTimer); clearTimeout(this.derivedTimer); this.derivedTimer = undefined;
|
|
360
392
|
this.stopThemeWatch?.();
|
|
361
393
|
this.renderer.off("theme_mode", this.terminalThemeChanged);
|
|
@@ -376,6 +408,7 @@ export class InteractiveApp implements Ui {
|
|
|
376
408
|
|
|
377
409
|
// --------------------------------------------------------------- transcript
|
|
378
410
|
async syncTranscript(): Promise<void> {
|
|
411
|
+
this.bindControl();
|
|
379
412
|
const session = this.app.session, dir = this.app.logDir, node = session.state.node;
|
|
380
413
|
if (dir !== this.historyDir || node !== this.historyNode) {
|
|
381
414
|
const history = await readNodeHistory(dir, node);
|
|
@@ -509,7 +542,7 @@ export class InteractiveApp implements Ui {
|
|
|
509
542
|
const app = this.app, spec = app.providerConfig.spec, budget = this.budget();
|
|
510
543
|
const t = app.session.totals, cost = reportedCost(t, spec.price);
|
|
511
544
|
const tools = items.filter(item => item.kind === "tool");
|
|
512
|
-
const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /
|
|
545
|
+
const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
|
|
513
546
|
return {
|
|
514
547
|
version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
|
|
515
548
|
workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
|
|
@@ -519,6 +552,11 @@ export class InteractiveApp implements Ui {
|
|
|
519
552
|
cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null } } : {}),
|
|
520
553
|
tools: { running: tools.filter(tool => tool.status === "pending").length, done: tools.filter(tool => tool.status === "ok").length, failed: tools.filter(tool => tool.status === "error").length },
|
|
521
554
|
...(this.derived ?? {}),
|
|
555
|
+
...(this.controlSnapshot?.goal ? { goal: { text: this.controlSnapshot.goal.text,
|
|
556
|
+
status: this.controlSnapshot.goal.state.status,
|
|
557
|
+
execution: this.inflight ? "running" : this.controlSnapshot.goal.state.execution?.reason ?? "idle",
|
|
558
|
+
observations: this.controlSnapshot.goal.state.progress.observations.length,
|
|
559
|
+
retries: this.controlSnapshot.goal.state.progress.unchanged_retries.length } } : {}),
|
|
522
560
|
files: this.files,
|
|
523
561
|
resources: { contextFiles: app.loadedContext.agents.map(file => basename(file.path)), skills: app.slash?.skills.size ?? 0, extensions: app.extensions?.loaded.length ?? 0 },
|
|
524
562
|
...(extension ? { extension } : {}),
|
|
@@ -557,7 +595,7 @@ export class InteractiveApp implements Ui {
|
|
|
557
595
|
* per debounce window, one run at a time, and only while a surface shows it.
|
|
558
596
|
*/
|
|
559
597
|
private requestDerived(): void {
|
|
560
|
-
if (this.disposed || this.screen.tier === "bare") return;
|
|
598
|
+
if (this.disposed || (this.screen.tier === "bare" && !this.screen.controlOpen)) return;
|
|
561
599
|
if (this.derivedKey === this.derivedTarget()) return;
|
|
562
600
|
if (this.derivedRun || this.derivedTimer) { this.derivedAgain = true; return; }
|
|
563
601
|
// Throttle, not a pure debounce: the first change after a quiet spell refreshes at once,
|
|
@@ -571,8 +609,25 @@ export class InteractiveApp implements Ui {
|
|
|
571
609
|
this.derivedLast = Date.now();
|
|
572
610
|
const target = this.derivedTarget();
|
|
573
611
|
this.derivedRun = (async () => {
|
|
574
|
-
|
|
575
|
-
|
|
612
|
+
const session = this.app.session;
|
|
613
|
+
try {
|
|
614
|
+
const derived = handoffStatus(await session.previewHandoff());
|
|
615
|
+
const snapshot = await sessionControl(session).snapshot(false);
|
|
616
|
+
if (this.disposed || this.app.session !== session) return;
|
|
617
|
+
this.derived = derived; this.controlSnapshot = snapshot; this.derivedKey = target;
|
|
618
|
+
// Idle acceptance changes have no model delta. Reconcile terminal attention
|
|
619
|
+
// with the same evidence projection that refreshes the visible goal.
|
|
620
|
+
if (!this.inflight) this.program?.update(programStatus({ running: false, waiting: snapshot.waiting,
|
|
621
|
+
...(snapshot.goal?.state.execution ? { outcome: snapshot.goal.state.execution } : {}),
|
|
622
|
+
...(snapshot.goal ? { goal: snapshot.goal.state.status } : {}) }));
|
|
623
|
+
this.screen.setControlModel(controlModel(snapshot));
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// Unavailable evidence cannot leave a stale verified goal visible.
|
|
627
|
+
if (this.disposed || this.app.session !== session) return;
|
|
628
|
+
this.controlSnapshot = undefined;
|
|
629
|
+
this.screen.setControlModel(undefined);
|
|
630
|
+
}
|
|
576
631
|
})();
|
|
577
632
|
await this.derivedRun;
|
|
578
633
|
this.derivedRun = null;
|
|
@@ -587,6 +642,7 @@ export class InteractiveApp implements Ui {
|
|
|
587
642
|
private onPaste(event: PasteEvent): void {
|
|
588
643
|
const text = new TextDecoder().decode(event.bytes);
|
|
589
644
|
if (this.screen.dialogs.open) { if (this.screen.dialogs.paste(text)) return; event.preventDefault(); return; }
|
|
645
|
+
this.screen.focusComposer();
|
|
590
646
|
const paths = droppedImagePaths(text);
|
|
591
647
|
if (paths) { event.preventDefault(); this.track(() => this.pasteClipboard({ files: paths })); }
|
|
592
648
|
}
|
|
@@ -602,22 +658,25 @@ export class InteractiveApp implements Ui {
|
|
|
602
658
|
if (bound(b, "app.exit", key) && !this.screen.dialogs.open && !this.screen.composer.text && !this.pendingImages.length) { this.requestShutdown(); return true; }
|
|
603
659
|
if (bound(b, "app.suspend", key)) { this.suspend(); return true; }
|
|
604
660
|
if (this.screen.dialogs.open) return this.screen.dialogs.key(key);
|
|
661
|
+
if (this.screen.handleControlKey(key)) return true;
|
|
605
662
|
const composer = this.screen.composer;
|
|
606
663
|
if (composer.completing) {
|
|
607
664
|
if (key.chord === "up") { composer.moveCompletion(-1); return true; }
|
|
608
665
|
if (key.chord === "down") { composer.moveCompletion(1); return true; }
|
|
609
666
|
if (key.chord === "escape") { composer.setCompletion(undefined); return true; }
|
|
610
|
-
if (key.chord === "tab"
|
|
611
|
-
|
|
667
|
+
if (key.chord === "tab" || key.chord === "enter") {
|
|
668
|
+
const kind = composer.completionView!.kind;
|
|
612
669
|
const item = composer.acceptCompletion();
|
|
613
670
|
if (item) {
|
|
614
|
-
const definition = commandDefinition(
|
|
615
|
-
|
|
616
|
-
|
|
671
|
+
const definition = commandDefinition(composer.text);
|
|
672
|
+
const required = !!definition && "argumentHint" in definition && definition.argumentHint.startsWith("<");
|
|
673
|
+
if (completionAction(kind, key.chord, required) === "submit") {
|
|
674
|
+
const text = composer.text;
|
|
675
|
+
this.track(() => this.submit(text, "steering"));
|
|
676
|
+
} else { this.updateCompletion(); this.paint(); }
|
|
617
677
|
}
|
|
678
|
+
return true;
|
|
618
679
|
}
|
|
619
|
-
if (key.chord === "enter" && !composer.text.trimEnd().match(/^\/\S+\s*$/)) { composer.acceptCompletion(); this.updateCompletion(); return true; }
|
|
620
|
-
if (key.chord === "enter") { composer.setCompletion(undefined); }
|
|
621
680
|
}
|
|
622
681
|
if (bound(b, "app.palette", key)) { this.openPalette(); return true; }
|
|
623
682
|
if (bound(b, "app.clear", key)) {
|
|
@@ -694,7 +753,7 @@ export class InteractiveApp implements Ui {
|
|
|
694
753
|
async whenIdle(): Promise<void> {
|
|
695
754
|
for (let round = 0; round < 50; round++) {
|
|
696
755
|
const turn = this.turn, work = this.work.size;
|
|
697
|
-
await turn; await this.idle(); await this.app.session.whenIdle(); await this.app.extensions?.settle();
|
|
756
|
+
await turn; await this.control?.whenIdle().catch(() => {}); await this.idle(); await this.app.session.whenIdle(); await this.app.extensions?.settle();
|
|
698
757
|
if (turn === this.turn && !this.inflight && work === this.work.size && !this.work.size) return;
|
|
699
758
|
}
|
|
700
759
|
}
|
|
@@ -743,12 +802,22 @@ export class InteractiveApp implements Ui {
|
|
|
743
802
|
private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
|
|
744
803
|
this.abort = new AbortController();
|
|
745
804
|
this.inflight = true;
|
|
805
|
+
this.programResult = false;
|
|
806
|
+
this.program?.update(programStatus({ running: true }));
|
|
746
807
|
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
747
808
|
this.paint();
|
|
748
809
|
try {
|
|
749
810
|
const sent = await this.hooks.run(this.app, text, this.abort.signal, this, images, skill);
|
|
811
|
+
if (!sent && !this.app.credential.ok) {
|
|
812
|
+
this.programResult = true; this.program?.update(programStatus({ running: false, waiting: "auth" }));
|
|
813
|
+
}
|
|
750
814
|
if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
|
|
815
|
+
} catch (error) {
|
|
816
|
+
this.programResult = true;
|
|
817
|
+
this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
818
|
+
throw error;
|
|
751
819
|
} finally {
|
|
820
|
+
if (!this.programResult) this.program?.update(programStatus({ running: false }));
|
|
752
821
|
await this.syncTranscript();
|
|
753
822
|
this.abort = null;
|
|
754
823
|
this.hooks.dropFileIndex(this.app.workspace);
|
|
@@ -956,15 +1025,118 @@ export class InteractiveApp implements Ui {
|
|
|
956
1025
|
|
|
957
1026
|
// ---------------------------------------------------------------- approval
|
|
958
1027
|
ask(request: PermissionRequest): Promise<ApprovalChoice> {
|
|
1028
|
+
this.program?.update(programStatus({ running: this.inflight, waiting: "permission" }));
|
|
959
1029
|
// While the dialog is open the pending call shows ◌: it is waiting on the person, not running.
|
|
960
1030
|
this.screen.messages.setWaiting(true);
|
|
961
|
-
const
|
|
1031
|
+
const present = (_id: string, resolve: (choice: ApprovalChoice) => void) => {
|
|
1032
|
+
const spec: DialogSpec = { kind: "list", title: "Tool approval", search: false, tone: "warning", intro: approvalHeading(request).split("\n"),
|
|
1033
|
+
items: approvalItems(request), onPick: item => resolve(choiceFromValue(item.value)), onCancel: () => resolve("deny") };
|
|
1034
|
+
this.screen.dialogs.push(spec);
|
|
1035
|
+
return () => { this.screen.messages.setWaiting(false); this.screen.dialogs.remove(spec); this.program?.update(programStatus({ running: this.inflight })); };
|
|
1036
|
+
};
|
|
1037
|
+
// Isolated skills and user shell commands retain their existing local approval lifecycle.
|
|
1038
|
+
const control = sessionControl(this.app.session);
|
|
1039
|
+
if (control.activeTurnId) return control.ask(request, present);
|
|
962
1040
|
return new Promise(resolve => {
|
|
963
|
-
|
|
964
|
-
|
|
1041
|
+
let close: () => void;
|
|
1042
|
+
close = present("local", choice => { close(); resolve(choice); });
|
|
965
1043
|
});
|
|
966
1044
|
}
|
|
967
1045
|
|
|
1046
|
+
private bindControl(): void {
|
|
1047
|
+
const control = sessionControl(this.app.session);
|
|
1048
|
+
if (this.control === control) return;
|
|
1049
|
+
this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
|
|
1050
|
+
this.screen.setControlModel(undefined);
|
|
1051
|
+
const detachInput = control.attachInput(phase => this.pullQueued(phase));
|
|
1052
|
+
const unsubscribe = control.subscribe(notice => {
|
|
1053
|
+
if (this.disposed) return;
|
|
1054
|
+
if (notice.method === "turn/started") this.program?.update(programStatus({ running: true }));
|
|
1055
|
+
if (notice.method === "turn/error") {
|
|
1056
|
+
this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
1057
|
+
this.track(() => this.syncTranscript());
|
|
1058
|
+
}
|
|
1059
|
+
if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
|
|
1060
|
+
if (notice.method === "thread/statusChanged" && "waiting" in notice.params)
|
|
1061
|
+
this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
|
|
1062
|
+
// A remote client drives the same Session; local turns already have the shell's stream adapter.
|
|
1063
|
+
if (!this.abort) {
|
|
1064
|
+
if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
|
|
1065
|
+
if (notice.method === "turn/completed") {
|
|
1066
|
+
this.inflight = false; this.executionKind = "idle"; this.clearStream();
|
|
1067
|
+
const outcome = notice.params["outcome"] as import("../../core/outcome.ts").ExecutionOutcome | undefined;
|
|
1068
|
+
this.turn = this.continueRemoteTurn(control, outcome).catch(error => this.notice(describeError(error), "error"));
|
|
1069
|
+
}
|
|
1070
|
+
if (notice.method === "turn/event") {
|
|
1071
|
+
const event = notice.params["event"] as import("../../runtime/loop.ts").LoopEvent;
|
|
1072
|
+
this.observeEvent(event);
|
|
1073
|
+
if (event.kind === "text_delta" || event.kind === "thinking_delta") this.pushDelta(event.kind === "text_delta" ? "text" : "thinking", event.text ?? "");
|
|
1074
|
+
else if (event.kind === "discard") this.discardStream();
|
|
1075
|
+
else if (event.kind === "progress") { if (event.progress) this.pushProgress(event.progress); }
|
|
1076
|
+
else this.track(() => this.syncTranscript());
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (notice.method !== "turn/event") { this.derivedKey = ""; this.requestDerived(); this.paint(); }
|
|
1080
|
+
});
|
|
1081
|
+
this.stopControl = () => { detachInput(); unsubscribe(); };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
private async continueRemoteTurn(control: SessionControl, outcome?: import("../../core/outcome.ts").ExecutionOutcome): Promise<void> {
|
|
1085
|
+
// Completion is announced before the iterator releases execution. Follow-ups
|
|
1086
|
+
// remain editor-owned until that release, preserving the existing queue contract.
|
|
1087
|
+
await control.whenIdle().catch(() => {});
|
|
1088
|
+
if (this.disposed || this.shutdownRequested || this.control !== control || this.inflight) return;
|
|
1089
|
+
if (outcome?.status !== "completed") { this.returnQueueToEditor(); return; }
|
|
1090
|
+
const batch = this.nextBatch("turn_end");
|
|
1091
|
+
if (!batch.length) return;
|
|
1092
|
+
this.queue = this.queue.filter(item => !batch.includes(item));
|
|
1093
|
+
await this.runPromptTurn(batch.map(item => item.text).join("\n\n"), batch.flatMap(item => item.images ?? []));
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
|
|
1097
|
+
if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
|
|
1098
|
+
if (event.kind === "done") this.program?.update(programStatus({ running: false,
|
|
1099
|
+
...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
|
|
1100
|
+
if (event.kind === "stop_and_ask") this.program?.update(programStatus({ running: false, waiting: "question" }));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async openControl(tab: ControlTab = "goal"): Promise<void> {
|
|
1104
|
+
this.bindControl();
|
|
1105
|
+
if (!await this.refreshControl()) return;
|
|
1106
|
+
this.screen.showControl(controlModel(this.controlSnapshot!), tab);
|
|
1107
|
+
this.requestDerived();
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private async refreshControl(): Promise<boolean> {
|
|
1111
|
+
const control = sessionControl(this.app.session);
|
|
1112
|
+
try {
|
|
1113
|
+
const snapshot = await control.snapshot();
|
|
1114
|
+
if (this.disposed || control !== sessionControl(this.app.session)) return false;
|
|
1115
|
+
this.controlSnapshot = snapshot;
|
|
1116
|
+
this.screen.setControlModel(controlModel(snapshot));
|
|
1117
|
+
return true;
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
if (!this.disposed && control === sessionControl(this.app.session)) {
|
|
1120
|
+
this.controlSnapshot = undefined;
|
|
1121
|
+
this.screen.setControlModel(undefined);
|
|
1122
|
+
}
|
|
1123
|
+
throw error;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private acceptControlCriterion(): void {
|
|
1128
|
+
const control = sessionControl(this.app.session), goal = this.controlSnapshot?.goal;
|
|
1129
|
+
const criteria = goal?.state.acceptance?.criteria.filter(item => item.kind === "human" && item.status !== "pass" && item.status !== "accepted") ?? [];
|
|
1130
|
+
if (this.inflight || !goal || !criteria.length) { this.toast("No human criterion ready for acceptance", "warning"); return; }
|
|
1131
|
+
this.screen.dialogs.push({ kind: "list", title: "Accept a human criterion", search: false,
|
|
1132
|
+
items: criteria.map(item => ({ value: item.id, label: item.description, description: item.status })),
|
|
1133
|
+
onPick: async item => {
|
|
1134
|
+
if (control !== sessionControl(this.app.session)) throw Error("Session changed; refresh the control panel");
|
|
1135
|
+
await control.accept(goal.state.request.event, item.value);
|
|
1136
|
+
await this.refreshControl();
|
|
1137
|
+
} });
|
|
1138
|
+
}
|
|
1139
|
+
|
|
968
1140
|
// ----------------------------------------------------------------- palette
|
|
969
1141
|
private paletteInput(): PaletteInput {
|
|
970
1142
|
const current = modelKey(this.app.providerConfig.spec);
|
|
@@ -1027,42 +1199,22 @@ export class InteractiveApp implements Ui {
|
|
|
1027
1199
|
keywords: `${row.model.provider} ${row.model.providerName}`, ...(row.current ? { hint: "● current" } : {}) }));
|
|
1028
1200
|
items.push({ value: "login", label: "+ Connect a provider…", section: "More", description: "subscription, API key, gateway or local server" });
|
|
1029
1201
|
this.push({ kind: "list", title: `Model · ${rows.filter(row => row.model.ready).length} ready`, items, selected: current,
|
|
1030
|
-
actions: [{ key: "ctrl+s", label: "save default", run: item => this.saveDefaultModel(item) }],
|
|
1031
1202
|
onPick: async item => {
|
|
1032
1203
|
if (item.value === "login") { this.openLogin(); return; }
|
|
1033
1204
|
const spec = this.app.catalog.find(model => modelKey(model) === item.value);
|
|
1034
1205
|
if (spec) await this.hooks.switchToModel(this.app, spec);
|
|
1035
1206
|
} });
|
|
1036
1207
|
}
|
|
1037
|
-
private async saveDefaultModel(item: ListItem | undefined): Promise<void> {
|
|
1038
|
-
const spec = item && this.app.catalog.find(model => modelKey(model) === item.value);
|
|
1039
|
-
if (!spec || !this.app.ui) return;
|
|
1040
|
-
const patch = { defaultModel: spec.id, defaultProvider: spec.provider };
|
|
1041
|
-
await writeUserSettings(patch);
|
|
1042
|
-
this.app.ui.userOverrides = { ...this.app.ui.userOverrides, ...patch };
|
|
1043
|
-
this.app.ui.settings = { ...this.app.ui.settings, ...patch };
|
|
1044
|
-
this.toast(`saved default model ${item.value}`, "success");
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
1208
|
openThinking(): void {
|
|
1048
1209
|
const spec = this.app.providerConfig.spec, current = this.app.providerConfig.effort;
|
|
1049
1210
|
this.push({ kind: "list", title: "Thinking", search: false, selected: current,
|
|
1050
1211
|
items: THINKING_LEVELS.map(level => ({ value: level, label: level, disabled: !this.hooks.effortSupported(spec, level),
|
|
1051
1212
|
...(level === current ? { hint: "● current" } : !this.hooks.effortSupported(spec, level) ? { description: "not supported by this model" } : {}) })),
|
|
1052
|
-
actions: [{ key: "ctrl+s", label: "save default", run: async item => {
|
|
1053
|
-
const effort = item && asThinkingLevel(item.value);
|
|
1054
|
-
if (!effort || !this.hooks.effortSupported(spec, effort) || !this.app.ui) return;
|
|
1055
|
-
const patch = { modelThinkingLevels: { [modelKey(spec)]: effort } };
|
|
1056
|
-
await writeUserSettings(patch);
|
|
1057
|
-
this.app.ui.userOverrides = mergeSettings({ builtin: this.app.ui.userOverrides, user: patch });
|
|
1058
|
-
this.app.ui.settings = mergeSettings({ builtin: this.app.ui.settings, user: patch });
|
|
1059
|
-
this.toast(`saved default thinking ${effort}`, "success");
|
|
1060
|
-
} }],
|
|
1061
1213
|
onPick: async item => { const effort = asThinkingLevel(item.value) as Effort | undefined; if (effort) await this.hooks.applyThinking(this.app, effort, this); } });
|
|
1062
1214
|
}
|
|
1063
1215
|
|
|
1064
1216
|
openLogin(method?: "oauth" | "api_key"): void {
|
|
1065
|
-
const providers = [...new Set(this.app.catalog.map(model => model.provider))];
|
|
1217
|
+
const providers = [...new Set(this.app.catalog.map(model => model.provider))].filter(provider => !subscriptionById(provider));
|
|
1066
1218
|
const status = (provider: string): string => {
|
|
1067
1219
|
const spec = this.app.catalog.find(model => model.provider === provider);
|
|
1068
1220
|
const resolved = spec ? this.app.credentialOf(spec) : undefined;
|
|
@@ -1105,13 +1257,12 @@ export class InteractiveApp implements Ui {
|
|
|
1105
1257
|
const ready = this.app.catalog.filter(spec => this.app.credentialOf(spec).ok);
|
|
1106
1258
|
const configured = this.app.ui?.settings.scopedModels;
|
|
1107
1259
|
const checked = configured ? ready.filter(spec => configured.some(pattern => pattern === modelKey(spec) || pattern === spec.id)).map(modelKey) : ready.map(modelKey);
|
|
1108
|
-
this.push({ kind: "list", title: "Models to cycle with alt+m", multi: true, checked, empty: "No available models. Use /
|
|
1260
|
+
this.push({ kind: "list", title: "Models to cycle with alt+m", multi: true, checked, empty: "No available models. Use /provider to add a credential.",
|
|
1109
1261
|
items: ready.map(spec => ({ value: modelKey(spec), label: spec.id, description: spec.provider, section: SOURCE_PROVIDERS[spec.provider]?.name ?? spec.provider })),
|
|
1110
1262
|
onSubmitChecked: async values => {
|
|
1111
|
-
await
|
|
1112
|
-
if (this.app.ui) { this.app.ui.settings = { ...this.app.ui.settings, scopedModels: values }; this.app.ui.userOverrides = { ...this.app.ui.userOverrides, scopedModels: values }; }
|
|
1263
|
+
const saved = await commitUserSettings(this.app, { scopedModels: values });
|
|
1113
1264
|
this.app.args.models = ""; this.app.args.modelsExplicit = false;
|
|
1114
|
-
this.toast(`saved ${values.length} scoped models`, "success");
|
|
1265
|
+
if (saved) this.toast(`saved ${values.length} scoped models`, "success");
|
|
1115
1266
|
} });
|
|
1116
1267
|
}
|
|
1117
1268
|
|
|
@@ -1121,7 +1272,7 @@ export class InteractiveApp implements Ui {
|
|
|
1121
1272
|
const entries: [string, string, string][] = [
|
|
1122
1273
|
["theme", "Theme", settings.theme ?? "dark"],
|
|
1123
1274
|
["thinking", "Thinking", this.app.providerConfig.effort],
|
|
1124
|
-
["defaultModel", "
|
|
1275
|
+
["defaultModel", "Model", modelKey(this.app.providerConfig.spec)],
|
|
1125
1276
|
["modelThinkingLevels", "Model thinking defaults", `${Object.keys(settings.modelThinkingLevels ?? {}).length} configured`],
|
|
1126
1277
|
["steeringMode", "Steering delivery", settings.steeringMode ?? "one-at-a-time"],
|
|
1127
1278
|
["followUpMode", "Follow-up delivery", settings.followUpMode ?? "one-at-a-time"],
|
|
@@ -1169,9 +1320,7 @@ export class InteractiveApp implements Ui {
|
|
|
1169
1320
|
ui.userOverrides = { ...ui.userOverrides, modelThinkingLevels: loaded.userOverrides.modelThinkingLevels ?? {} };
|
|
1170
1321
|
} else {
|
|
1171
1322
|
const patch = { modelThinkingLevels: { [item.value]: choice.value as Effort } };
|
|
1172
|
-
await
|
|
1173
|
-
ui.settings = mergeSettings({ builtin: ui.settings, user: patch });
|
|
1174
|
-
ui.userOverrides = mergeSettings({ builtin: ui.userOverrides, user: patch });
|
|
1323
|
+
await commitUserSettings(this.app, patch);
|
|
1175
1324
|
}
|
|
1176
1325
|
this.screen.dialogs.pop(); this.openModelThinkingDefaults();
|
|
1177
1326
|
} });
|
|
@@ -1318,10 +1467,15 @@ function noticeOf(id: string, after: number, lines: string[], tone: Tone): Notic
|
|
|
1318
1467
|
export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: () => void, renderer?: CliRenderer): Promise<void> {
|
|
1319
1468
|
const fullscreen = (app.ui?.settings.tuiMode ?? "fullscreen") !== "regular";
|
|
1320
1469
|
releaseEarlyTty?.();
|
|
1470
|
+
// Capture the real writer before OpenTUI owns stdout, so OSC never becomes transcript text.
|
|
1471
|
+
const terminalWrite = process.stdout.write.bind(process.stdout);
|
|
1321
1472
|
const r = renderer ?? await createCliRenderer({ screenMode: fullscreen ? "alternate-screen" : "main-screen", exitOnCtrlC: false, exitSignals: [],
|
|
1322
1473
|
// Performance (refactor-tui-dock-layout DoD 9): no console capture, no stats, no hover tracking; bursts coalesce at 60 fps.
|
|
1323
1474
|
consoleMode: "disabled", openConsoleOnError: false, useMouse: true, enableMouseMovement: false, gatherStats: false, memorySnapshotInterval: 0, targetFps: 60, maxFps: 60 });
|
|
1324
|
-
const
|
|
1475
|
+
const program = new ProgramStatusReporter({ write: text => { terminalWrite(text); }, id: `xeno-${process.pid}`,
|
|
1476
|
+
tty: !renderer && process.stdout.isTTY === true, mode: process.env["XENO_PROGRAM_STATUS"] ?? "auto",
|
|
1477
|
+
multiplexed: Boolean(process.env["TMUX"] || process.env["STY"]) });
|
|
1478
|
+
const ui = new InteractiveApp(app, hooks, r, program);
|
|
1325
1479
|
const previousSay = app.io.say;
|
|
1326
1480
|
app.io.say = line => ui.notice(line);
|
|
1327
1481
|
const stop = (code: number) => { process.exitCode = code; ui.requestShutdown(); };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ControlSnapshot } from "../../runtime/session-control.ts";
|
|
2
|
+
import type { ControlPanelModel } from "../../tui/ui/control-panel.ts";
|
|
3
|
+
|
|
4
|
+
export function controlModel(snapshot: ControlSnapshot): ControlPanelModel {
|
|
5
|
+
const goal = snapshot.goal, state = goal?.state, context = snapshot.context;
|
|
6
|
+
const progress = (label: string, sources: readonly { node: number; event: string }[]) => ({ label, count: sources.length, sources: sources.map(source => `${source.node}/${source.event}`) });
|
|
7
|
+
return { thread: snapshot.threadId, turn: snapshot.activeTurnId ?? state?.request.event ?? null,
|
|
8
|
+
goal: goal?.text ?? "Send a request to establish a goal.", status: state?.status ?? "idle",
|
|
9
|
+
execution: snapshot.waiting ? `waiting for ${snapshot.waiting}` : snapshot.activeTurnId ? "running" : state?.execution?.reason ?? "idle",
|
|
10
|
+
gaps: goal?.gaps ?? [], checks: state?.commands.map(command => `${command.status} · exit ${command.exit} · ${command.command} · ${command.source.node}/${command.source.event}`) ?? [],
|
|
11
|
+
progress: state ? [
|
|
12
|
+
progress("New observations", state.progress.observations),
|
|
13
|
+
progress("Artifact changes", state.progress.artifact_changes),
|
|
14
|
+
progress("Acceptance advances", state.progress.acceptance_advances),
|
|
15
|
+
progress("New diagnostics", state.progress.new_diagnostics),
|
|
16
|
+
progress("Unchanged retries", state.progress.unchanged_retries),
|
|
17
|
+
progress("Unknown diagnostics", state.progress.unknown_diagnostics),
|
|
18
|
+
] : [],
|
|
19
|
+
context: [`${context.used} tokens (${context.countKind}); ${context.messages.length} messages; ${context.policy}`,
|
|
20
|
+
...Object.entries(context.components).map(([name, count]) => `${name}: ~${count}`),
|
|
21
|
+
...context.messages.map(message => `#${message.index} ${message.role}: ${message.sources.join(", ")}`)],
|
|
22
|
+
omitted: context.omitted.map(event => `${event.id}: ${event.type} (original stays in log)`),
|
|
23
|
+
history: context.sections.filter(section => section.total > 0).map(section => `${section.section}: ${section.retained}/${section.total}` +
|
|
24
|
+
(section.recovery ? ` · history(${JSON.stringify(section.recovery)})` : "")),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/apps/ui/surface.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface TurnSurface {
|
|
|
19
19
|
syncTranscript(): Promise<void>;
|
|
20
20
|
pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
|
|
21
21
|
pullQueued?(phase: Phase): Promise<UserInput | null>;
|
|
22
|
+
observeEvent?(event: LoopEvent): void;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface Ui extends TurnSurface {
|
|
@@ -52,6 +53,7 @@ export interface Ui extends TurnSurface {
|
|
|
52
53
|
openFork(): Promise<void>;
|
|
53
54
|
openTree(): Promise<void>;
|
|
54
55
|
openResources(): Promise<void>;
|
|
56
|
+
openControl(tab?: "goal" | "progress" | "context"): Promise<void>;
|
|
55
57
|
openTrust(): Promise<void>;
|
|
56
58
|
openAttachments(): void;
|
|
57
59
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { assertNever } from "./exhaustive.ts";
|
|
2
|
+
|
|
3
|
+
/** D19: confirmation follows the selected completion, never the unfinished draft. */
|
|
4
|
+
export function completionAction(kind: "command" | "argument" | "path", key: "enter" | "tab", requiresArgument: boolean): "insert" | "submit" {
|
|
5
|
+
if (key === "tab") return "insert";
|
|
6
|
+
switch (kind) {
|
|
7
|
+
case "command": return requiresArgument ? "insert" : "submit";
|
|
8
|
+
case "argument": return "submit";
|
|
9
|
+
case "path": return "insert";
|
|
10
|
+
default: return assertNever(kind, "completion kind");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Match the parser's exact identity before discoverable prefixes and substrings. */
|
|
15
|
+
export function commandCompletionRank(name: string, query: string): number {
|
|
16
|
+
const canonical = name.toLowerCase(), typed = query.toLowerCase();
|
|
17
|
+
if (canonical === typed) return 0;
|
|
18
|
+
if (canonical.startsWith(typed)) return 1;
|
|
19
|
+
return typed.length > 1 && canonical.includes(typed) ? 2 : Number.POSITIVE_INFINITY;
|
|
20
|
+
}
|