@visual-z/xeno 0.5.0 → 0.7.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 (102) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -4
  3. package/docs/app-server.md +32 -3
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  7. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  8. package/docs/research/foundation-network-2026-10-09.md +37 -0
  9. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  10. package/package.json +10 -2
  11. package/src/apps/acp-server.ts +41 -0
  12. package/src/apps/agent-profiles.ts +19 -0
  13. package/src/apps/app-server.ts +4 -2
  14. package/src/apps/cli.ts +18 -2
  15. package/src/apps/commands.ts +20 -0
  16. package/src/apps/control-transport.ts +3 -2
  17. package/src/apps/model-catalog.ts +57 -31
  18. package/src/apps/question-ui.ts +27 -0
  19. package/src/apps/resources.ts +63 -25
  20. package/src/apps/review.ts +109 -0
  21. package/src/apps/session-hub.ts +95 -0
  22. package/src/apps/shell.ts +55 -1
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +89 -38
  25. package/src/apps/ui/image-cache.ts +71 -28
  26. package/src/apps/ui/surface.ts +1 -0
  27. package/src/apps/update-check.ts +71 -0
  28. package/src/apps/workflow.ts +58 -0
  29. package/src/core/activity-status.ts +28 -0
  30. package/src/core/agent-profile.ts +130 -0
  31. package/src/core/control-protocol.ts +8 -1
  32. package/src/core/file-precondition.ts +6 -0
  33. package/src/core/git-status.ts +29 -0
  34. package/src/core/image-limits.ts +72 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/patch.ts +130 -0
  38. package/src/core/permissions.ts +143 -4
  39. package/src/core/progress.ts +9 -0
  40. package/src/core/question.ts +94 -0
  41. package/src/core/request-budget.ts +13 -0
  42. package/src/core/review.ts +68 -0
  43. package/src/core/session-exit.ts +26 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/stall.ts +18 -6
  46. package/src/core/stop-report.ts +25 -0
  47. package/src/core/update-check.ts +24 -5
  48. package/src/core/web-content.ts +80 -0
  49. package/src/core/workflow-mode.ts +34 -0
  50. package/src/runtime/acp-agent.ts +289 -0
  51. package/src/runtime/agent-profiles.ts +21 -0
  52. package/src/runtime/catalog.ts +2 -2
  53. package/src/runtime/control-client.ts +149 -0
  54. package/src/runtime/control-connection.ts +39 -4
  55. package/src/runtime/exec-sessions.ts +40 -4
  56. package/src/runtime/extensions.ts +20 -2
  57. package/src/runtime/formatter-tool.ts +38 -0
  58. package/src/runtime/formatter.ts +50 -0
  59. package/src/runtime/foundation.ts +78 -0
  60. package/src/runtime/images.ts +6 -2
  61. package/src/runtime/integrations.ts +8 -2
  62. package/src/runtime/loop.ts +124 -21
  63. package/src/runtime/lsp-client.ts +201 -0
  64. package/src/runtime/mcp-client.ts +264 -0
  65. package/src/runtime/mcp-oauth.ts +168 -0
  66. package/src/runtime/mcp-schema.ts +51 -0
  67. package/src/runtime/oauth.ts +1 -1
  68. package/src/runtime/openai-responses.ts +6 -4
  69. package/src/runtime/openai.ts +6 -1
  70. package/src/runtime/output-schema.ts +26 -0
  71. package/src/runtime/patch-tool.ts +38 -0
  72. package/src/runtime/permission-resources.ts +11 -0
  73. package/src/runtime/pi-provider.ts +5 -4
  74. package/src/runtime/provider-tool-policy.ts +33 -0
  75. package/src/runtime/provider.ts +8 -1
  76. package/src/runtime/question-tool.ts +62 -0
  77. package/src/runtime/review-tool.ts +48 -0
  78. package/src/runtime/session-control.ts +61 -2
  79. package/src/runtime/session-hub.ts +85 -0
  80. package/src/runtime/tool-discovery.ts +4 -3
  81. package/src/runtime/tools.ts +44 -10
  82. package/src/runtime/update-check.ts +3 -2
  83. package/src/runtime/web-http.ts +56 -0
  84. package/src/runtime/web-tools.ts +88 -0
  85. package/src/runtime/workspace-lifecycle.ts +14 -1
  86. package/src/store/agent-profile-files.ts +13 -0
  87. package/src/store/blobs.ts +2 -1
  88. package/src/store/context-files.ts +77 -1
  89. package/src/store/file-checkpoints.ts +5 -4
  90. package/src/store/foundation-config.ts +24 -0
  91. package/src/store/git-status.ts +15 -0
  92. package/src/store/lsp-document.ts +30 -0
  93. package/src/store/mcp-credentials.ts +85 -0
  94. package/src/store/patch.ts +92 -0
  95. package/src/store/permission-paths.ts +34 -0
  96. package/src/store/review.ts +109 -0
  97. package/src/store/search.ts +48 -9
  98. package/src/tui/ui/layout.ts +7 -7
  99. package/src/tui/ui/messages.ts +1 -1
  100. package/src/tui/ui/pictures.ts +26 -9
  101. package/src/tui/ui/screen.ts +12 -8
  102. package/src/tui/ui/status.ts +32 -6
@@ -31,7 +31,7 @@ import { screenLayout, sidebarContentWidth, type ScreenLayout } from "./layout.t
31
31
  import { greetingLines, mascotFrame, MASCOT_FRAME_MS, MASCOT_FRAMES, type Greeting } from "./mascot.ts";
32
32
  import { MessageList, type MessageFeatures } from "./messages.ts";
33
33
  import { Motion } from "./motion.ts";
34
- import { contextLabel, sidebarLines, topbarRow, type Line, type StatusModel } from "./status.ts";
34
+ import { contextLabel, modeLabels, sidebarLines, topbarRow, type Line, type StatusModel } from "./status.ts";
35
35
  import { DEFAULT_CELL, stripImageRows } from "./image-size.ts";
36
36
  import { Pictures, type PictureSource } from "./pictures.ts";
37
37
  import type { ImageView, ViewItem } from "./items.ts";
@@ -112,7 +112,7 @@ export class Screen {
112
112
  private queue: readonly string[] = [];
113
113
  private attachmentImages: readonly ImageView[] = [];
114
114
  private attachmentsKey = "";
115
- private picturesSource: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => null };
115
+ private picturesSource: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => ({ kind: "error", reason: "closed" }) };
116
116
  private readonly attachments: BoxRenderable;
117
117
  private widgets: readonly string[] = [];
118
118
  private unread = 0;
@@ -317,7 +317,7 @@ export class Screen {
317
317
 
318
318
  private paintStatus(): void {
319
319
  const model = this.status;
320
- if (model && this.topbar.visible) this.topbar.setRows([topbarRow(model, Math.max(1, this.renderer.width - 2))]);
320
+ if (model && this.topbar.visible) this.topbar.setRows([topbarRow(this.shape.tier === "sidebar" ? { ...model, title: null } : model, Math.max(1, this.renderer.width - 2))]);
321
321
  if (this.controlShown) {
322
322
  this.sidebar.borderColor = this.controlFocused ? this.colors.accent : this.colors.line;
323
323
  this.sidebarRows.setRows(this.controlView.rows(this.sidebarRows.width, this.sidebarRows.height, this.controlFocused));
@@ -327,7 +327,7 @@ export class Screen {
327
327
  if (!model) { this.sidebarRows.setRows([]); return; }
328
328
  if (this.sidebar.visible) {
329
329
  const width = Math.max(1, this.shape.sidebarWidth - 3);
330
- this.sidebarRows.setRows(sidebarLines(model, width, Math.max(0, this.renderer.height - 1)));
330
+ this.sidebarRows.setRows(sidebarLines(model, width, Math.max(0, this.renderer.height - (this.shape.topbar ? 1 : 0) - 1)));
331
331
  }
332
332
  }
333
333
 
@@ -361,6 +361,7 @@ export class Screen {
361
361
  let left: Label[] = [];
362
362
  if (dialog) {
363
363
  left = [{ segs: [{ text: this.dialogs.title, role: "text", bold: true }], priority: 0, min: 8 }];
364
+ if (this.status?.activity?.state === "blocked") right.push({ segs: [{ text: this.status.activity.label, role: "warning" }], priority: 1, min: 8 });
364
365
  if (this.dialogs.edgeHint) right.push({ segs: [{ text: this.dialogs.edgeHint, role: "muted" }], priority: 3, min: 10 });
365
366
  } else if (this.controlShown && this.renderer.height <= 3) {
366
367
  // No transcript rows remain for a drawer. Keep its state and explain how
@@ -368,19 +369,22 @@ export class Screen {
368
369
  left = [{ segs: [{ text: "Resize for control", role: "accent" }], priority: 0, min: 1 }];
369
370
  } else if (d.busy) {
370
371
  const seconds = Math.max(0, Math.floor((Date.now() - this.busySince) / 1000));
371
- left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${d.activity}`, role: "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
372
+ const activity = this.status?.activity;
373
+ const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
374
+ left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
372
375
  right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
373
376
  } else {
374
377
  // A draft taller than the box says what scrolled out of view (fix-tui-composer-growth DoD 4).
375
378
  const { above, below } = this.composer.overflow;
376
379
  if (above) left = [{ segs: [{ text: `↑ ${above} more`, role: "muted" }], priority: 1 }];
377
380
  if (below && !this.toastText) right.push({ segs: [{ text: `↓ ${below} more`, role: "muted" }], priority: 2 });
381
+ if (!above && this.status?.activity) left = [{ segs: [{ text: this.status.activity.label, role: this.status.activity.state === "error" ? "error" : this.status.activity.state === "blocked" ? "warning" : "muted" }], priority: 1, min: 4 }];
378
382
  }
379
383
  this.topEdge.setRows([edgeLine({ width, corners: ["╭", "╮"], role, left, right })]);
380
- const bottomLeft: Label[] = d.model ? [
381
- { segs: [{ text: d.model, role: "text", bold: true }], priority: 0, min: 8 },
384
+ const bottomLeft: Label[] = [...(this.status ? modeLabels(this.status) : []), ...(d.model ? [
385
+ { segs: [{ text: d.model, role: "text" as const, bold: true }], priority: 1, min: 8 },
382
386
  ...(d.thinking ? [{ segs: [{ text: d.thinking, role: "muted" as const }], priority: 2 }] : []),
383
- ] : [];
387
+ ] : [])];
384
388
  const context = this.shape.tier !== "sidebar" && this.status ? contextLabel(this.status) : undefined;
385
389
  this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: context ? [context] : [] })]);
386
390
  }
@@ -34,6 +34,10 @@ export interface StatusModel {
34
34
  readonly workspace: string;
35
35
  readonly workspaceShort: string;
36
36
  readonly branch: string | null;
37
+ readonly mode?: { readonly yolo: boolean; readonly workflow: "build" | "plan" };
38
+ readonly provider?: { readonly name: string; readonly api: string; readonly host: string | null; readonly role: string | null; readonly ready: boolean };
39
+ readonly activity?: { readonly state: string; readonly label: string; readonly detail: string; readonly queued: number };
40
+ readonly git?: { readonly staged: number; readonly changed: number; readonly untracked: number; readonly conflicted: number; readonly ahead: number; readonly behind: number } | null;
37
41
  readonly context?: ContextStatus;
38
42
  readonly usage?: { readonly input: number; readonly output: number; readonly cacheRate: number; readonly cost: string | null };
39
43
  readonly tools: { readonly running: number; readonly done: number; readonly failed: number };
@@ -71,10 +75,18 @@ function shareSeg(context: ContextStatus): Seg | undefined {
71
75
  return { text: `${context.estimated ? "~" : ""}${Math.round(share * 100)}%`, role: share >= CONTEXT_WARNING ? "warning" : "text" };
72
76
  }
73
77
 
74
- /** The input box's bottom-right label where there is no sidebar: `ctx ~12%`. */
78
+ /** The input box's bottom-right label names the measured budget: `seg ~12%` or `model ~12%`. */
75
79
  export function contextLabel(model: StatusModel): Label | undefined {
76
80
  const share = model.context && shareSeg(model.context);
77
- return share ? { segs: [{ text: "ctx ", role: "muted" }, share], priority: 1 } : undefined;
81
+ return share ? { segs: [{ text: model.context?.trigger === null ? "model " : "seg ", role: "muted" }, share], priority: 3 } : undefined;
82
+ }
83
+
84
+ export function modeLabels(model: StatusModel): Label[] {
85
+ if (!model.mode) return [];
86
+ return [
87
+ { segs: [{ text: model.mode.yolo ? "YOLO" : "Approval", role: model.mode.yolo ? "warning" : "muted", bold: model.mode.yolo }], priority: 0 },
88
+ { segs: [{ text: model.mode.workflow === "plan" ? "Plan" : "Build", role: "accent" }], priority: 0 },
89
+ ];
78
90
  }
79
91
 
80
92
  export function terminalTitle(model: StatusModel): string {
@@ -91,6 +103,16 @@ function firstLine(text: string): string { return text.split("\n").find(line =>
91
103
  * row is narrow: location goes before counts, as OpenCode #570 asked.
92
104
  */
93
105
  export function topbarRow(model: StatusModel, width: number): Seg[] {
106
+ if (model.provider) {
107
+ const provider = model.provider;
108
+ return splitLine(width, [
109
+ { segs: [{ text: "xeno", role: "text", bold: true }], priority: 0 },
110
+ { segs: [{ text: `${provider.ready ? "" : "! "}${provider.name}`, role: provider.ready ? "text" : "warning" }], priority: 0, min: 6 },
111
+ { segs: [{ text: provider.api, role: "muted" }], priority: 2, min: 8 },
112
+ ...(provider.host ? [{ segs: [{ text: provider.host, role: "muted" as const }], priority: 3, min: 8 }] : []),
113
+ ...(provider.role ? [{ segs: [{ text: `agent ${provider.role}`, role: "accent" as const }], priority: 1, min: 8 }] : []),
114
+ ], model.title ? [{ segs: [{ text: firstLine(model.title), role: "muted" }], priority: 4, min: 12 }] : []);
115
+ }
94
116
  const left: Label[] = [
95
117
  { segs: [{ text: "xeno", role: "text", bold: true }], priority: 0 },
96
118
  // Never dropped, but gives up width after cost and background counts, before the handoff counts go.
@@ -175,6 +197,9 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
175
197
  ...title.map(text => [{ text, role: model.title ? "text" : "muted", bold: Boolean(model.title) }] as Line),
176
198
  [{ text: clipText(model.workspace, w), role: "muted" }],
177
199
  ...(model.branch ? [[{ text: "⎇ ", role: "muted" }, { text: model.branch, role: "muted" }] as Line] : []),
200
+ ...(model.git ? [[{ text: `${model.git.staged} staged · ${model.git.changed} changed · ${model.git.untracked} new`, role: "muted" }] as Line,
201
+ ...(model.git.conflicted ? [[{ text: `${model.git.conflicted} conflicts`, role: "warning" }] as Line] : []),
202
+ ...(model.git.ahead || model.git.behind ? [[{ text: `↑${model.git.ahead} ↓${model.git.behind}`, role: "muted" }] as Line] : [])] : model.git === null ? [[{ text: "Git unavailable", role: "muted" }] as Line] : []),
178
203
  ] });
179
204
  const context = model.context;
180
205
  if (model.goal) blocks.push({ list: false, items: [], head: [
@@ -190,10 +215,11 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
190
215
  const tone = share !== undefined && share >= CONTEXT_WARNING ? "warning" : "accent";
191
216
  const shareText = shareSeg(context);
192
217
  blocks.push({ list: false, items: [], head: [
193
- row([{ text: "Context", role: "text", bold: true }], shareText ? [shareText] : []),
218
+ row([{ text: "Context", role: "text", bold: true }], shareText ? [{ text: context.trigger === null ? "model " : "seg ", role: "muted" }, shareText] : []),
194
219
  ...(share === undefined ? [] : [[{ text: "━".repeat(filled), role: tone }, { text: "─".repeat(Math.max(0, w - filled)), role: "line" }] as Line]),
195
- [{ text: `${est}${compact(context.used)}${context.window ? ` / ${compact(context.window)}` : ""}`, role: "text" }, { text: ` · node ${context.segment}`, role: "muted" }],
196
- ...(context.trigger !== null ? [[{ text: `cut at ${est}${compact(context.trigger)}`, role: "muted" }] as Line] : []),
220
+ [{ text: `Segment ${est}${compact(context.used)}${context.trigger !== null ? ` / ${est}${compact(context.trigger)}` : " · cut disabled"}`, role: "text" }],
221
+ [{ text: context.window ? `Model ${est}${compact(context.used)} / ${compact(context.window)} · ${est}${Math.round(context.used / context.window * 100)}%` : "Model window unknown", role: "muted" }],
222
+ [{ text: `node ${context.segment}`, role: "muted" }],
197
223
  ] });
198
224
  }
199
225
  const usage = model.usage, tools = model.tools;
@@ -222,7 +248,7 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
222
248
  blocks.push({ list: true, head: [row([{ text: "Tasks", role: "text", bold: true }], [{ text: `${tasks.length} open`, role: "muted" }])],
223
249
  items: tasks.map(task => [{ text: task.status === "blocked" ? "! " : "◐ ", role: task.status === "blocked" ? "warning" : "accent" }, { text: task.what, role: "text" }] as Line) });
224
250
  const files = model.files ?? [];
225
- blocks.push({ list: true, head: [row([{ text: "Files", role: "text", bold: true }], [{ text: String(files.length), role: "muted" }])],
251
+ blocks.push({ list: true, head: [row([{ text: "Agent files", role: "text", bold: true }], [{ text: String(files.length), role: "muted" }])],
226
252
  items: files.map(file => fileLine(file, w)) });
227
253
  const background = model.background ?? [];
228
254
  blocks.push({ list: true, head: [row([{ text: "Background", role: "text", bold: true }], [{ text: String(background.length), role: "muted" }])],