@visual-z/xeno 0.6.0 → 0.7.1

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 (40) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +13 -2
  3. package/docs/providers.md +1 -1
  4. package/package.json +1 -1
  5. package/src/apps/provider-connect.ts +3 -3
  6. package/src/apps/session-hub.ts +12 -1
  7. package/src/apps/shell.ts +1 -1
  8. package/src/apps/ui/app.ts +81 -19
  9. package/src/apps/ui/image-cache.ts +71 -28
  10. package/src/apps/update-check.ts +21 -10
  11. package/src/core/activity-status.ts +28 -0
  12. package/src/core/git-status.ts +29 -0
  13. package/src/core/image-limits.ts +72 -0
  14. package/src/core/progress.ts +9 -0
  15. package/src/core/request-budget.ts +13 -0
  16. package/src/core/session-exit.ts +26 -0
  17. package/src/core/settings.ts +1 -1
  18. package/src/core/stall.ts +18 -6
  19. package/src/core/stop-report.ts +25 -0
  20. package/src/runtime/exec-sessions.ts +40 -4
  21. package/src/runtime/images.ts +6 -2
  22. package/src/runtime/loop.ts +56 -11
  23. package/src/runtime/oauth.ts +19 -1
  24. package/src/runtime/openai-responses.ts +6 -4
  25. package/src/runtime/openai.ts +6 -1
  26. package/src/runtime/pi-provider.ts +11 -5
  27. package/src/runtime/provider-presets.ts +11 -0
  28. package/src/runtime/provider-request.ts +3 -1
  29. package/src/runtime/provider-tool-policy.ts +33 -0
  30. package/src/runtime/provider.ts +6 -1
  31. package/src/runtime/session-control.ts +1 -0
  32. package/src/runtime/tools.ts +7 -3
  33. package/src/store/blobs.ts +2 -1
  34. package/src/store/git-status.ts +15 -0
  35. package/src/tui/ui/dialog.ts +24 -5
  36. package/src/tui/ui/layout.ts +7 -7
  37. package/src/tui/ui/messages.ts +1 -1
  38. package/src/tui/ui/pictures.ts +26 -9
  39. package/src/tui/ui/screen.ts +30 -9
  40. 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";
@@ -96,6 +96,7 @@ export class Screen {
96
96
  private sidebarWanted = true;
97
97
  private status: StatusModel | undefined;
98
98
  private dockState: DockState = { model: "", thinking: "", busy: false, activity: "" };
99
+ private transientActivity: { readonly label: string; readonly startedAt: number; readonly timeoutMs: number } | undefined;
99
100
  private home: Greeting | undefined;
100
101
  private greetingPlayed = false;
101
102
  private greetingFrame = MASCOT_FRAMES;
@@ -112,7 +113,7 @@ export class Screen {
112
113
  private queue: readonly string[] = [];
113
114
  private attachmentImages: readonly ImageView[] = [];
114
115
  private attachmentsKey = "";
115
- private picturesSource: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => null };
116
+ private picturesSource: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => ({ kind: "error", reason: "closed" }) };
116
117
  private readonly attachments: BoxRenderable;
117
118
  private widgets: readonly string[] = [];
118
119
  private unread = 0;
@@ -317,7 +318,7 @@ export class Screen {
317
318
 
318
319
  private paintStatus(): void {
319
320
  const model = this.status;
320
- if (model && this.topbar.visible) this.topbar.setRows([topbarRow(model, Math.max(1, this.renderer.width - 2))]);
321
+ 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
322
  if (this.controlShown) {
322
323
  this.sidebar.borderColor = this.controlFocused ? this.colors.accent : this.colors.line;
323
324
  this.sidebarRows.setRows(this.controlView.rows(this.sidebarRows.width, this.sidebarRows.height, this.controlFocused));
@@ -327,7 +328,7 @@ export class Screen {
327
328
  if (!model) { this.sidebarRows.setRows([]); return; }
328
329
  if (this.sidebar.visible) {
329
330
  const width = Math.max(1, this.shape.sidebarWidth - 3);
330
- this.sidebarRows.setRows(sidebarLines(model, width, Math.max(0, this.renderer.height - 1)));
331
+ this.sidebarRows.setRows(sidebarLines(model, width, Math.max(0, this.renderer.height - (this.shape.topbar ? 1 : 0) - 1)));
331
332
  }
332
333
  }
333
334
 
@@ -348,6 +349,12 @@ export class Screen {
348
349
  this.paintEdges();
349
350
  }
350
351
 
352
+ setTransientActivity(activity: { readonly label: string; readonly timeoutMs: number } | undefined): void {
353
+ this.transientActivity = activity ? { ...activity, startedAt: Date.now() } : undefined;
354
+ this.syncActivity();
355
+ this.paintEdges();
356
+ }
357
+
351
358
  private paintEdges(): void {
352
359
  const width = Math.max(2, this.renderer.width - (this.shape.tier === "sidebar" ? this.shape.sidebarWidth : 0) - 2);
353
360
  const d = this.dockState, dialog = this.dialogs.open;
@@ -361,6 +368,7 @@ export class Screen {
361
368
  let left: Label[] = [];
362
369
  if (dialog) {
363
370
  left = [{ segs: [{ text: this.dialogs.title, role: "text", bold: true }], priority: 0, min: 8 }];
371
+ if (this.status?.activity?.state === "blocked") right.push({ segs: [{ text: this.status.activity.label, role: "warning" }], priority: 1, min: 8 });
364
372
  if (this.dialogs.edgeHint) right.push({ segs: [{ text: this.dialogs.edgeHint, role: "muted" }], priority: 3, min: 10 });
365
373
  } else if (this.controlShown && this.renderer.height <= 3) {
366
374
  // No transcript rows remain for a drawer. Keep its state and explain how
@@ -368,19 +376,25 @@ export class Screen {
368
376
  left = [{ segs: [{ text: "Resize for control", role: "accent" }], priority: 0, min: 1 }];
369
377
  } else if (d.busy) {
370
378
  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 }];
379
+ const activity = this.status?.activity;
380
+ const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
381
+ const transient = this.transientActivity ? ` · ${this.transientActivityText()}` : "";
382
+ left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
372
383
  right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
384
+ } else if (this.transientActivity) {
385
+ left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${this.transientActivityText()}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
373
386
  } else {
374
387
  // A draft taller than the box says what scrolled out of view (fix-tui-composer-growth DoD 4).
375
388
  const { above, below } = this.composer.overflow;
376
389
  if (above) left = [{ segs: [{ text: `↑ ${above} more`, role: "muted" }], priority: 1 }];
377
390
  if (below && !this.toastText) right.push({ segs: [{ text: `↓ ${below} more`, role: "muted" }], priority: 2 });
391
+ 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
392
  }
379
393
  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 },
394
+ const bottomLeft: Label[] = [...(this.status ? modeLabels(this.status) : []), ...(d.model ? [
395
+ { segs: [{ text: d.model, role: "text" as const, bold: true }], priority: 1, min: 8 },
382
396
  ...(d.thinking ? [{ segs: [{ text: d.thinking, role: "muted" as const }], priority: 2 }] : []),
383
- ] : [];
397
+ ] : [])];
384
398
  const context = this.shape.tier !== "sidebar" && this.status ? contextLabel(this.status) : undefined;
385
399
  this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: context ? [context] : [] })]);
386
400
  }
@@ -393,7 +407,7 @@ export class Screen {
393
407
 
394
408
  /** The clock runs only while something on screen shows the activity glyph. */
395
409
  private syncActivity(): void {
396
- const wanted = this.animations && (this.dockState.busy || this.dialogs.animated);
410
+ const wanted = this.animations && (this.dockState.busy || this.transientActivity !== undefined || this.dialogs.animated);
397
411
  if (wanted && !this.activity.running) { this.lastEvent = Date.now(); this.startClock(ACTIVITY_MS); }
398
412
  else if (!wanted && this.activity.running) { this.activity.stop(); this.paintEdges(); }
399
413
  }
@@ -409,6 +423,13 @@ export class Screen {
409
423
  }, undefined, intervalMs);
410
424
  }
411
425
 
426
+ private transientActivityText(): string {
427
+ const activity = this.transientActivity!;
428
+ const elapsed = Math.max(0, Math.floor((Date.now() - activity.startedAt) / 1000));
429
+ const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
430
+ return `${activity.label} · ${elapsed}s / ${deadline}s max`;
431
+ }
432
+
412
433
  /** The spinner's current interval (tests). */
413
434
  get activityInterval(): number { return this.activity.running ? this.activityMs : 0; }
414
435
 
@@ -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" }])],