@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +55 -16
  3. package/docs/app-server.md +86 -0
  4. package/docs/install.md +1 -1
  5. package/docs/providers.md +20 -6
  6. package/package.json +3 -2
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/cli.ts +25 -10
  9. package/src/apps/commands.ts +10 -6
  10. package/src/apps/completions.ts +15 -13
  11. package/src/apps/control-transport.ts +89 -0
  12. package/src/apps/model-catalog.ts +2 -1
  13. package/src/apps/preferences.ts +20 -0
  14. package/src/apps/provider-connect.ts +4 -4
  15. package/src/apps/shell.ts +73 -50
  16. package/src/apps/startup-input.ts +7 -2
  17. package/src/apps/startup.ts +52 -19
  18. package/src/apps/ui/app.ts +210 -56
  19. package/src/apps/ui/control-model.ts +26 -0
  20. package/src/apps/ui/surface.ts +2 -0
  21. package/src/core/completion-action.ts +20 -0
  22. package/src/core/completion-review.ts +38 -29
  23. package/src/core/control-protocol.ts +62 -0
  24. package/src/core/control-view.ts +42 -0
  25. package/src/core/event-validation.ts +1 -1
  26. package/src/core/events.ts +2 -0
  27. package/src/core/harness-diagnostics.ts +206 -0
  28. package/src/core/model-preferences.ts +28 -0
  29. package/src/core/oauth-fields.ts +44 -0
  30. package/src/core/permissions.ts +13 -7
  31. package/src/core/program-status.ts +31 -0
  32. package/src/core/project.ts +25 -1
  33. package/src/core/request-progress.ts +87 -0
  34. package/src/core/request-state.ts +114 -0
  35. package/src/core/settings.ts +8 -1
  36. package/src/core/tool-input.ts +6 -0
  37. package/src/runtime/auth.ts +10 -3
  38. package/src/runtime/catalog.ts +21 -4
  39. package/src/runtime/control-connection.ts +104 -0
  40. package/src/runtime/harness-eval.ts +3 -1
  41. package/src/runtime/loop.ts +72 -19
  42. package/src/runtime/model.ts +9 -0
  43. package/src/runtime/native-eval.ts +6 -3
  44. package/src/runtime/oauth.ts +80 -3
  45. package/src/runtime/pi-provider.ts +169 -12
  46. package/src/runtime/program-status.ts +38 -0
  47. package/src/runtime/provider-connect.ts +2 -2
  48. package/src/runtime/provider-presets.ts +1 -1
  49. package/src/runtime/provider-request.ts +13 -2
  50. package/src/runtime/provider.ts +23 -9
  51. package/src/runtime/session-control.ts +198 -0
  52. package/src/runtime/tools.ts +11 -9
  53. package/src/store/resources.ts +13 -22
  54. package/src/store/user-settings.ts +30 -0
  55. package/src/tui/ui/composer.ts +3 -2
  56. package/src/tui/ui/control-panel.ts +25 -0
  57. package/src/tui/ui/control-sidebar.ts +49 -0
  58. package/src/tui/ui/mascot.ts +1 -1
  59. package/src/tui/ui/screen.ts +107 -11
  60. package/src/tui/ui/status.ts +8 -0
@@ -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, mergeSettings, settingValue, settingValues } from "../../core/settings.ts";
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, writeUserSettings, loadUiResources } from "../../store/resources.ts";
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 /login custom endpoints. */
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 => resolve(value), onCancel: () => resolve(undefined) }));
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
- return new Promise(resolve => this.screen.dialogs.push({ kind: "confirm", title, message, onAnswer: yes => resolve(yes) }));
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 { this.abort?.abort(); this.returnQueueToEditor(); }
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 · /login"].filter(Boolean).join(" · ");
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
- try { this.derived = handoffStatus(await this.app.session.previewHandoff()); this.derivedKey = target; }
575
- catch { /* preview needs a readable log; the status keeps its last state */ }
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") { composer.acceptCompletion(); this.updateCompletion(); this.paint(); return true; }
611
- if (key.chord === "enter" && /^\/\S*$/.test(composer.text)) {
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(item.value.trim());
615
- if (definition && "argumentHint" in definition && definition.argumentHint?.startsWith("<")) { this.updateCompletion(); return true; }
616
- this.track(() => this.submit(item.value, "steering")); return true;
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 answered = (choice: ApprovalChoice) => { this.screen.messages.setWaiting(false); return choice; };
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
- this.screen.dialogs.push({ kind: "list", title: "Tool approval", search: false, tone: "warning", intro: approvalHeading(request).split("\n"),
964
- items: approvalItems(request), onPick: item => resolve(answered(choiceFromValue(item.value))), onCancel: () => resolve(answered("deny")) });
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 /login to add a credential.",
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 writeUserSettings({ scopedModels: values });
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", "Default model", settings.defaultModel ? `${settings.defaultProvider}/${settings.defaultModel}` : "automatic"],
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 writeUserSettings(patch);
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 ui = new InteractiveApp(app, hooks, r);
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
+ }
@@ -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
+ }