@visual-z/xeno 0.8.0 → 0.8.2
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 +41 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/startup.ts +27 -2
- package/src/apps/ui/app.ts +69 -9
- package/src/core/activity-status.ts +10 -1
- package/src/core/command-contract.ts +79 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/environment.ts +7 -0
- package/src/core/goal-view.ts +64 -0
- package/src/core/history.ts +7 -1
- package/src/core/permissions.ts +4 -3
- package/src/core/prompt-policy.ts +9 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/response-recovery.ts +13 -1
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +21 -1
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +17 -4
- package/src/core/update-check.ts +2 -1
- package/src/runtime/exec-sessions.ts +3 -1
- package/src/runtime/loop.ts +98 -12
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/tools.ts +46 -8
- package/src/runtime/usage.ts +28 -0
- package/src/store/command.ts +3 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/search.ts +9 -5
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +18 -9
- package/src/tui/ui/status.ts +115 -21
package/src/tui/ui/screen.ts
CHANGED
|
@@ -31,8 +31,9 @@ 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, modeLabels, sidebarLines, topbarRow, type Line, type StatusModel } from "./status.ts";
|
|
34
|
+
import { contextLabel, goalLabel, idleActivityLabel, modeLabels, sidebarLines, speedText, topbarRow, type Line, type StatusModel } from "./status.ts";
|
|
35
35
|
import { DEFAULT_CELL, stripImageRows } from "./image-size.ts";
|
|
36
|
+
import { formatElapsed } from "./duration.ts";
|
|
36
37
|
import { Pictures, type PictureSource } from "./pictures.ts";
|
|
37
38
|
import type { ImageView, ViewItem } from "./items.ts";
|
|
38
39
|
|
|
@@ -104,6 +105,8 @@ export class Screen {
|
|
|
104
105
|
private readonly activity = new Motion();
|
|
105
106
|
private spin = 0;
|
|
106
107
|
private busySince = 0;
|
|
108
|
+
/** The last turn's wall time, shown beside its outcome until the next turn starts. */
|
|
109
|
+
private lastTurnMs: number | undefined;
|
|
107
110
|
private lastEvent = 0;
|
|
108
111
|
private activityMs = ACTIVITY_MS;
|
|
109
112
|
private animations = true;
|
|
@@ -336,7 +339,8 @@ export class Screen {
|
|
|
336
339
|
setDock(state: DockState): void {
|
|
337
340
|
const wasBusy = this.dockState.busy;
|
|
338
341
|
this.dockState = state;
|
|
339
|
-
if (state.busy && !wasBusy) this.busySince = Date.now();
|
|
342
|
+
if (state.busy && !wasBusy) { this.busySince = Date.now(); this.lastTurnMs = undefined; }
|
|
343
|
+
if (!state.busy && wasBusy) this.lastTurnMs = Date.now() - this.busySince;
|
|
340
344
|
this.syncActivity();
|
|
341
345
|
this.paintEdges();
|
|
342
346
|
}
|
|
@@ -376,11 +380,14 @@ export class Screen {
|
|
|
376
380
|
// to recover on the existing edge, without putting control content in it.
|
|
377
381
|
left = [{ segs: [{ text: "Resize for control", role: "accent" }], priority: 0, min: 1 }];
|
|
378
382
|
} else if (d.busy) {
|
|
379
|
-
const
|
|
383
|
+
const elapsed = formatElapsed(Date.now() - this.busySince);
|
|
380
384
|
const activity = this.status?.activity;
|
|
381
385
|
const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
|
|
382
386
|
const transient = this.transientActivity ? ` · ${this.transientActivityText()}` : "";
|
|
383
|
-
left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${
|
|
387
|
+
left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${elapsed}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
|
|
388
|
+
// The turn's speed so far, refreshed after each model request (runtime/usage.ts generationSpeed).
|
|
389
|
+
const speed = this.status?.usage?.speed;
|
|
390
|
+
if (speed !== undefined) right.push({ segs: [{ text: speedText(speed), role: "muted" }], priority: 3 });
|
|
384
391
|
right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
|
|
385
392
|
} else if (this.transientActivity) {
|
|
386
393
|
left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${this.transientActivityText()}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
|
|
@@ -389,15 +396,17 @@ export class Screen {
|
|
|
389
396
|
const { above, below } = this.composer.overflow;
|
|
390
397
|
if (above) left = [{ segs: [{ text: `↑ ${above} more`, role: "muted" }], priority: 1 }];
|
|
391
398
|
if (below && !this.toastText) right.push({ segs: [{ text: `↓ ${below} more`, role: "muted" }], priority: 2 });
|
|
392
|
-
if (!above && this.status?.activity) left = [
|
|
399
|
+
if (!above && this.status?.activity) left = [idleActivityLabel(this.status.activity, this.lastTurnMs)];
|
|
393
400
|
}
|
|
394
401
|
this.topEdge.setRows([edgeLine({ width, corners: ["╭", "╮"], role, left, right })]);
|
|
395
402
|
const bottomLeft: Label[] = [...(this.status ? modeLabels(this.status) : []), ...(d.model ? [
|
|
396
403
|
{ segs: [{ text: d.model, role: "text" as const, bold: true }], priority: 1, min: 8 },
|
|
397
404
|
...(d.thinking ? [{ segs: [{ text: d.thinking, role: "muted" as const }], priority: 2 }] : []),
|
|
398
405
|
] : [])];
|
|
406
|
+
// Without a sidebar the goal and the context share ride on this edge (layout.ts placement).
|
|
399
407
|
const context = this.shape.tier !== "sidebar" && this.status ? contextLabel(this.status) : undefined;
|
|
400
|
-
this.
|
|
408
|
+
const goal = this.shape.tier !== "sidebar" && this.status ? goalLabel(this.status) : undefined;
|
|
409
|
+
this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: [...(goal ? [goal] : []), ...(context ? [context] : [])] })]);
|
|
401
410
|
}
|
|
402
411
|
|
|
403
412
|
/** A stream event arrived (delta, tool progress, shell output): the spinner returns to full speed. */
|
|
@@ -426,9 +435,9 @@ export class Screen {
|
|
|
426
435
|
|
|
427
436
|
private transientActivityText(): string {
|
|
428
437
|
const activity = this.transientActivity!;
|
|
429
|
-
|
|
430
|
-
const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
|
|
431
|
-
return `${activity.label} · ${
|
|
438
|
+
// The limit is a whole number of seconds, rounded up so it is never shown shorter than it is.
|
|
439
|
+
const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000)) * 1000;
|
|
440
|
+
return `${activity.label} · ${formatElapsed(Date.now() - activity.startedAt)} / ${formatElapsed(deadline)} max${activity.detail ? ` · ${activity.detail}` : ""}`;
|
|
432
441
|
}
|
|
433
442
|
|
|
434
443
|
/** The spinner's current interval (tests). */
|
package/src/tui/ui/status.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { clipSegs, clipText, segsWidth, splitLine, type Label, type Seg } from "./edge.ts";
|
|
16
16
|
import { displayWidth } from "../width.ts";
|
|
17
|
+
import { formatElapsed } from "./duration.ts";
|
|
17
18
|
|
|
18
19
|
export interface ContextStatus {
|
|
19
20
|
readonly used: number;
|
|
@@ -30,9 +31,13 @@ export interface StatusModel {
|
|
|
30
31
|
readonly version: string;
|
|
31
32
|
/** `/name`, else the first prompt; null for a session with neither. */
|
|
32
33
|
readonly title: string | null;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* An explicit /goal, prepared by apps from the log (core/goal-view.ts). The
|
|
36
|
+
* block exists only while a goal is set, and for one message after it is met.
|
|
37
|
+
*/
|
|
38
|
+
readonly goal?: GoalStatusView;
|
|
39
|
+
/** Without a goal: the latest check after this request's last edit, when it changed files. */
|
|
40
|
+
readonly checks?: { readonly status: "unchecked" | "pass" | "fail" | "stale" | "partial"; readonly command?: string; readonly exit?: number };
|
|
36
41
|
readonly workspace: string;
|
|
37
42
|
readonly workspaceShort: string;
|
|
38
43
|
readonly branch: string | null;
|
|
@@ -41,7 +46,8 @@ export interface StatusModel {
|
|
|
41
46
|
readonly activity?: { readonly state: string; readonly label: string; readonly detail: string; readonly queued: number };
|
|
42
47
|
readonly git?: { readonly staged: number; readonly changed: number; readonly untracked: number; readonly conflicted: number; readonly ahead: number; readonly behind: number } | null;
|
|
43
48
|
readonly context?: ContextStatus;
|
|
44
|
-
|
|
49
|
+
/** `speed`: output tokens per second over this turn's model requests (runtime/usage.ts generationSpeed). */
|
|
50
|
+
readonly usage?: { readonly input: number; readonly output: number; readonly cacheRate: number; readonly cost: string | null; readonly speed?: number };
|
|
45
51
|
readonly tools: { readonly running: number; readonly done: number; readonly failed: number };
|
|
46
52
|
readonly handoff?: { readonly verified: readonly string[]; readonly inFlight: readonly string[]; readonly unverified: readonly string[] };
|
|
47
53
|
readonly tasks?: readonly { readonly what: string; readonly status: "active" | "blocked" }[];
|
|
@@ -51,6 +57,18 @@ export interface StatusModel {
|
|
|
51
57
|
readonly extension?: string;
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
export interface GoalStatusView {
|
|
61
|
+
readonly phase: "drafting" | "working" | "needs_you" | "met" | "stopped";
|
|
62
|
+
readonly objective: string;
|
|
63
|
+
readonly elapsedMs: number;
|
|
64
|
+
readonly checks: readonly { readonly id: string; readonly kind: "command" | "human" | "fresh_check"; readonly status: string; readonly guard?: true; readonly exit?: number }[];
|
|
65
|
+
/** S.1: one glyph per rung (ran, tests the change, independent, human), prepared by apps. */
|
|
66
|
+
readonly ladder?: { readonly glyphs: string; readonly summary: string };
|
|
67
|
+
/** Model requests since the goal was set (from the log) and output tokens this process saw. */
|
|
68
|
+
readonly requests: number;
|
|
69
|
+
readonly tokens?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
export type Line = readonly Seg[];
|
|
55
73
|
|
|
56
74
|
/** 1234 → "1.2k", 1234567 → "1.2m". The same compact form everywhere a token count appears. */
|
|
@@ -150,6 +168,10 @@ interface Block {
|
|
|
150
168
|
readonly items: readonly Line[];
|
|
151
169
|
/** A list block with no items is not shown at all. */
|
|
152
170
|
readonly list: boolean;
|
|
171
|
+
/** Rows shown after the items, kept with the head (the goal's ladder and spend). */
|
|
172
|
+
readonly tail?: readonly Line[];
|
|
173
|
+
/** A list shown even without items: a goal being drafted has no checks yet. */
|
|
174
|
+
readonly keep?: boolean;
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
/**
|
|
@@ -160,12 +182,12 @@ interface Block {
|
|
|
160
182
|
*/
|
|
161
183
|
export function sidebarLines(model: StatusModel, width: number, height: number): Line[] {
|
|
162
184
|
const w = Math.max(1, Math.floor(width)), h = Math.max(0, Math.floor(height));
|
|
163
|
-
const blocks = sidebarBlocks(model, w).filter(block => !block.list || block.items.length > 0);
|
|
185
|
+
const blocks = sidebarBlocks(model, w).filter(block => !block.list || block.items.length > 0 || block.keep);
|
|
164
186
|
const plan: { block: Block; rows: number }[] = [];
|
|
165
187
|
let used = 0;
|
|
166
188
|
for (const block of blocks) {
|
|
167
189
|
const minimum = block.list ? Math.min(block.items.length, 2) : 0;
|
|
168
|
-
const need = (plan.length ? 1 : 0) + block.head.length + minimum;
|
|
190
|
+
const need = (plan.length ? 1 : 0) + block.head.length + minimum + (block.tail?.length ?? 0);
|
|
169
191
|
if (used + need > h) continue;
|
|
170
192
|
used += need;
|
|
171
193
|
plan.push({ block, rows: minimum });
|
|
@@ -185,6 +207,7 @@ export function sidebarLines(model: StatusModel, width: number, height: number):
|
|
|
185
207
|
out.push(...items.slice(0, entry.rows - 1));
|
|
186
208
|
out.push([{ text: `+${items.length - entry.rows + 1} more`, role: "muted" }]);
|
|
187
209
|
}
|
|
210
|
+
out.push(...entry.block.tail ?? []);
|
|
188
211
|
});
|
|
189
212
|
return out.slice(0, h).map(line => clipSegs(line, w));
|
|
190
213
|
}
|
|
@@ -203,36 +226,37 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
|
|
|
203
226
|
...(model.git.conflicted ? [[{ text: `${model.git.conflicted} conflicts`, role: "warning" }] as Line] : []),
|
|
204
227
|
...(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] : []),
|
|
205
228
|
] });
|
|
229
|
+
const goal = model.goal;
|
|
230
|
+
if (goal) blocks.push(goalBlock(goal, w, row));
|
|
231
|
+
else if (model.checks) blocks.push({ list: false, items: [], head: [checksLine(model.checks, w)] });
|
|
206
232
|
const context = model.context;
|
|
207
|
-
if (model.goal) blocks.push({ list: false, items: [], head: [
|
|
208
|
-
[{ text: "Goal", role: "text", bold: true }, { text: ` · ${model.goal.status}`, role: model.goal.status === "verified" || model.goal.status === "checked" ? "success" : model.goal.status === "not_applicable" ? "muted" : "warning" }],
|
|
209
|
-
...(model.goal.ladder ? [[{ text: model.goal.ladder.glyphs, role: "text" }] as Line,
|
|
210
|
-
...wrap(model.goal.ladder.summary, w, 2).map(text => [{ text, role: "muted" }] as Line)] : []),
|
|
211
|
-
...wrap(firstLine(model.goal.text), w, 2).map(text => [{ text, role: "text" }] as Line),
|
|
212
|
-
[{ text: model.goal.execution, role: "muted" }],
|
|
213
|
-
[{ text: `${model.goal.observations} observations · ${model.goal.retries} retries`, role: "muted" }],
|
|
214
|
-
[{ text: "/control · goal, evidence, context", role: "muted" }],
|
|
215
|
-
] });
|
|
216
233
|
if (context) {
|
|
217
234
|
const share = contextShare(context), est = context.estimated ? "~" : "";
|
|
218
235
|
const filled = share === undefined ? 0 : Math.max(share > 0 ? 1 : 0, Math.round(share * w));
|
|
219
236
|
const tone = share !== undefined && share >= CONTEXT_WARNING ? "warning" : "accent";
|
|
220
237
|
const shareText = shareSeg(context);
|
|
238
|
+
// Two budgets, each named: the segment cut (what the bar shows) and the model window. Both shares
|
|
239
|
+
// ride on the heading, both denominators on one row below the bar.
|
|
240
|
+
const modelShare = context.window && context.trigger !== null ? `${est}${Math.round(context.used / context.window * 100)}%` : undefined;
|
|
241
|
+
const segment = context.trigger !== null ? `seg ${est}${compact(context.used)} / ${est}${compact(context.trigger)}` : `${est}${compact(context.used)} · no cut`;
|
|
221
242
|
blocks.push({ list: false, items: [], head: [
|
|
222
|
-
row([{ text: "Context", role: "text", bold: true }],
|
|
243
|
+
row([{ text: "Context", role: "text", bold: true }], [
|
|
244
|
+
...(shareText ? [{ text: context.trigger === null ? "model " : "seg ", role: "muted" as const }, shareText] : []),
|
|
245
|
+
...(modelShare ? [{ text: ` · model ${modelShare}`, role: "muted" as const }] : [])]),
|
|
223
246
|
...(share === undefined ? [] : [[{ text: "━".repeat(filled), role: tone }, { text: "─".repeat(Math.max(0, w - filled)), role: "line" }] as Line]),
|
|
224
|
-
[{ text:
|
|
225
|
-
[{ text: context.window ? `Model ${est}${compact(context.used)} / ${compact(context.window)} · ${est}${Math.round(context.used / context.window * 100)}%` : "Model window unknown", role: "muted" }],
|
|
226
|
-
[{ text: `node ${context.segment}`, role: "muted" }],
|
|
247
|
+
row([{ text: segment, role: "muted" }], [{ text: context.window ? `model ${compact(context.window)}` : "window unknown", role: "muted" }]),
|
|
227
248
|
] });
|
|
228
249
|
}
|
|
229
250
|
const usage = model.usage, tools = model.tools;
|
|
230
251
|
const toolCount = tools.done + tools.failed + tools.running;
|
|
231
252
|
if (usage || toolCount) {
|
|
253
|
+
const figures = usage ? [`↑${compact(usage.input)} ↓${compact(usage.output)}`, `cache ${Math.round(usage.cacheRate * 100)}%`,
|
|
254
|
+
...(usage.speed !== undefined ? [speedText(usage.speed)] : [])].join(" · ") : "";
|
|
255
|
+
// Heading with the cost; one row of figures below it. Together they need about 49 columns, more than the 34-42 the sidebar has.
|
|
232
256
|
blocks.push({ list: false, items: [], head: [
|
|
233
257
|
row([{ text: "Usage", role: "text", bold: true }], usage?.cost ? [{ text: usage.cost, role: "text" }] : []),
|
|
234
|
-
...(
|
|
235
|
-
...(
|
|
258
|
+
...(figures ? [[{ text: figures, role: "muted" }] as Line] : []),
|
|
259
|
+
...(tools.failed || tools.running ? [[
|
|
236
260
|
{ text: `${tools.done} done`, role: "muted" },
|
|
237
261
|
...(tools.failed ? [{ text: " · ", role: "muted" as const }, { text: `${tools.failed} failed`, role: "error" as const }] : []),
|
|
238
262
|
...(tools.running ? [{ text: " · ", role: "muted" as const }, { text: `${tools.running} running`, role: "accent" as const }] : []),
|
|
@@ -270,6 +294,60 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
|
|
|
270
294
|
return blocks;
|
|
271
295
|
}
|
|
272
296
|
|
|
297
|
+
/** `42 tok/s`; under 10 one decimal, so a slow upstream does not read as zero. */
|
|
298
|
+
export function speedText(speed: number): string {
|
|
299
|
+
return `${speed < 10 ? speed.toFixed(1) : Math.round(speed)} tok/s`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const PHASE: Record<GoalStatusView["phase"], { label: string; role: Seg["role"] }> = {
|
|
303
|
+
drafting: { label: "drafting", role: "accent" }, working: { label: "working", role: "accent" },
|
|
304
|
+
needs_you: { label: "needs you", role: "warning" }, met: { label: "met", role: "success" }, stopped: { label: "stopped", role: "warning" },
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* `Goal · needs you 4m 12s`, the objective, one row per accepted check with
|
|
309
|
+
* what it needs, the evidence ladder and what the goal has spent. A met goal
|
|
310
|
+
* is one summary row. The sidebar may cut the checks; a cut ends in "+N more".
|
|
311
|
+
*/
|
|
312
|
+
function goalBlock(goal: GoalStatusView, w: number, row: (left: readonly Seg[], right?: readonly Seg[]) => Line): Block {
|
|
313
|
+
const phase = PHASE[goal.phase];
|
|
314
|
+
const passed = goal.checks.filter(check => check.status === "pass" || check.status === "accepted").length;
|
|
315
|
+
const elapsed = goal.phase === "drafting" ? [] : [{ text: formatElapsed(goal.elapsedMs), role: "muted" as const }];
|
|
316
|
+
const title = [{ text: "Goal", role: "text" as const, bold: true }, { text: ` · ${phase.label}`, role: phase.role }];
|
|
317
|
+
if (goal.phase === "met") return { list: false, items: [], head: [row(title, [{ text: `${passed}/${goal.checks.length} · `, role: "muted" }, ...elapsed])] };
|
|
318
|
+
const items = goal.checks.map(check => {
|
|
319
|
+
const done = check.status === "pass" || check.status === "accepted";
|
|
320
|
+
const glyph: Seg = done ? { text: "✓ ", role: "success" } : check.status === "fail" ? { text: "✗ ", role: "error" } : { text: "○ ", role: "muted" };
|
|
321
|
+
const need = check.kind === "human" && !done ? `/accept ${check.id}` : check.status === "fail" && check.exit !== undefined ? `exit ${check.exit}`
|
|
322
|
+
: check.status === "stale" ? "rerun" : check.guard ? "guard" : "";
|
|
323
|
+
return row([glyph, { text: check.id, role: "text" }], need ? [{ text: need, role: check.kind === "human" && !done ? "warning" : "muted" }] : []);
|
|
324
|
+
});
|
|
325
|
+
const spent = [`${goal.requests} ${goal.requests === 1 ? "request" : "requests"}`, ...(goal.tokens ? [`${compact(goal.tokens)} tokens this run`] : [])].join(" · ");
|
|
326
|
+
return { list: true, keep: true, head: [row(title, elapsed), ...wrap(firstLine(goal.objective), w, 2).map(text => [{ text, role: "text" }] as Line)],
|
|
327
|
+
items, ...(goal.phase === "drafting" ? {} : { tail: [...(goal.ladder ? [[{ text: goal.ladder.glyphs, role: "text" }] as Line] : []), [{ text: spent, role: "muted" }] as Line] }) };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Without a goal: whether a check has passed since this request's last edit. */
|
|
331
|
+
function checksLine(checks: NonNullable<StatusModel["checks"]>, w: number): Line {
|
|
332
|
+
if (checks.status === "unchecked" || !checks.command) return [{ text: "Checks", role: "text", bold: true }, { text: " · not run since last edit", role: "warning" }];
|
|
333
|
+
// partial: some of a declared contract holds; the rest may only be waiting for the user, so it is not a failure mark.
|
|
334
|
+
const mark: Seg[] = checks.status === "pass" ? [{ text: " ✓", role: "success" }] : checks.status === "partial" ? []
|
|
335
|
+
: checks.status === "fail" ? [{ text: " ✗", role: "error" }, ...(checks.exit === undefined ? [] : [{ text: ` exit ${checks.exit}`, role: "muted" as const }])]
|
|
336
|
+
: [{ text: " stale", role: "warning" }];
|
|
337
|
+
const room = Math.max(1, w - segsWidth(mark) - "Checks · ".length);
|
|
338
|
+
return [{ text: "Checks", role: "text", bold: true }, { text: ` · ${clipText(checks.command, room)}`, role: "muted" }, ...mark];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Without a sidebar the goal rides on the input box's bottom edge: `goal 2/3 · needs you · 4m 12s`. */
|
|
342
|
+
export function goalLabel(model: StatusModel): Label | undefined {
|
|
343
|
+
const goal = model.goal;
|
|
344
|
+
if (!goal) return undefined;
|
|
345
|
+
const passed = goal.checks.filter(check => check.status === "pass" || check.status === "accepted").length;
|
|
346
|
+
const parts = [`goal ${goal.phase === "drafting" ? "" : `${passed}/${goal.checks.length} · `}${PHASE[goal.phase].label}`,
|
|
347
|
+
...(goal.phase === "drafting" ? [] : [formatElapsed(goal.elapsedMs)])];
|
|
348
|
+
return { segs: [{ text: parts.join(" · "), role: goal.phase === "needs_you" ? "warning" : "muted" }], priority: 2, min: 8 };
|
|
349
|
+
}
|
|
350
|
+
|
|
273
351
|
function fileLine(file: FileStatus, w: number): Line {
|
|
274
352
|
const name = file.path.split("/").pop() || file.path;
|
|
275
353
|
const counts: Seg[] = file.edited && (file.added || file.removed)
|
|
@@ -307,3 +385,19 @@ export function wrap(text: string, width: number, lines: number): string[] {
|
|
|
307
385
|
kept[lines - 1] = `${displayWidth(last) + 1 <= w ? last : cells(last, w - 1).trimEnd()}…`;
|
|
308
386
|
return kept;
|
|
309
387
|
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The input box's top-edge label while no turn runs: the outcome in its own
|
|
391
|
+
* colour, then how long the last turn took (Codex "Worked for 2m 5s", Claude
|
|
392
|
+
* Code "Cooked for 1m 6s"). A plain grey "Turn finished" read the same as
|
|
393
|
+
* "nothing happened" (fix-finished-turn-status).
|
|
394
|
+
*/
|
|
395
|
+
export function idleActivityLabel(activity: NonNullable<StatusModel["activity"]>, turnMs?: number): Label {
|
|
396
|
+
const tone = activity.state === "done" ? { glyph: "✓", role: "success" as const }
|
|
397
|
+
: activity.state === "error" ? { glyph: "✗", role: "error" as const }
|
|
398
|
+
: activity.state === "blocked" ? { glyph: activity.label === "Stopped" ? "■" : "", role: "warning" as const }
|
|
399
|
+
: { glyph: "", role: "muted" as const };
|
|
400
|
+
const text = activity.label === "Stopped" && activity.detail ? `Stopped · ${activity.detail}` : activity.label;
|
|
401
|
+
return { segs: [...(tone.glyph ? [{ text: tone.glyph, role: tone.role }, { text: " ", role: "muted" as const }] : []), { text, role: tone.role },
|
|
402
|
+
...(turnMs !== undefined ? [{ text: ` · ${formatElapsed(turnMs)}`, role: "muted" as const }] : [])], priority: 1, min: 4, shrink: tone.glyph ? 2 : 0 };
|
|
403
|
+
}
|