@visual-z/xeno 0.8.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # xeno changelog
2
2
 
3
+ ## 0.8.1 — 2026-10-11
4
+
5
+ The sidebar now follows your goal:
6
+ - The Goal block appears only for `/goal`. It lists every accepted check — ✓ passed, ✗ failed with its exit code, ○ not yet — with `guard` or `/accept <id>` beside it, then the evidence ladder and what the goal has used: requests, and tokens in this run.
7
+ - The heading reads working, needs you, met or stopped, with how long the goal has been running.
8
+ - A met goal stays as one line until your next message.
9
+ - A request without a goal shows nothing, or one `Checks` line once it has changed files: not run since the last edit, or the latest check with ✓ or ✗ and its exit code. A declared acceptance contract shows how many of its criteria hold.
10
+ - While a goal's criteria are being drafted the sidebar shows `Goal · drafting` and your objective, not the drafting instructions.
11
+ - Without a sidebar (narrower than 110 columns) the goal sits on the input box's lower edge: `goal 2/3 · needs you · 4m 12s`.
12
+ - Context and Usage fit in five rows instead of up to eight. Both budgets keep their names and shares. The node number, the observation and retry counts, the tool count when nothing failed and the `/control` hint moved out; they remain in `/control` and `/state`.
13
+ - Generation speed: Usage and the input box show tokens per second for the current turn, measured as OpenCode does: output over the time its model requests took. Reasoning that a provider does not stream is never left out of the time, so the figure does not overstate. Requests shorter than 250 ms are not counted.
14
+ - Running time reads `59s`, `1m 05s`, `1h 02m 05s` instead of `3725s`, and so does an update's progress against its limit.
15
+
16
+ Fixes:
17
+ - A gateway that answers "about to call a tool" with no tool call no longer ends the request silently. The response is retried automatically; once retries run out it is reported as a provider error.
18
+ - The Anthropic SDK path now sends `xeno/<version>` as its User-Agent, as the other paths do, so gateways attribute its traffic to xeno instead of a custom client.
19
+
3
20
  ## 0.8.0 — 2026-10-10
4
21
 
5
22
  xeno now proves completion with receipts, and proves the receipts test the change. Two changes need attention when upgrading:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
@@ -55,6 +55,8 @@ import { sessionsDir } from "../../store/sessions.ts";
55
55
  import { readGitStatus } from "../../store/git-status.ts";
56
56
  import type { GitStatus } from "../../core/git-status.ts";
57
57
  import { activityStatus } from "../../core/activity-status.ts";
58
+ import { goalStatus } from "../../core/goal-view.ts";
59
+ import { generationSpeed } from "../../runtime/usage.ts";
58
60
  import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
59
61
  import { projectTrust } from "../../store/trust.ts";
60
62
  import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
@@ -161,6 +163,14 @@ export class InteractiveApp implements Ui {
161
163
  private gitRun: Promise<void> | undefined;
162
164
  private gitAgain = false;
163
165
  private attention: ControlSnapshot["waiting"] = null;
166
+ /** The objective of a /goal whose criteria are being drafted. */
167
+ private drafting: string | undefined;
168
+ /** Index of this turn's first usage sample; generation speed reads from here. */
169
+ private turnUsageStart = 0;
170
+ /** Where this process began counting a goal's tokens: usage samples are kept per process, not in the log. */
171
+ private goalUsage: { goal: string; start: number } | undefined;
172
+ /** The last goal view and what it was derived from; see goalAndChecks. */
173
+ private goalCache: { key: string; view: import("../../core/goal-view.ts").GoalStatus | null; goal?: string } | undefined;
164
174
  private lastOutcome: ExecutionOutcome | undefined;
165
175
  private paintTimer: ReturnType<typeof setTimeout> | undefined;
166
176
  /** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
@@ -594,21 +604,61 @@ export class InteractiveApp implements Ui {
594
604
  ...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
595
605
  ...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
596
606
  // Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
597
- cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null } } : {}),
607
+ cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null,
608
+ ...speedOf(app.session.state.usage.slice(this.turnUsageStart)) } } : {}),
598
609
  tools: { running: tools.filter(tool => tool.status === "pending").length, done: tools.filter(tool => tool.status === "ok").length, failed: tools.filter(tool => tool.status === "error").length },
599
610
  ...(this.derived ?? {}),
600
- ...(this.controlSnapshot?.goal ? { goal: { text: this.controlSnapshot.goal.text,
601
- status: this.controlSnapshot.goal.state.status,
602
- execution: this.inflight ? "running" : this.controlSnapshot.goal.state.execution?.reason ?? "idle",
603
- observations: this.controlSnapshot.goal.state.progress.observations.length,
604
- ...ladderView(this.controlSnapshot.goal.state),
605
- retries: this.controlSnapshot.goal.state.progress.unchanged_retries.length } } : {}),
611
+ ...this.goalAndChecks(),
606
612
  files: this.files,
607
613
  resources: { contextFiles: app.loadedContext.agents.map(file => basename(file.path)), skills: app.slash?.skills.size ?? 0, extensions: app.extensions?.loaded.length ?? 0 },
608
614
  ...(extension ? { extension } : {}),
609
615
  };
610
616
  }
611
617
 
618
+ /**
619
+ * The sidebar's evidence: an explicit goal's block, or -- without one -- a
620
+ * Checks line for a request that changed files or declared a contract
621
+ * (feat-sidebar-goal-and-speed). Both read the request ledger the control
622
+ * snapshot already derived. The goal view is a scan of the whole log, so it
623
+ * is cached per log length; only its clock is read on every paint.
624
+ */
625
+ private goalAndChecks(): Pick<StatusModel, "goal" | "checks"> {
626
+ const state = this.controlSnapshot?.goal?.state, session = this.app.session;
627
+ const key = `${this.app.logDir}:${session.state.node}:${this.ancestors.length}:${session.state.events.length}:${this.inflight}:${this.drafting ?? ""}:${state?.request.event ?? ""}:${state?.status ?? ""}`;
628
+ if (this.goalCache?.key !== key) {
629
+ const events = [...this.ancestors, ...session.state.events];
630
+ const set = events.findLast(event => event.t === "goal" && event.action === "set");
631
+ this.goalCache = { key, view: goalStatus(events, state, { running: this.inflight, ...(this.drafting !== undefined ? { drafting: this.drafting } : {}) }),
632
+ ...(set ? { goal: set.id } : {}) };
633
+ }
634
+ const view = this.goalCache.view;
635
+ if (view) {
636
+ // The session's own clock stamps the log, so a running goal's time is read on it too.
637
+ const now = Date.parse(session.clock.now());
638
+ const since = this.goalCache.goal ? session.state.usage.slice(this.goalUsageStart(this.goalCache.goal)) : [];
639
+ const tokens = since.reduce((total, sample) => total + sample.input_tokens + sample.cache_creation_input_tokens + sample.cache_read_input_tokens + sample.output_tokens, 0);
640
+ return { goal: { phase: view.phase, objective: view.objective, checks: view.checks, requests: view.requests,
641
+ elapsedMs: view.phase === "drafting" ? 0 : Math.max(0, (view.endedAt ?? now) - view.startedAt),
642
+ ...(state && view.phase !== "drafting" ? ladderView(state) : {}), ...(tokens ? { tokens } : {}) } };
643
+ }
644
+ if (state?.acceptance) {
645
+ // A declared contract without /goal: how many of its criteria hold; /acceptance lists them.
646
+ const items = state.acceptance.criteria, passed = items.filter(item => item.status === "pass" || item.status === "accepted").length;
647
+ return { checks: { status: passed === items.length ? "pass" : "partial", command: `${passed}/${items.length} passed · /acceptance` } };
648
+ }
649
+ if (!state?.changed) return {};
650
+ const fresh = state.criteria.find(item => item.kind === "fresh_check");
651
+ const run = fresh?.command ? state.commands.findLast(command => command.command === fresh.command) : state.commands.at(-1);
652
+ if (!fresh || fresh.status === "not_run" || !run) return { checks: { status: "unchecked" } };
653
+ return { checks: { status: fresh.status === "pass" ? "pass" : fresh.status === "stale" ? "stale" : "fail", command: run.command, exit: run.exit } };
654
+ }
655
+
656
+ /** Usage samples belong to this process; a goal's spend counts those recorded after this process first saw it. */
657
+ private goalUsageStart(goal: string): number {
658
+ if (this.goalUsage?.goal !== goal) this.goalUsage = { goal, start: this.app.session.state.usage.length };
659
+ return this.goalUsage.start;
660
+ }
661
+
612
662
  private writeTitle(title: string): void {
613
663
  if (title === this.writtenTitle || this.disposed) return;
614
664
  this.writtenTitle = title;
@@ -865,11 +915,13 @@ export class InteractiveApp implements Ui {
865
915
  */
866
916
  startGoal(objective: string): void {
867
917
  this.track(async () => {
918
+ // The draft request's own text is an instruction to the model, not the goal; the sidebar shows the objective.
919
+ this.drafting = objective;
868
920
  const pending = await draftGoal(this.app, objective, async (prompt, options) => {
869
921
  await this.runPromptTurn(prompt, [], undefined, options);
870
922
  // A cancelled draft is not a draft to accept, nor one to send back.
871
923
  return this.lastOutcome?.status !== "cancelled";
872
- });
924
+ }).finally(() => { this.drafting = undefined; this.paint(); });
873
925
  if (!pending) return;
874
926
  this.push({ kind: "list", title: `Accept the checks for: ${objective}`, multi: true, checked: pending.criteria.map(item => item.id), search: false,
875
927
  items: pending.criteria.map(item => ({ value: item.id, label: item.id, description: criterionSummary(item) })),
@@ -886,6 +938,8 @@ export class InteractiveApp implements Ui {
886
938
  this.abort = new AbortController();
887
939
  this.inflight = true;
888
940
  this.lastOutcome = undefined;
941
+ // Speed is per turn: only this turn's model requests count (feat-sidebar-goal-and-speed).
942
+ this.turnUsageStart = this.app.session.state.usage.length;
889
943
  this.programResult = false;
890
944
  this.program?.update(programStatus({ running: true }));
891
945
  this.executionKind = text.startsWith("!") ? "bash" : "model";
@@ -1140,7 +1194,7 @@ export class InteractiveApp implements Ui {
1140
1194
  const detachInput = control.attachInput(phase => this.pullQueued(phase));
1141
1195
  const unsubscribe = control.subscribe(notice => {
1142
1196
  if (this.disposed) return;
1143
- if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
1197
+ if (notice.method === "turn/started") { this.lastOutcome = undefined; this.turnUsageStart = this.app.session.state.usage.length; this.program?.update(programStatus({ running: true })); }
1144
1198
  if (notice.method === "turn/error") {
1145
1199
  this.lastOutcome = executionOutcome("execution_error");
1146
1200
  this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
@@ -1623,6 +1677,12 @@ function projectWithStart(items: ReturnType<typeof projectItems>, clock: string
1623
1677
  }
1624
1678
 
1625
1679
  /** "~/Projects/workspace/xeno-tui" → "~/P/w/xeno-tui": every directory but the last cut to its first letter. */
1680
+ /** The turn's speed when one can be measured; nothing otherwise, so the row never shows a made-up figure. */
1681
+ function speedOf(samples: readonly import("../../runtime/usage.ts").UsageSample[]): { speed?: number } {
1682
+ const speed = generationSpeed(samples);
1683
+ return speed === undefined ? {} : { speed };
1684
+ }
1685
+
1626
1686
  export function shortPath(path: string): string {
1627
1687
  const parts = path.split("/");
1628
1688
  return parts.map((part, i) => i === parts.length - 1 || part === "~" || part === "" ? part : part.startsWith(".") ? part.slice(0, 2) : part.slice(0, 1)).join("/");
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What the sidebar shows about an explicit goal (feat-sidebar-goal-and-speed),
3
+ * derived from the log alone so it reads the same after a restart.
4
+ *
5
+ * The block exists only while a /goal is set, and for one message after it is
6
+ * met. Its verdicts are the request ledger's (D11); this only chooses words:
7
+ * the phase answers "do I need to act, and is it still running", and each
8
+ * accepted check says what it needs from the user.
9
+ */
10
+ import type { LogEvent } from "./events.ts";
11
+ import type { RequestCriterion, RequestState } from "./request-state.ts";
12
+
13
+ export type GoalPhase = "drafting" | "working" | "needs_you" | "met" | "stopped";
14
+ export type GoalCheck = { id: string; kind: RequestCriterion["kind"]; status: RequestCriterion["status"]; guard?: true; exit?: number };
15
+ /**
16
+ * `startedAt` is when the goal was set; `endedAt` when it was met, absent while
17
+ * it runs. Times, not a duration, so a caller can cache this per log length and
18
+ * read the clock only when it paints.
19
+ */
20
+ export type GoalStatus = { phase: GoalPhase; objective: string; checks: GoalCheck[]; startedAt: number; endedAt?: number; requests: number };
21
+
22
+ /**
23
+ * The goal block for the end of an ancestry, or null when no goal applies.
24
+ * `running` is whether a request is in flight now; `drafting` is the objective
25
+ * of a /goal whose criteria are still being drafted.
26
+ */
27
+ export function goalStatus(events: readonly LogEvent[], state: RequestState | undefined,
28
+ live: { running: boolean; drafting?: string }): GoalStatus | null {
29
+ if (live.drafting !== undefined) return { phase: "drafting", objective: live.drafting, checks: [], startedAt: 0, requests: 0 };
30
+ const lastSet = events.findLastIndex(event => event.t === "goal" && event.action === "set");
31
+ if (lastSet < 0) return null;
32
+ const set = events[lastSet]!;
33
+ if (set.t !== "goal" || set.action !== "set") return null;
34
+ const after = events.slice(lastSet + 1);
35
+ // The last clear decides: a met goal the user then cleared is gone like any other cleared goal.
36
+ const clear = after.findLastIndex(event => event.t === "goal" && event.action === "clear");
37
+ const cleared = clear < 0 ? undefined : after[clear];
38
+ // A goal the user cleared is gone; a met goal stays for one summary, until the next message opens a new request.
39
+ if (cleared?.t === "goal" && cleared.action === "clear" && cleared.reason !== "met") return null;
40
+ const recorded = cleared !== undefined;
41
+ if (recorded && after.slice(clear + 1).some(event => event.t === "user" && event.request === undefined)) return null;
42
+ // Only the goal's own request carries its contract; the ledger of any other request says nothing about it.
43
+ const own = state?.acceptance?.contract_id === set.contract.id && state.acceptance.contract_event !== undefined
44
+ && events.some((event, index) => index > lastSet && event.id === state.acceptance!.contract_event) ? state : undefined;
45
+ // The ledger is the verdict (D11): verified is met even before the clear is recorded -- an /accept outside a
46
+ // request, a log written before /accept recorded it -- and its clock stops at the last recorded fact.
47
+ const met = recorded || own?.status === "verified";
48
+ const end = recorded ? Date.parse(cleared.at) : met ? Date.parse(after.at(-1)?.at ?? set.at) : undefined;
49
+ const checks: GoalCheck[] = set.contract.criteria.map(item => {
50
+ const entry = own?.criteria.find(criterion => criterion.id === item.id);
51
+ const run = item.kind === "command" ? own?.commands.findLast(command => command.command === item.command) : undefined;
52
+ return { id: item.id, kind: item.kind, status: entry?.status ?? (item.kind === "human" ? "awaiting_human" : "not_run"),
53
+ ...(item.kind === "command" && item.guard ? { guard: true as const } : {}),
54
+ ...(entry?.status === "fail" && run ? { exit: run.exit } : {}) };
55
+ });
56
+ return { phase: met ? "met" : live.running ? "working" : own?.status === "awaiting_human" ? "needs_you" : "stopped",
57
+ objective: set.objective, checks, startedAt: Date.parse(set.at), ...(end === undefined ? {} : { endedAt: end }),
58
+ requests: (recorded ? after.slice(0, clear) : after).filter(event => event.t === "assistant").length };
59
+ }
60
+
61
+ /** The words the sidebar and the input edge use for a phase. */
62
+ export function goalPhaseLabel(phase: GoalPhase): string {
63
+ return phase === "needs_you" ? "needs you" : phase;
64
+ }
@@ -1,5 +1,5 @@
1
1
  /** N.12: a returned response is durable evidence, not necessarily a completed action. */
2
- import type { GuidanceEvent, LogEvent } from "./events.ts";
2
+ import type { AssistantBlock, GuidanceEvent, LogEvent } from "./events.ts";
3
3
  import { currentRequestEvents } from "./request-state.ts";
4
4
 
5
5
  // One appended attempt is an operational assumption, not a measured optimum.
@@ -15,6 +15,18 @@ export function isCompleteResponse(reason: string | null | undefined): boolean {
15
15
  return reason !== undefined && reason !== null && ["stop", "end_turn", "stop_sequence", "tool_calls", "tool_use"].includes(reason);
16
16
  }
17
17
 
18
+ /**
19
+ * A stop that asks for tool execution must carry a tool call. One that does not
20
+ * is a malformed provider response, not the model's answer: taken as a final
21
+ * reply it ends the request with outcome unknown and nothing on screen
22
+ * (session mv28chr8-oc3x, an Anthropic-compatible gateway returning
23
+ * `stop_reason: tool_use` with no content). Adapters reject it inside their
24
+ * retry so the loop only sees a coherent turn or a definite failure.
25
+ */
26
+ export function isToolStopWithoutCall(stopReason: string | null | undefined, blocks: readonly AssistantBlock[]): boolean {
27
+ return (stopReason === "tool_use" || stopReason === "tool_calls") && !blocks.some(block => block.type === "tool_use");
28
+ }
29
+
18
30
  export function isOutputLimit(reason: string | null | undefined): boolean {
19
31
  return reason === "length" || reason === "max_tokens";
20
32
  }
@@ -23,7 +23,7 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
23
23
  import { imageData } from "./images.ts";
24
24
  import type { AssistantBlock } from "../core/events.ts";
25
25
  import { assertNever } from "../core/exhaustive.ts";
26
- import type { Provider, ProviderConfig, Turn, ToolSchema, OnDelta } from "./provider.ts";
26
+ import { coherentTurn, type Provider, type ProviderConfig, type Turn, type ToolSchema, type OnDelta } from "./provider.ts";
27
27
  import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
28
28
  import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
29
29
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
@@ -502,7 +502,7 @@ export class OpenAICompletionsProvider implements Provider {
502
502
  }
503
503
 
504
504
  const assembled = await readCompletionsSse(response.body, onDelta);
505
- return parseResponse(assembled, node, Date.now() - started);
505
+ return coherentTurn(parseResponse(assembled, node, Date.now() - started), assembled.usage !== undefined);
506
506
  }
507
507
  }
508
508
 
@@ -20,7 +20,7 @@ import { providerRequest } from "./provider-request.ts";
20
20
  import { grokVersionHint } from "./oauth.ts";
21
21
  import type { ResolveEffects } from "./auth.ts";
22
22
  import { imageData } from "./images.ts";
23
- import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
23
+ import { coherentTurn, FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
24
24
  import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
25
25
  import { HttpError, StreamError } from "./openai.ts";
26
26
  import type { UsageSample } from "./usage.ts";
@@ -392,7 +392,7 @@ export class PiProvider implements Provider {
392
392
  if (identityErrors.length) throw new ProviderIntegrityError(identityErrors, hasKnownUsage ? usage : undefined);
393
393
  // The SDK has already classified native STOP/end_turn/toolUse/length values. The loop consumes
394
394
  // xeno's closed completion vocabulary, never a raw native spelling that could skip tool execution.
395
- return { blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
396
- usage };
395
+ return coherentTurn({ blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
396
+ usage }, hasKnownUsage);
397
397
  }
398
398
  }
@@ -35,10 +35,12 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
35
35
  import { imageData } from "./images.ts";
36
36
  import type { AssistantBlock } from "../core/events.ts";
37
37
  import { assertNever } from "../core/exhaustive.ts";
38
+ import { isToolStopWithoutCall } from "../core/response-recovery.ts";
38
39
  import type { UsageSample } from "./usage.ts";
39
40
  import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
40
41
  import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
41
42
  import { anthropicOAuthHeaders } from "./oauth.ts";
43
+ import { XENO_VERSION } from "./version.ts";
42
44
  import type { ProviderSendOptions } from "./provider-tool-policy.ts";
43
45
 
44
46
  export interface ToolSchema {
@@ -159,6 +161,27 @@ export class FailedResponseError extends Error {
159
161
  }
160
162
  }
161
163
 
164
+ /**
165
+ * A 200 response that contradicts itself, such as a tool stop with no tool
166
+ * call. retry.ts treats it like a transient stream failure: a fresh sample is
167
+ * the remedy, and exhausting retries must surface as a provider error rather
168
+ * than reach the loop as a silent final answer. The output token count tells
169
+ * "generated but lost in transit" apart from "nothing generated".
170
+ */
171
+ export class MalformedResponseError extends FailedResponseError {
172
+ constructor(detail: string, usage?: UsageSample) {
173
+ super(`malformed provider response: ${detail}${usage ? ` (${usage.output_tokens} output tokens)` : ""}`, usage);
174
+ this.name = "MalformedResponseError";
175
+ }
176
+ }
177
+
178
+ /** Every adapter returns through this, so no dialect can hand the loop a contradictory turn. */
179
+ export function coherentTurn(turn: Turn, usageKnown = true): Turn {
180
+ if (isToolStopWithoutCall(turn.stopReason, turn.blocks))
181
+ throw new MalformedResponseError(`stop_reason ${JSON.stringify(turn.stopReason)} without a tool call`, usageKnown ? turn.usage : undefined);
182
+ return turn;
183
+ }
184
+
162
185
  /** Thrown for a refusal, which arrives as HTTP 200 rather than an error. */
163
186
  export class RefusalError extends FailedResponseError {
164
187
  readonly category: string | null;
@@ -176,6 +199,12 @@ function buildAnthropicClient(
176
199
  apiKey: string | undefined,
177
200
  options: { fetch?: typeof fetch; oauth?: boolean; baseUrl?: string },
178
201
  ): AnthropicTransport {
202
+ // D18: the User-Agent, not SDK headers, identifies xeno on the wire. Left alone
203
+ // the SDK sends `Anthropic/JS <version>`, which model gateways fold into "custom
204
+ // client" attribution; provider-request.ts already stamps xeno's UA on the Pi
205
+ // path. Constructor defaultHeaders merge after the SDK's built-in UA, so this
206
+ // entry wins without touching per-request headers.
207
+ const identity = { "User-Agent": `xeno/${XENO_VERSION}` };
179
208
  if (options.oauth === true && apiKey) {
180
209
  // authToken is Bearer. Passing apiKey here would send x-api-key, which
181
210
  // an OAuth token is not accepted as.
@@ -183,7 +212,7 @@ function buildAnthropicClient(
183
212
  maxRetries: 0,
184
213
  ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
185
214
  authToken: apiKey,
186
- defaultHeaders: anthropicOAuthHeaders(),
215
+ defaultHeaders: { ...anthropicOAuthHeaders(), ...identity },
187
216
  ...(options.fetch ? { fetch: options.fetch } : {}),
188
217
  });
189
218
  }
@@ -191,6 +220,7 @@ function buildAnthropicClient(
191
220
  maxRetries: 0,
192
221
  ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
193
222
  ...(apiKey ? { apiKey } : {}),
223
+ defaultHeaders: identity,
194
224
  ...(options.fetch ? { fetch: options.fetch } : {}),
195
225
  });
196
226
  }
@@ -319,11 +349,11 @@ export class AnthropicProvider implements Provider {
319
349
  throw new RefusalError(details?.category ?? null, details?.explanation ?? null, assembled.usageKnown ? usage : undefined);
320
350
  }
321
351
 
322
- return {
352
+ return coherentTurn({
323
353
  blocks: assembled.blocks,
324
354
  stopReason: assembled.stopReason,
325
355
  usage,
326
- };
356
+ }, assembled.usageKnown);
327
357
  }
328
358
  }
329
359
 
@@ -33,6 +33,11 @@ function isStreamFailure(error: unknown): error is Error & { code?: string } {
33
33
  return error instanceof Error && error.name === "StreamError";
34
34
  }
35
35
 
36
+ /** provider.ts MalformedResponseError: a self-contradictory 200, structural for the same reason. */
37
+ function isMalformedResponse(error: unknown): boolean {
38
+ return error instanceof Error && error.name === "MalformedResponseError";
39
+ }
40
+
36
41
  export interface RetryPolicy {
37
42
  /**
38
43
  * Attempts after the first. Four means a request can take about 15s of
@@ -140,8 +145,10 @@ export function retryDecision(
140
145
  // A failure reported inside a 200 stream carries no status. Relays and
141
146
  // Anthropic's own `overloaded_error` use it for exactly the conditions a
142
147
  // 5xx would signal; anything else in a stream is a real rejection.
143
- if (status === undefined && isStreamFailure(error)) {
144
- if (!TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
148
+ if (status === undefined && (isStreamFailure(error) || isMalformedResponse(error))) {
149
+ // A contradictory response is a bad sample, not a rejection of the request:
150
+ // resend it like a dropped stream, and let the bound turn it into an error.
151
+ if (isStreamFailure(error) && !TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
145
152
  return { retry: false, reason: "the stream reported a non-transient error" };
146
153
  }
147
154
  const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
@@ -88,6 +88,34 @@ export function totalUsage(samples: readonly UsageSample[]): UsageTotals {
88
88
  return t;
89
89
  }
90
90
 
91
+ /**
92
+ * Below this a request's speed is not shown: a response that returns at once
93
+ * (a test double, a local cache) would divide into thousands of tokens per
94
+ * second. Assumed, not measured.
95
+ */
96
+ export const SPEED_MIN_MS = 250;
97
+
98
+ /**
99
+ * Output tokens per second over a set of model requests, or undefined when
100
+ * none can be measured (feat-sidebar-goal-and-speed). OpenCode's built-in
101
+ * turnTokensPerSecond uses the same definition: output over the time the
102
+ * requests took, each request counted once. Not output over the time since
103
+ * the first visible output -- output counts include reasoning that providers
104
+ * do not always stream (OpenAI summaries arrive late, Anthropic can omit
105
+ * them), so that would keep reasoning tokens and drop reasoning time, and
106
+ * overstate. Whole request time never overstates; an overloaded upstream
107
+ * reads slow, which is what the user is waiting on.
108
+ */
109
+ export function generationSpeed(samples: readonly UsageSample[]): number | undefined {
110
+ let tokens = 0, ms = 0;
111
+ for (const sample of samples) {
112
+ if (sample.output_tokens <= 0 || !(sample.ms >= SPEED_MIN_MS)) continue;
113
+ tokens += sample.output_tokens;
114
+ ms += sample.ms;
115
+ }
116
+ return ms > 0 ? tokens / (ms / 1000) : undefined;
117
+ }
118
+
91
119
  /**
92
120
  * Cost in equivalent base-input tokens, the unit appendix A's model uses.
93
121
  *
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Elapsed time as people read it (feat-sidebar-goal-and-speed): `59s`, then
3
+ * `1m 00s`, then `1h 01m 01s`. The steps are Codex's fmt_elapsed_compact; a
4
+ * running turn used to read `3725s`.
5
+ *
6
+ * Every field after the first is two digits, so a running clock keeps its
7
+ * width within a unit instead of jittering the labels beside it. Parts of a
8
+ * second never round up: the clock shows seconds that have fully passed.
9
+ */
10
+ export function formatElapsed(ms: number): string {
11
+ const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
12
+ if (total < 60) return `${total}s`;
13
+ const seconds = String(total % 60).padStart(2, "0");
14
+ if (total < 3600) return `${Math.floor(total / 60)}m ${seconds}s`;
15
+ return `${Math.floor(total / 3600)}h ${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}m ${seconds}s`;
16
+ }
@@ -48,7 +48,7 @@ export function screenLayout(cols: number, rows: number, sidebarWanted = true):
48
48
  return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
49
49
  }
50
50
 
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;
51
+ export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "goal", "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
54
  export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
@@ -61,13 +61,13 @@ export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
61
61
  export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
62
62
  switch (tier) {
63
63
  case "sidebar":
64
- return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", 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", goal: "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 { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", 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", goal: "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 { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", 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", goal: "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
  }
@@ -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, 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
 
@@ -376,11 +377,14 @@ export class Screen {
376
377
  // to recover on the existing edge, without putting control content in it.
377
378
  left = [{ segs: [{ text: "Resize for control", role: "accent" }], priority: 0, min: 1 }];
378
379
  } else if (d.busy) {
379
- const seconds = Math.max(0, Math.floor((Date.now() - this.busySince) / 1000));
380
+ const elapsed = formatElapsed(Date.now() - this.busySince);
380
381
  const activity = this.status?.activity;
381
382
  const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
382
383
  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: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
384
+ 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 }];
385
+ // The turn's speed so far, refreshed after each model request (runtime/usage.ts generationSpeed).
386
+ const speed = this.status?.usage?.speed;
387
+ if (speed !== undefined) right.push({ segs: [{ text: speedText(speed), role: "muted" }], priority: 3 });
384
388
  right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
385
389
  } else if (this.transientActivity) {
386
390
  left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${this.transientActivityText()}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
@@ -396,8 +400,10 @@ export class Screen {
396
400
  { segs: [{ text: d.model, role: "text" as const, bold: true }], priority: 1, min: 8 },
397
401
  ...(d.thinking ? [{ segs: [{ text: d.thinking, role: "muted" as const }], priority: 2 }] : []),
398
402
  ] : [])];
403
+ // Without a sidebar the goal and the context share ride on this edge (layout.ts placement).
399
404
  const context = this.shape.tier !== "sidebar" && this.status ? contextLabel(this.status) : undefined;
400
- this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: context ? [context] : [] })]);
405
+ const goal = this.shape.tier !== "sidebar" && this.status ? goalLabel(this.status) : undefined;
406
+ this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: [...(goal ? [goal] : []), ...(context ? [context] : [])] })]);
401
407
  }
402
408
 
403
409
  /** A stream event arrived (delta, tool progress, shell output): the spinner returns to full speed. */
@@ -426,9 +432,9 @@ export class Screen {
426
432
 
427
433
  private transientActivityText(): string {
428
434
  const activity = this.transientActivity!;
429
- const elapsed = Math.max(0, Math.floor((Date.now() - activity.startedAt) / 1000));
430
- const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
431
- return `${activity.label} · ${elapsed}s / ${deadline}s max${activity.detail ? ` · ${activity.detail}` : ""}`;
435
+ // The limit is a whole number of seconds, rounded up so it is never shown shorter than it is.
436
+ const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000)) * 1000;
437
+ return `${activity.label} · ${formatElapsed(Date.now() - activity.startedAt)} / ${formatElapsed(deadline)} max${activity.detail ? ` · ${activity.detail}` : ""}`;
432
438
  }
433
439
 
434
440
  /** The spinner's current interval (tests). */
@@ -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
- readonly goal?: { readonly text: string; readonly status: string; readonly execution: string; readonly observations: number; readonly retries: number;
34
- /** S.1: one glyph per rung (ran, tests the change, independent, human) and the reading of them, prepared by apps. */
35
- readonly ladder?: { readonly glyphs: string; readonly summary: string } };
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
- readonly usage?: { readonly input: number; readonly output: number; readonly cacheRate: number; readonly cost: string | null };
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 }], shareText ? [{ text: context.trigger === null ? "model " : "seg ", role: "muted" }, shareText] : []),
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: `Segment ${est}${compact(context.used)}${context.trigger !== null ? ` / ${est}${compact(context.trigger)}` : " · cut disabled"}`, role: "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
- ...(usage ? [[{ text: `↑${compact(usage.input)} ↓${compact(usage.output)}`, role: "text" }, { text: ` cache ${Math.round(usage.cacheRate * 100)}%`, role: "muted" }] as Line] : []),
235
- ...(toolCount ? [[
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)