@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 +17 -0
- package/package.json +1 -1
- package/src/apps/ui/app.ts +69 -9
- package/src/core/goal-view.ts +64 -0
- package/src/core/response-recovery.ts +13 -1
- 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/usage.ts +28 -0
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +13 -7
- package/src/tui/ui/status.ts +99 -21
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
package/src/apps/ui/app.ts
CHANGED
|
@@ -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
|
-
...
|
|
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
|
}
|
package/src/runtime/openai.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/runtime/provider.ts
CHANGED
|
@@ -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
|
|
package/src/runtime/retry.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/runtime/usage.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tui/ui/layout.ts
CHANGED
|
@@ -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
|
}
|
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, 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
|
|
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: ` ${
|
|
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.
|
|
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
|
-
|
|
430
|
-
const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
|
|
431
|
-
return `${activity.label} · ${
|
|
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). */
|
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)
|