@visual-z/xeno 0.6.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.
- package/CHANGELOG.md +9 -0
- package/README.md +13 -2
- package/package.json +1 -1
- package/src/apps/session-hub.ts +12 -1
- package/src/apps/shell.ts +1 -1
- package/src/apps/ui/app.ts +68 -16
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/core/activity-status.ts +28 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/progress.ts +9 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/images.ts +6 -2
- package/src/runtime/loop.ts +56 -11
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +6 -1
- package/src/runtime/session-control.ts +1 -0
- package/src/runtime/tools.ts +7 -3
- package/src/store/blobs.ts +2 -1
- package/src/store/git-status.ts +15 -0
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
package/src/runtime/provider.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type { UsageSample } from "./usage.ts";
|
|
|
39
39
|
import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
|
|
40
40
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
41
41
|
import { anthropicOAuthHeaders } from "./oauth.ts";
|
|
42
|
+
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
42
43
|
|
|
43
44
|
export interface ToolSchema {
|
|
44
45
|
name: string;
|
|
@@ -106,6 +107,7 @@ export interface Provider {
|
|
|
106
107
|
node: number,
|
|
107
108
|
signal?: AbortSignal,
|
|
108
109
|
onDelta?: OnDelta,
|
|
110
|
+
options?: ProviderSendOptions,
|
|
109
111
|
): Promise<Turn>;
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -235,9 +237,10 @@ export class AnthropicProvider implements Provider {
|
|
|
235
237
|
node: number,
|
|
236
238
|
signal?: AbortSignal,
|
|
237
239
|
onDelta?: OnDelta,
|
|
240
|
+
options?: ProviderSendOptions,
|
|
238
241
|
): Promise<Turn> {
|
|
239
242
|
return withRetry(
|
|
240
|
-
() => this.attempt(frozen, messages, node, signal, onDelta),
|
|
243
|
+
() => this.attempt(frozen, messages, node, signal, onDelta, options),
|
|
241
244
|
this.retry,
|
|
242
245
|
undefined,
|
|
243
246
|
(attempt, delayMs, error) => {
|
|
@@ -257,6 +260,7 @@ export class AnthropicProvider implements Provider {
|
|
|
257
260
|
node: number,
|
|
258
261
|
signal?: AbortSignal,
|
|
259
262
|
onDelta?: OnDelta,
|
|
263
|
+
options?: ProviderSendOptions,
|
|
260
264
|
): Promise<Turn> {
|
|
261
265
|
const started = Date.now();
|
|
262
266
|
|
|
@@ -277,6 +281,7 @@ export class AnthropicProvider implements Provider {
|
|
|
277
281
|
// Auto-caching walks the breakpoint forward over the growing tail.
|
|
278
282
|
cache_control: { type: "ephemeral" },
|
|
279
283
|
tools: frozen.tools as Anthropic.Tool[],
|
|
284
|
+
...(options?.toolUse === "disabled" ? { tool_choice: { type: "none" as const } } : {}),
|
|
280
285
|
// Only a reasoning model understands these. An older Claude, or an
|
|
281
286
|
// Anthropic-compatible endpoint serving some other model, answers
|
|
282
287
|
// them with a 400 or silently ignores them.
|
|
@@ -164,6 +164,7 @@ export class SessionControl {
|
|
|
164
164
|
// Assumed ingress bound, not an execution policy. Keep a client from queuing unbounded work.
|
|
165
165
|
if (turn.queue.length >= 32) throw Error("steering queue is full");
|
|
166
166
|
turn.queue.push(structuredClone(input));
|
|
167
|
+
this.session.wakeProcessWaits();
|
|
167
168
|
this.emit("turn/steeringQueued", { count: turn.queue.length });
|
|
168
169
|
}
|
|
169
170
|
interrupt(turnId: string): void {
|
package/src/runtime/tools.ts
CHANGED
|
@@ -127,6 +127,9 @@ export interface ToolContext {
|
|
|
127
127
|
historyCache?: HistoryCache;
|
|
128
128
|
/** O.14: opaque process ownership is supplied by Session, never by model input. */
|
|
129
129
|
execOwner?: string;
|
|
130
|
+
/** Session-owned empty stdin waits; low-level/native callers retain bounded polling. */
|
|
131
|
+
processWait?: { wake(): AbortSignal };
|
|
132
|
+
onProcessWait?: (session: number, active: boolean) => void;
|
|
130
133
|
/** Session-owned registrations never mutate the built-in table (D13). */
|
|
131
134
|
extensions?: ReadonlyMap<string, ToolDefinition>;
|
|
132
135
|
/** R11: current locally selected optional schemas, never restored as authority from historical text. */
|
|
@@ -629,7 +632,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
629
632
|
access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
|
|
630
633
|
description:
|
|
631
634
|
"Send characters to an owned running process handle and return the output since the last call. " +
|
|
632
|
-
"
|
|
635
|
+
"In an agent session, empty chars waits for new output, exit, hard timeout, cancellation or user steering without periodic model polling. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
|
|
633
636
|
"After exit, empty chars retrieves the last receipt without rerunning or producing fresh verification. Source and byte cursor are in data; full_output recovers all captured output. " +
|
|
634
637
|
"Lost handles cannot accept input; a last running observation is not an exit. Ctrl-C may interrupt a PTY foreground job while its REPL stays alive.",
|
|
635
638
|
input_schema: {
|
|
@@ -637,7 +640,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
637
640
|
properties: {
|
|
638
641
|
session_id: { type: "integer", minimum: 1 },
|
|
639
642
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
640
|
-
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "
|
|
643
|
+
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Bounded wait when sending input or using low-level polling; agent empty waits are owned until an observation changes" },
|
|
641
644
|
},
|
|
642
645
|
required: ["session_id"],
|
|
643
646
|
},
|
|
@@ -1108,7 +1111,8 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1108
1111
|
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1109
1112
|
}
|
|
1110
1113
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1111
|
-
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal
|
|
1114
|
+
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal,
|
|
1115
|
+
ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined));
|
|
1112
1116
|
}
|
|
1113
1117
|
|
|
1114
1118
|
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
package/src/store/blobs.ts
CHANGED
|
@@ -121,8 +121,9 @@ export async function getBlob(dir: string, sha: string): Promise<string | null>
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/** Restore uses bytes; decoding binary data is an irreversible transformation. */
|
|
124
|
-
export async function getBlobBytes(dir: string, sha: string): Promise<Uint8Array | null> {
|
|
124
|
+
export async function getBlobBytes(dir: string, sha: string, maxBytes?: number): Promise<Uint8Array | null> {
|
|
125
125
|
try {
|
|
126
|
+
if (maxBytes !== undefined && (await stat(blobFilePath(dir, sha))).size > maxBytes) throw new RangeError("Blob exceeds byte limit");
|
|
126
127
|
const bytes = await readFile(blobFilePath(dir, sha));
|
|
127
128
|
if (blobAddress(bytes) !== sha) throw new Error(`blob integrity failure: ${sha}`);
|
|
128
129
|
return bytes;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Local Git reads are asynchronous and bounded; a paint never launches a process. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { parseGitStatus, type GitStatus } from "../core/git-status.ts";
|
|
4
|
+
/** Assumed cold-disk allowance; Git failure remains unavailable rather than "clean". */
|
|
5
|
+
const GIT_STATUS_TIMEOUT_MS = 1000;
|
|
6
|
+
/** A busy repository cannot flood memory or the status view with paths. */
|
|
7
|
+
const GIT_STATUS_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export function readGitStatus(cwd: string): Promise<GitStatus | null> {
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
execFile("git", ["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=normal"],
|
|
12
|
+
{ cwd, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BYTES, encoding: "utf8" },
|
|
13
|
+
(error, output) => resolve(error ? null : parseGitStatus(output)));
|
|
14
|
+
});
|
|
15
|
+
}
|
package/src/tui/ui/layout.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where status lives at a given terminal size (docs/tasks/refactor-tui-dock-layout.md). Pure.
|
|
3
3
|
*
|
|
4
|
-
* sidebar ≥ 110 cols and ≥ 20 rows right column
|
|
4
|
+
* sidebar ≥ 110 cols and ≥ 20 rows right column plus compact provider header
|
|
5
5
|
* topbar 80–109 cols, or a wide but short terminal (≥ 18 rows) one row on top
|
|
6
6
|
* bare everything else the input box's bottom edge only;
|
|
7
7
|
* session and branch go to the terminal title
|
|
@@ -42,16 +42,16 @@ export function screenLayout(cols: number, rows: number, sidebarWanted = true):
|
|
|
42
42
|
const c = Math.max(0, Math.floor(cols)), r = Math.max(0, Math.floor(rows));
|
|
43
43
|
if (sidebarWanted && c >= SIDEBAR_MIN_COLUMNS && r >= SIDEBAR_MIN_ROWS) {
|
|
44
44
|
const sidebarWidth = sidebarContentWidth(c) + 1;
|
|
45
|
-
return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar:
|
|
45
|
+
return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar: true };
|
|
46
46
|
}
|
|
47
47
|
if (c >= TOPBAR_MIN_COLUMNS && r >= TOPBAR_MIN_ROWS) return { tier: "topbar", sidebarWidth: 0, mainWidth: c, topbar: true };
|
|
48
48
|
return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export const STATUS_FIELDS = ["model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
51
|
+
export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
52
52
|
export type StatusField = (typeof STATUS_FIELDS)[number];
|
|
53
53
|
/** Screen surfaces only; the terminal title is outside the frame and listed separately. */
|
|
54
|
-
export type Place = "sidebar" | "topbar" | "bottomEdge" | "hidden";
|
|
54
|
+
export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* The one place each field takes in a tier. Hidden fields stay one command
|
|
@@ -61,13 +61,13 @@ export type Place = "sidebar" | "topbar" | "bottomEdge" | "hidden";
|
|
|
61
61
|
export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
|
|
62
62
|
switch (tier) {
|
|
63
63
|
case "sidebar":
|
|
64
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
64
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
65
65
|
usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
|
|
66
66
|
case "topbar":
|
|
67
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
67
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
68
68
|
usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
|
|
69
69
|
case "bare":
|
|
70
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
70
|
+
return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
71
71
|
usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
|
|
72
72
|
default: return assertNever(tier, "placement");
|
|
73
73
|
}
|
package/src/tui/ui/messages.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface MessageFeatures {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** Without a source (tests, print mode) pictures are named, not drawn. */
|
|
37
|
-
const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () =>
|
|
37
|
+
const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => ({ kind: "error", reason: "closed" }) };
|
|
38
38
|
|
|
39
39
|
interface Entry { rev: string; node: Renderable; item: ViewItem }
|
|
40
40
|
|
package/src/tui/ui/pictures.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { BoxRenderable, ImageRenderable, type CliRenderer, type ImageRenderProtocol, type NativeImage } from "@opentui/core";
|
|
14
14
|
import { Canvas } from "./canvas.ts";
|
|
15
15
|
import type { UiColors } from "./colors.ts";
|
|
16
|
-
import type
|
|
16
|
+
import { splitLine, type Seg } from "./edge.ts";
|
|
17
17
|
import { imageCells, IMAGES_PER_ITEM } from "./image-size.ts";
|
|
18
18
|
import type { ImageView } from "./items.ts";
|
|
19
19
|
|
|
@@ -25,10 +25,14 @@ export interface PictureSource {
|
|
|
25
25
|
readonly cell: { readonly width: number; readonly height: number };
|
|
26
26
|
/** The widest a picture may be, in cells (`terminal.imageWidthCells`). */
|
|
27
27
|
readonly widthCells: number;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
readonly reason?: string;
|
|
29
|
+
/** The decoded picture for a blob, shared by every node that shows it, or its preparation failure. */
|
|
30
|
+
load(sha256: string): Promise<PictureLoad>;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
export type PictureLoad = { readonly kind: "ready"; readonly image: NativeImage } |
|
|
34
|
+
{ readonly kind: "error"; readonly reason: "missing" | "corrupt" | "decode" | "limit" | "busy" | "closed" };
|
|
35
|
+
|
|
32
36
|
let serial = 0;
|
|
33
37
|
|
|
34
38
|
export function chipLine(images: readonly ImageView[], limit: number): Seg[] {
|
|
@@ -52,17 +56,31 @@ export class Pictures extends BoxRenderable {
|
|
|
52
56
|
const limit = options.limit ?? IMAGES_PER_ITEM;
|
|
53
57
|
const shown = images.slice(0, limit);
|
|
54
58
|
const pictures: ImageRenderable[] = [];
|
|
59
|
+
const states = shown.map(() => source.mode === "picture" ? "preparing" : source.reason ?? "preview unavailable");
|
|
60
|
+
const caption = new Canvas(ctx, { id: `${id}-caption`, height: Math.max(1, shown.length), flexShrink: 0, colors });
|
|
61
|
+
// A long filename must not hide the preparation state or the reason a preview failed.
|
|
62
|
+
const paintCaption = () => caption.setRows(shown.length ? shown.map((image, i) => splitLine(options.maxCols, [
|
|
63
|
+
{ segs: [{ text: "▣ ", role: "muted" }, { text: image.name, role: "text" }], priority: 0, min: 4, shrinkPriority: 5 },
|
|
64
|
+
{ segs: [{ text: `${image.width}×${image.height}`, role: "muted" }], priority: 3 },
|
|
65
|
+
...(i === shown.length - 1 && images.length > shown.length ? [{ segs: [{ text: `+${images.length - shown.length} more`, role: "muted" as const }], priority: 4 }] : []),
|
|
66
|
+
], [{ segs: [{ text: states[i]!, role: states[i]?.startsWith("preview failed") ? "warning" : "muted" }], priority: 0 }], 1, [{ text: " · ", role: "muted" }])) : [chipLine(images, limit)]);
|
|
55
67
|
if (source.mode === "picture" && shown.length) {
|
|
56
68
|
const row = new BoxRenderable(ctx, { id: `${id}-row`, flexDirection: "row", flexShrink: 0, columnGap: 1 });
|
|
57
69
|
// Pictures share the width; each keeps its own proportions.
|
|
58
70
|
const each = Math.max(1, Math.floor((Math.min(options.maxCols, source.widthCells * shown.length) - (shown.length - 1)) / shown.length));
|
|
59
|
-
for (const image of shown) {
|
|
71
|
+
for (const [index, image] of shown.entries()) {
|
|
60
72
|
const size = imageCells(image, Math.min(each, source.widthCells), options.maxRows, source.cell);
|
|
61
73
|
const picture = new ImageRenderable(ctx, { id: `${id}-${image.sha256.slice(0, 8)}-${serial++}`, width: size.cols, height: size.rows,
|
|
62
|
-
fit: "fit", protocol: source.protocol, flexShrink: 0
|
|
63
|
-
|
|
74
|
+
fit: "fit", protocol: source.protocol, flexShrink: 0,
|
|
75
|
+
onLoad: () => { states[index] = "decoded"; if (!caption.isDestroyed) paintCaption(); },
|
|
76
|
+
onError: () => { states[index] = "preview failed: decode"; picture.visible = false; if (!caption.isDestroyed) paintCaption(); } });
|
|
77
|
+
void source.load(image.sha256).then(result => {
|
|
78
|
+
if (picture.isDestroyed) return;
|
|
79
|
+
if (result.kind === "ready") picture.source = result.image;
|
|
80
|
+
else { picture.visible = false; states[index] = `preview failed: ${result.reason}`; paintCaption(); }
|
|
81
|
+
}).catch(() => {
|
|
64
82
|
if (picture.isDestroyed) return;
|
|
65
|
-
|
|
83
|
+
picture.visible = false; states[index] = "preview failed: decode"; paintCaption();
|
|
66
84
|
});
|
|
67
85
|
pictures.push(picture);
|
|
68
86
|
row.add(picture);
|
|
@@ -71,8 +89,7 @@ export class Pictures extends BoxRenderable {
|
|
|
71
89
|
}
|
|
72
90
|
this.pictures = pictures;
|
|
73
91
|
// The names stay visible beside the pictures (and stand in for them in chip mode).
|
|
74
|
-
|
|
75
|
-
caption.setRows([chipLine(images, source.mode === "picture" ? shown.length : limit)]);
|
|
92
|
+
paintCaption();
|
|
76
93
|
this.add(caption);
|
|
77
94
|
}
|
|
78
95
|
}
|
package/src/tui/ui/screen.ts
CHANGED
|
@@ -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 () =>
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/src/tui/ui/status.ts
CHANGED
|
@@ -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
|
|
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: "
|
|
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:
|
|
196
|
-
|
|
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: "
|
|
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" }])],
|