@visual-z/xeno 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +13 -2
- package/docs/providers.md +1 -1
- package/package.json +1 -1
- package/src/apps/provider-connect.ts +3 -3
- package/src/apps/session-hub.ts +12 -1
- package/src/apps/shell.ts +1 -1
- package/src/apps/ui/app.ts +81 -19
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/update-check.ts +21 -10
- package/src/core/activity-status.ts +28 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/progress.ts +9 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/images.ts +6 -2
- package/src/runtime/loop.ts +56 -11
- package/src/runtime/oauth.ts +19 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/pi-provider.ts +11 -5
- package/src/runtime/provider-presets.ts +11 -0
- package/src/runtime/provider-request.ts +3 -1
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +6 -1
- package/src/runtime/session-control.ts +1 -0
- package/src/runtime/tools.ts +7 -3
- package/src/store/blobs.ts +2 -1
- package/src/store/git-status.ts +15 -0
- package/src/tui/ui/dialog.ts +24 -5
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +30 -9
- package/src/tui/ui/status.ts +32 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# xeno changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
- Keep a dialog form editable after a validation error. A rejected provider id no longer destroys the input field (`EditBuffer is destroyed`), and the error suggests a valid id.
|
|
6
|
+
- Send the Grok proxy's version-gate header so Grok account inference no longer fails with HTTP 426. A future 426 explains how a xeno user recovers.
|
|
7
|
+
- Show progress, elapsed time and a 3-second limit for a manual `/update` check, with failures styled as errors.
|
|
8
|
+
|
|
9
|
+
## 0.7.0 — 2026-10-09
|
|
10
|
+
|
|
11
|
+
- Keep fullscreen exit output bounded to the execution state and resume command. Preserve full transcript export as an explicit choice.
|
|
12
|
+
- Wait for actual process output, exit or the configured timeout when the agent owns an empty-input process wait. Preserve steering, cancellation and process receipts without spending model requests on empty polling.
|
|
13
|
+
- Reserve the last available model request for a response with tools disabled, and report unfinished work when the budget is exhausted. Track cumulative unproductive activity separately from consecutive failures.
|
|
14
|
+
- Share in-flight session discovery so concurrent cold resumes resolve aliases to one execution owner.
|
|
15
|
+
- Show provider, approval/YOLO, Build/Plan, running/waiting/queued state, model budgets and Git status from their current owners. Label model request time explicitly.
|
|
16
|
+
- Preserve screenshot detail with a 4096-pixel long edge and an 8 Mi-pixel default limit, preflight decoding, bounded image caching and visible load errors.
|
|
17
|
+
|
|
3
18
|
## 0.6.0 — 2026-10-09
|
|
4
19
|
|
|
5
20
|
- Add a foundation tool profile and Build/Plan agent roles, structured questions, resource and literal command-prefix permissions. Keep the ten-tool coding default and explicit tool selections intact across cold resumes.
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ is ever summarized by a model, and nothing is ever rewritten in place.
|
|
|
17
17
|
messages[] frozen system prefix + handoff + this node's events
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Version **0.
|
|
20
|
+
Version **0.7.1**. The on-disk session format will still change; `0.x` is that
|
|
21
21
|
statement. Published as `@visual-z/xeno`; the license is `UNLICENSED` (readable and installable, no reuse granted).
|
|
22
22
|
|
|
23
23
|
## Install
|
|
@@ -497,9 +497,20 @@ PgUp/PgDn scroll, and Ctrl+Home/End jump to the start or latest messages.
|
|
|
497
497
|
Ctrl+O expands tools, Ctrl+T expands thinking, and Ctrl+B toggles the sidebar.
|
|
498
498
|
Mouse selection copies plain text when `fullscreenCopyOnSelect` is enabled;
|
|
499
499
|
Ctrl+X copies a selection or the last reply. Exiting restores the terminal and
|
|
500
|
-
prints
|
|
500
|
+
prints a bounded execution status and a resume command. Set
|
|
501
|
+
`fullscreenExitOutput` to `transcript` in `/settings` for full replay on exit.
|
|
502
|
+
The composer stays at the bottom
|
|
501
503
|
and grows with the draft; pickers and forms use the same input area.
|
|
502
504
|
|
|
505
|
+
An empty `write_stdin` in an agent session waits for new output, command exit,
|
|
506
|
+
hard timeout, cancellation or user steering. The process owner holds that wait;
|
|
507
|
+
quiet commands do not consume model requests through periodic polling.
|
|
508
|
+
Sending input and low-level process polling keep their bounded yield contract.
|
|
509
|
+
For `--max-turns N`, budgets above one reserve the last request for a text answer.
|
|
510
|
+
Tools stay frozen and are disabled for that request, with host enforcement.
|
|
511
|
+
Unsupported protocols or a failed final response use an evidence-based stop
|
|
512
|
+
report inside the same budget. `/usage` labels provider durations as model time.
|
|
513
|
+
|
|
503
514
|
`/settings theme` previews built-in and custom themes from the config directory's
|
|
504
515
|
`themes/`. Escape restores the prior theme. Custom files reload after valid edits;
|
|
505
516
|
`auto` follows terminal light/dark changes. Built-in themes keep the terminal's
|
package/docs/providers.md
CHANGED
|
@@ -25,7 +25,7 @@ xeno 内置 Pi 0.99.1 和 OpenCode 所用 Models.dev 的固定源并集,另加
|
|
|
25
25
|
|
|
26
26
|
账户存为 `auth.json` 的 `xai-grok`(0600),模型身份为 `xai-grok/grok-build`,请求走 `https://cli-chat-proxy.grok.com/v1`。`xai` 仍表示 `XAI_API_KEY` 与 `https://api.x.ai/v1`;两个身份不共享凭据或计费账户。也可显式提供 `GROK_ACCESS_TOKEN`,但环境 token 没有 refresh token,不能自动刷新。账户代理地址不能覆盖到其它服务。
|
|
27
27
|
|
|
28
|
-
采用官方 RFC 8628 设备授权(`https://auth.x.ai/oauth2/device/code` 与 `/oauth2/token`);只申请推理及刷新所需 scopes,`referrer` 与 User-Agent 标识 xeno。协议所需 `X-XAI-Token-Auth`
|
|
28
|
+
采用官方 RFC 8628 设备授权(`https://auth.x.ai/oauth2/device/code` 与 `/oauth2/token`);只申请推理及刷新所需 scopes,`referrer` 与 User-Agent 标识 xeno。协议所需 `X-XAI-Token-Auth`、模型路由头与版本闸门头 `x-grok-client-version`(固定为 1.0.46,`x-grok-client-identifier: xeno`)遵循官方代理合同;代理最低版本越过它时返回 426,xeno 会提示更新 xeno 或暂用 `xai` + `XAI_API_KEY`。依据:[Grok Build 企业认证](https://docs.x.ai/build/enterprise)、[官方授权源码](https://github.com/xai-org/grok-build/tree/main/crates/codegen/xai-grok-login/src)、[官方代理调用示例](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/README.md)。
|
|
29
29
|
|
|
30
30
|
`grok-build` 是官方代理别名,不能把 API 模型的价格、上下文长度或可选 effort 套给它。当前保守暴露文本/工具调用,价格未知、effort 选择关闭;实际账户权限由官方服务决定。设备授权启动已实测返回 200,授权后的推理/刷新有受控 HTTP 验证;没有替用户完成真实账户批准或调用付费模型。
|
|
31
31
|
|
package/package.json
CHANGED
|
@@ -19,7 +19,7 @@ import type { ModelSpec } from "../runtime/model.ts";
|
|
|
19
19
|
import { discoverModels, discoveryFamily, likelyChatModel, normalizeBaseUrl, type DiscoveredModel } from "../runtime/model-discovery.ts";
|
|
20
20
|
import { SOURCE_PROVIDERS } from "../runtime/model-data.ts";
|
|
21
21
|
import { applyConnect, applyDisconnect, catalogTwin } from "../runtime/provider-connect.ts";
|
|
22
|
-
import { CUSTOM_PRESETS, presetById, suggestProviderId, validProviderId, type ProviderPreset } from "../runtime/provider-presets.ts";
|
|
22
|
+
import { CUSTOM_PRESETS, presetById, slugProviderId, suggestProviderId, validProviderId, type ProviderPreset } from "../runtime/provider-presets.ts";
|
|
23
23
|
import { providerAccess } from "../runtime/provider-access.ts";
|
|
24
24
|
import { writeAuthFile } from "../store/auth-store.ts";
|
|
25
25
|
import { authPath } from "../store/config.ts";
|
|
@@ -154,8 +154,8 @@ function beginPreset(host: ConnectHost, preset: ProviderPreset): void {
|
|
|
154
154
|
title: `${preset.label} · name`,
|
|
155
155
|
label: "Provider ID",
|
|
156
156
|
value: suggested,
|
|
157
|
-
hint: "shown in /model as <id>/<model>; lowercase
|
|
158
|
-
validate: id => !validProviderId(id) ?
|
|
157
|
+
hint: "shown in /model as <id>/<model>; lowercase a-z, digits, - and _",
|
|
158
|
+
validate: id => !validProviderId(id) ? `Use lowercase a-z, digits, - and _ (no spaces, capitals or non-Latin letters); try "${slugProviderId(id) || suggested}"`
|
|
159
159
|
: SOURCE_PROVIDERS[id] !== undefined ? `"${id}" is a built-in provider; pick another id or choose it from the list` : undefined,
|
|
160
160
|
onSubmit: id => {
|
|
161
161
|
draft.id = id;
|
package/src/apps/session-hub.ts
CHANGED
|
@@ -15,6 +15,17 @@ export function createAppSessionHub(initial: App): SessionHub {
|
|
|
15
15
|
// Aliases resolve to one durable log before construction: two cold resumes
|
|
16
16
|
// must never append through independent Session owners (D5/D6).
|
|
17
17
|
const opening = new Map<string, { result: Promise<SessionControl>; configuration: string }>();
|
|
18
|
+
let lookup: ReturnType<typeof listSessions> | undefined;
|
|
19
|
+
// Q.26: cold aliases await one scan in ingress order before claiming their
|
|
20
|
+
// canonical owner. Keep only in-flight I/O; construction and live reloads stay independent.
|
|
21
|
+
const lookupSessions = (): ReturnType<typeof listSessions> => {
|
|
22
|
+
if (lookup) return lookup;
|
|
23
|
+
const current = listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace));
|
|
24
|
+
lookup = current;
|
|
25
|
+
const release = () => { if (lookup === current) lookup = undefined; };
|
|
26
|
+
void current.then(release, release);
|
|
27
|
+
return current;
|
|
28
|
+
};
|
|
18
29
|
return new SessionHub(sessionControl(initial.session), {
|
|
19
30
|
list: async () => ({ ...await listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace)), live: [...apps.keys()] }),
|
|
20
31
|
open: async (kind, params) => {
|
|
@@ -31,7 +42,7 @@ export function createAppSessionHub(initial: App): SessionHub {
|
|
|
31
42
|
});
|
|
32
43
|
return sessionControl(live.session);
|
|
33
44
|
}
|
|
34
|
-
const found = (await
|
|
45
|
+
const found = (await lookupSessions()).sessions
|
|
35
46
|
.find(item => item.ok && (createHash("sha256").update(item.path).digest("hex") === requested || item.path === requested || item.id === requested));
|
|
36
47
|
if (!live && !found) throw Error("session must belong to this app-server workspace");
|
|
37
48
|
source = live?.logDir ?? found!.path;
|
package/src/apps/shell.ts
CHANGED
|
@@ -739,7 +739,7 @@ function printUsage(app: App): void {
|
|
|
739
739
|
} else {
|
|
740
740
|
return assertNever(dollars, "printUsage");
|
|
741
741
|
}
|
|
742
|
-
app.io.say(`
|
|
742
|
+
app.io.say(` model time ${(t.ms / 1000).toFixed(1)}s`);
|
|
743
743
|
app.io.say("");
|
|
744
744
|
}
|
|
745
745
|
|
package/src/apps/ui/app.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settin
|
|
|
29
29
|
import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
|
|
30
30
|
import { builtinTheme } from "../../core/theme.ts";
|
|
31
31
|
import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
|
|
32
|
+
import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, sessionExitStatus } from "../../core/session-exit.ts";
|
|
32
33
|
import { lastAssistantText } from "../../core/export.ts";
|
|
33
34
|
import type { PermissionRequest } from "../../core/permissions.ts";
|
|
34
35
|
import type { ApprovalChoice } from "../../runtime/tools.ts";
|
|
@@ -44,7 +45,10 @@ import { isUpgrade } from "../../core/version.ts";
|
|
|
44
45
|
import type { Effort } from "../../runtime/model.ts";
|
|
45
46
|
import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
|
|
46
47
|
import { readNodeHistory } from "../../store/restore.ts";
|
|
47
|
-
import {
|
|
48
|
+
import { readGitStatus } from "../../store/git-status.ts";
|
|
49
|
+
import type { GitStatus } from "../../core/git-status.ts";
|
|
50
|
+
import { activityStatus } from "../../core/activity-status.ts";
|
|
51
|
+
import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
|
|
48
52
|
import { projectTrust } from "../../store/trust.ts";
|
|
49
53
|
import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
|
|
50
54
|
import { nextResourceOverride } from "../../core/resource-selection.ts";
|
|
@@ -107,6 +111,11 @@ function samePalette(a: TerminalPalette, b: TerminalPalette | null): boolean {
|
|
|
107
111
|
return b !== null && a.foreground === b.foreground && a.background === b.background && a.slots.every((slot, i) => slot === b.slots[i]);
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
/** Only the endpoint host is chrome; credentials, query and path stay out of it. */
|
|
115
|
+
function endpointHost(baseUrl: string | undefined): string | null {
|
|
116
|
+
try { return baseUrl ? new URL(baseUrl).host : null; } catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
110
119
|
export class InteractiveApp implements Ui {
|
|
111
120
|
readonly app: App;
|
|
112
121
|
readonly screen: Screen;
|
|
@@ -141,6 +150,11 @@ export class InteractiveApp implements Ui {
|
|
|
141
150
|
private retryTimer: ReturnType<typeof setInterval> | undefined;
|
|
142
151
|
private bashDecoder = new TextDecoder();
|
|
143
152
|
private branch: string | null;
|
|
153
|
+
private git: GitStatus | null = null;
|
|
154
|
+
private gitRun: Promise<void> | undefined;
|
|
155
|
+
private gitAgain = false;
|
|
156
|
+
private attention: ControlSnapshot["waiting"] = null;
|
|
157
|
+
private lastOutcome: ExecutionOutcome | undefined;
|
|
144
158
|
private paintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
145
159
|
/** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
|
|
146
160
|
private derived: ReturnType<typeof handoffStatus> | undefined;
|
|
@@ -211,7 +225,7 @@ export class InteractiveApp implements Ui {
|
|
|
211
225
|
this.program = program;
|
|
212
226
|
this.stopOsc = program ? renderer.subscribeOsc(sequence => program.receive(sequence)) : undefined;
|
|
213
227
|
this.closed = new Promise(resolve => { this.closedResolve = resolve; });
|
|
214
|
-
this.branch =
|
|
228
|
+
this.branch = null;
|
|
215
229
|
this.sessionName = app.args.sessionName || null;
|
|
216
230
|
this.thinkingExpanded = app.ui?.settings.hideThinkingBlock === false;
|
|
217
231
|
this.screen = new Screen(renderer, this.colors(), () => this.keymap().byAction, {
|
|
@@ -347,9 +361,19 @@ export class InteractiveApp implements Ui {
|
|
|
347
361
|
checkForUpdate(): void {
|
|
348
362
|
// The explicit action owns its result even when it joins a startup request.
|
|
349
363
|
this.manualUpdateRequested = true;
|
|
350
|
-
void this.updateChecks.manual({ offline: this.app.args.offline === true }, text => {
|
|
351
|
-
if (!this.disposed) this.notice(text);
|
|
352
|
-
}
|
|
364
|
+
void this.updateChecks.manual({ offline: this.app.args.offline === true }, (text, tone) => {
|
|
365
|
+
if (!this.disposed) this.notice(text, tone);
|
|
366
|
+
}, activity => {
|
|
367
|
+
if (this.disposed) return;
|
|
368
|
+
this.screen.setTransientActivity(activity.state === "checking" ? {
|
|
369
|
+
label: `Checking for xeno updates (current ${activity.current})`, timeoutMs: activity.timeoutMs,
|
|
370
|
+
} : undefined);
|
|
371
|
+
}).catch(() => {
|
|
372
|
+
if (!this.disposed) {
|
|
373
|
+
this.screen.setTransientActivity(undefined);
|
|
374
|
+
this.notice("Could not check for xeno updates. Try /update again.", "error");
|
|
375
|
+
}
|
|
376
|
+
});
|
|
353
377
|
}
|
|
354
378
|
|
|
355
379
|
private emptySession(): boolean {
|
|
@@ -417,7 +441,7 @@ export class InteractiveApp implements Ui {
|
|
|
417
441
|
// A different session: its notices, queue and draft attachments do not carry over.
|
|
418
442
|
if (this.historyDir) { this.notices = []; this.queue = []; this.imageDraft.reset(); this.screen.dialogs.clear(); }
|
|
419
443
|
this.clearStream();
|
|
420
|
-
this.
|
|
444
|
+
void this.refreshGit();
|
|
421
445
|
this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
|
|
422
446
|
this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
|
|
423
447
|
void this.refreshFileIndex();
|
|
@@ -458,7 +482,8 @@ export class InteractiveApp implements Ui {
|
|
|
458
482
|
} else {
|
|
459
483
|
this.stopRetry();
|
|
460
484
|
if (progress.kind === "tool") this.live.tools.set(progress.id, { name: progress.name, arguments: progress.arguments });
|
|
461
|
-
else this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
|
|
485
|
+
else if (progress.kind === "output") this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
|
|
486
|
+
else this.live.output = progress.active ? `Waiting for process ${progress.session} · Esc to interrupt` : "";
|
|
462
487
|
}
|
|
463
488
|
this.paint();
|
|
464
489
|
}
|
|
@@ -542,9 +567,19 @@ export class InteractiveApp implements Ui {
|
|
|
542
567
|
const t = app.session.totals, cost = reportedCost(t, spec.price);
|
|
543
568
|
const tools = items.filter(item => item.kind === "tool");
|
|
544
569
|
const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
|
|
570
|
+
const pending = tools.find(tool => tool.status === "pending");
|
|
571
|
+
const phase = this.executionKind === "bash" || pending ? "tool" : this.live.text ? "streaming" : this.live.thinking ? "thinking" : "connecting";
|
|
572
|
+
const activity = activityStatus({ running: this.inflight, waiting: this.attention ?? (!app.credential.ok ? "auth" : null), queued: this.queue.length, phase,
|
|
573
|
+
...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
|
|
574
|
+
...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
|
|
575
|
+
...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
|
|
576
|
+
...(this.live.retryUntil ? { retry: { attempt: this.live.retryAttempt, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
|
|
545
577
|
return {
|
|
546
578
|
version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
|
|
547
579
|
workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
|
|
580
|
+
mode: { yolo: app.args.yolo, workflow: app.session.config.tools.workflowMode ?? "build" },
|
|
581
|
+
provider: { name: spec.provider, api: spec.api, host: endpointHost(spec.baseUrl), role: app.args.role?.profile.name ?? app.args.agent ?? null, ready: app.credential.ok },
|
|
582
|
+
activity, git: this.git,
|
|
548
583
|
...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
|
|
549
584
|
...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
|
|
550
585
|
// Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
|
|
@@ -568,6 +603,18 @@ export class InteractiveApp implements Ui {
|
|
|
568
603
|
this.renderer.setTerminalTitle(title);
|
|
569
604
|
}
|
|
570
605
|
|
|
606
|
+
private async refreshGit(): Promise<void> {
|
|
607
|
+
if (this.disposed) return;
|
|
608
|
+
if (this.gitRun) { this.gitAgain = true; return; }
|
|
609
|
+
const workspace = this.app.workspace;
|
|
610
|
+
this.gitRun = (async () => {
|
|
611
|
+
const status = await readGitStatus(workspace);
|
|
612
|
+
if (!this.disposed && this.app.workspace === workspace) { this.git = status; this.branch = status?.branch ?? null; this.paint(); }
|
|
613
|
+
})();
|
|
614
|
+
try { await this.gitRun; }
|
|
615
|
+
finally { this.gitRun = undefined; if (this.gitAgain) { this.gitAgain = false; void this.refreshGit(); } }
|
|
616
|
+
}
|
|
617
|
+
|
|
571
618
|
private liveStream(): LiveStream | undefined {
|
|
572
619
|
const l = this.live;
|
|
573
620
|
if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
|
|
@@ -793,7 +840,8 @@ export class InteractiveApp implements Ui {
|
|
|
793
840
|
const shell = command.text.startsWith("!");
|
|
794
841
|
const images = shell ? [] : input.images;
|
|
795
842
|
if (shell) this.imageDraft.insertImages(input.images);
|
|
796
|
-
if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
|
|
843
|
+
if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
|
|
844
|
+
if (!shell && kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); return; }
|
|
797
845
|
this.turn = this.runPromptTurn(command.text, images, command.skill).catch(error => this.notice(describeError(error), "error"));
|
|
798
846
|
this.paint();
|
|
799
847
|
}
|
|
@@ -801,6 +849,7 @@ export class InteractiveApp implements Ui {
|
|
|
801
849
|
private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
|
|
802
850
|
this.abort = new AbortController();
|
|
803
851
|
this.inflight = true;
|
|
852
|
+
this.lastOutcome = undefined;
|
|
804
853
|
this.programResult = false;
|
|
805
854
|
this.program?.update(programStatus({ running: true }));
|
|
806
855
|
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
@@ -813,16 +862,18 @@ export class InteractiveApp implements Ui {
|
|
|
813
862
|
if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
|
|
814
863
|
} catch (error) {
|
|
815
864
|
this.programResult = true;
|
|
865
|
+
this.lastOutcome = executionOutcome("execution_error");
|
|
816
866
|
this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
817
867
|
throw error;
|
|
818
868
|
} finally {
|
|
869
|
+
if (this.abort?.signal.aborted && !this.lastOutcome) this.lastOutcome = executionOutcome("cancelled");
|
|
819
870
|
if (!this.programResult) this.program?.update(programStatus({ running: false }));
|
|
820
871
|
await this.syncTranscript();
|
|
821
872
|
this.abort = null;
|
|
822
873
|
this.hooks.dropFileIndex(this.app.workspace);
|
|
823
874
|
this.inflight = false;
|
|
824
875
|
this.executionKind = "idle";
|
|
825
|
-
this.
|
|
876
|
+
void this.refreshGit();
|
|
826
877
|
this.clearStream();
|
|
827
878
|
this.requestDerived();
|
|
828
879
|
this.paint();
|
|
@@ -954,7 +1005,7 @@ export class InteractiveApp implements Ui {
|
|
|
954
1005
|
this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
|
|
955
1006
|
}
|
|
956
1007
|
deliverExtension(text: string, kind: QueueKind): void {
|
|
957
|
-
if (this.inflight) { this.queue.push({ kind, text }); this.paint(); }
|
|
1008
|
+
if (this.inflight) { this.queue.push({ kind, text }); if (kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); }
|
|
958
1009
|
else this.turn = this.runPromptTurn(text).catch(error => this.notice(describeError(error), "error"));
|
|
959
1010
|
}
|
|
960
1011
|
|
|
@@ -993,10 +1044,12 @@ export class InteractiveApp implements Ui {
|
|
|
993
1044
|
const requested = terminal?.imageProtocol ?? "auto";
|
|
994
1045
|
const resolution = this.renderer.resolution;
|
|
995
1046
|
const effective = resolveImageRenderProtocol(requested, this.renderer.capabilities, resolution !== null);
|
|
996
|
-
const show = terminal?.showImages !== false &&
|
|
1047
|
+
const show = terminal?.showImages !== false && effective !== "blocks";
|
|
1048
|
+
const reason = terminal?.showImages === false ? "preview off" : requested === "sixel" && resolution === null ? "waiting for terminal resolution" : "terminal graphics unavailable";
|
|
997
1049
|
const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
|
|
998
1050
|
? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
|
|
999
|
-
return { mode: show ? "picture" : "chip", protocol:
|
|
1051
|
+
return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
|
|
1052
|
+
...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
|
|
1000
1053
|
}
|
|
1001
1054
|
private applyPictures(): void {
|
|
1002
1055
|
if (this.disposed) return;
|
|
@@ -1046,18 +1099,23 @@ export class InteractiveApp implements Ui {
|
|
|
1046
1099
|
const control = sessionControl(this.app.session);
|
|
1047
1100
|
if (this.control === control) return;
|
|
1048
1101
|
this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
|
|
1102
|
+
this.attention = null; this.lastOutcome = undefined;
|
|
1049
1103
|
this.screen.setControlModel(undefined);
|
|
1050
1104
|
const detachInput = control.attachInput(phase => this.pullQueued(phase));
|
|
1051
1105
|
const unsubscribe = control.subscribe(notice => {
|
|
1052
1106
|
if (this.disposed) return;
|
|
1053
|
-
if (notice.method === "turn/started") this.program?.update(programStatus({ running: true }));
|
|
1107
|
+
if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
|
|
1054
1108
|
if (notice.method === "turn/error") {
|
|
1109
|
+
this.lastOutcome = executionOutcome("execution_error");
|
|
1055
1110
|
this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
1056
1111
|
this.track(() => this.syncTranscript());
|
|
1057
1112
|
}
|
|
1058
1113
|
if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
|
|
1059
|
-
if (notice.method === "thread/statusChanged" && "waiting" in notice.params)
|
|
1114
|
+
if (notice.method === "thread/statusChanged" && "waiting" in notice.params) {
|
|
1115
|
+
this.attention = notice.params["waiting"] as ControlSnapshot["waiting"];
|
|
1060
1116
|
this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
|
|
1117
|
+
}
|
|
1118
|
+
if (notice.method === "turn/completed") this.lastOutcome = notice.params["outcome"] as ExecutionOutcome | undefined;
|
|
1061
1119
|
// A remote client drives the same Session; local turns already have the shell's stream adapter.
|
|
1062
1120
|
if (!this.abort) {
|
|
1063
1121
|
if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
|
|
@@ -1093,6 +1151,7 @@ export class InteractiveApp implements Ui {
|
|
|
1093
1151
|
}
|
|
1094
1152
|
|
|
1095
1153
|
observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
|
|
1154
|
+
if (event.outcome) this.lastOutcome = event.outcome;
|
|
1096
1155
|
if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
|
|
1097
1156
|
if (event.kind === "done") this.program?.update(programStatus({ running: false,
|
|
1098
1157
|
...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
|
|
@@ -1501,20 +1560,23 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
|
|
|
1501
1560
|
app.io.say = previousSay;
|
|
1502
1561
|
process.off("SIGTERM", terminate); process.off("SIGHUP", hangup); process.off("uncaughtException", crash); process.off("unhandledRejection", crash); process.off("exit", lastResort);
|
|
1503
1562
|
}
|
|
1504
|
-
if (!renderer && fullscreen) printExit(app);
|
|
1563
|
+
if (!renderer && fullscreen) await printExit(app);
|
|
1505
1564
|
}
|
|
1506
1565
|
}
|
|
1507
1566
|
|
|
1508
|
-
/**
|
|
1509
|
-
function printExit(app: App): void {
|
|
1510
|
-
const
|
|
1567
|
+
/** Restore the shell with a bounded status and resume entry; full replay is explicit. */
|
|
1568
|
+
async function printExit(app: App): Promise<void> {
|
|
1569
|
+
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
1570
|
+
const events = history.ok ? history.nodes.flatMap(part => part.events) : app.session.state.events;
|
|
1571
|
+
const mode = app.ui?.settings.fullscreenExitOutput ?? DEFAULT_FULLSCREEN_EXIT_OUTPUT;
|
|
1511
1572
|
if (mode === "transcript") {
|
|
1512
|
-
for (const item of projectTranscript(
|
|
1573
|
+
for (const item of projectTranscript(events)) {
|
|
1513
1574
|
if (item.kind === "user") process.stdout.write(`\n❯ ${item.text}\n`);
|
|
1514
1575
|
else if (item.kind === "assistant") process.stdout.write(`\n${item.text}\n`);
|
|
1515
1576
|
else if (item.kind === "tool") process.stdout.write(` ⏺ ${item.name}${item.result ? item.result.ok ? "" : " (failed)" : " (pending)"}\n`);
|
|
1516
1577
|
}
|
|
1517
1578
|
}
|
|
1579
|
+
process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
|
|
1518
1580
|
process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
|
|
1519
1581
|
}
|
|
1520
1582
|
|
|
@@ -8,20 +8,28 @@
|
|
|
8
8
|
* its pixels, and memory is returned once the last node goes.
|
|
9
9
|
*
|
|
10
10
|
* Bytes come from the blob store, which checks the hash; a missing or
|
|
11
|
-
* corrupt blob, or one that will not decode,
|
|
12
|
-
*
|
|
11
|
+
* corrupt blob, or one that will not decode, has a typed failure so the view
|
|
12
|
+
* retains the caption and shows the actual preparation error.
|
|
13
13
|
*/
|
|
14
|
-
import { NativeImage } from "@opentui/core";
|
|
14
|
+
import { NativeImage, imageInfo } from "@opentui/core";
|
|
15
15
|
import { getBlobBytes } from "../../store/blobs.ts";
|
|
16
|
+
import { modelImageDimensions, MAX_IMAGE_SOURCE_BYTES, MAX_IMAGE_SOURCE_PIXELS } from "../../core/image-limits.ts";
|
|
17
|
+
import type { PictureLoad } from "../../tui/ui/pictures.ts";
|
|
16
18
|
|
|
17
|
-
/**
|
|
19
|
+
/** Two full previews fit; mounted ImageRenderables retain their own references. */
|
|
18
20
|
export const IMAGE_CACHE_BYTES = 64 * 1024 * 1024;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
/** Bounds failed entries and queued requests as well as successful pixels. */
|
|
22
|
+
export const IMAGE_CACHE_ENTRIES = 128;
|
|
23
|
+
/** Limit simultaneous native allocations; remaining admitted loads wait in the bounded cache. */
|
|
24
|
+
const IMAGE_DECODE_CONCURRENCY = 2;
|
|
25
|
+
interface Entry { readonly result: Promise<PictureLoad>; bytes: number; settled: boolean }
|
|
21
26
|
|
|
22
27
|
export class ImageCache {
|
|
23
28
|
private readonly entries = new Map<string, Entry>();
|
|
24
29
|
private used = 0;
|
|
30
|
+
private active = 0;
|
|
31
|
+
private closed = false;
|
|
32
|
+
private readonly waiting: (() => void)[] = [];
|
|
25
33
|
private readonly blobDir: () => string;
|
|
26
34
|
private readonly budget: number;
|
|
27
35
|
|
|
@@ -34,45 +42,80 @@ export class ImageCache {
|
|
|
34
42
|
get size(): number { return this.used; }
|
|
35
43
|
get count(): number { return this.entries.size; }
|
|
36
44
|
|
|
37
|
-
load(sha256: string): Promise<NativeImage | null> {
|
|
45
|
+
async load(sha256: string): Promise<NativeImage | null> {
|
|
46
|
+
const result = await this.loadResult(sha256);
|
|
47
|
+
return result.kind === "ready" ? result.image : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
loadResult(sha256: string): Promise<PictureLoad> {
|
|
51
|
+
if (this.closed) return Promise.resolve({ kind: "error", reason: "closed" });
|
|
38
52
|
const hit = this.entries.get(sha256);
|
|
39
53
|
if (hit) {
|
|
40
54
|
// Most recently used goes last; eviction starts from the front.
|
|
41
55
|
this.entries.delete(sha256); this.entries.set(sha256, hit);
|
|
42
|
-
return hit.
|
|
56
|
+
return hit.result;
|
|
43
57
|
}
|
|
44
|
-
|
|
58
|
+
if (this.entries.size >= IMAGE_CACHE_ENTRIES) {
|
|
59
|
+
const oldest = [...this.entries].find(([, entry]) => entry.settled);
|
|
60
|
+
if (oldest) this.drop(oldest[0], oldest[1]);
|
|
61
|
+
else return Promise.resolve({ kind: "error", reason: "busy" });
|
|
62
|
+
}
|
|
63
|
+
// Capture the blob owner before awaiting: session switches cannot redirect an in-flight read.
|
|
64
|
+
const entry: Entry = { result: this.decode(this.blobDir(), sha256), bytes: 0, settled: false };
|
|
45
65
|
this.entries.set(sha256, entry);
|
|
46
|
-
void entry.
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
void entry.result.then(result => {
|
|
67
|
+
entry.settled = true;
|
|
68
|
+
if (this.entries.get(sha256) !== entry) { if (result.kind === "ready") result.image.dispose(); return; }
|
|
69
|
+
entry.bytes = result.kind === "ready" ? result.image.width * result.image.height * 4 : 0;
|
|
49
70
|
this.used += entry.bytes;
|
|
50
|
-
this.
|
|
71
|
+
for (const [sha, old] of this.entries) {
|
|
72
|
+
if (this.used <= this.budget) break;
|
|
73
|
+
if (sha !== sha256 && old.settled) this.drop(sha, old);
|
|
74
|
+
}
|
|
51
75
|
});
|
|
52
|
-
return entry.
|
|
76
|
+
return entry.result;
|
|
53
77
|
}
|
|
54
78
|
|
|
55
|
-
private async decode(
|
|
79
|
+
private async decode(dir: string, sha: string): Promise<PictureLoad> {
|
|
80
|
+
if (this.active >= IMAGE_DECODE_CONCURRENCY) await new Promise<void>(resolve => this.waiting.push(resolve));
|
|
81
|
+
else this.active++;
|
|
82
|
+
if (this.closed) return { kind: "error", reason: "closed" };
|
|
83
|
+
let native: NativeImage | undefined;
|
|
56
84
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
let bytes: Uint8Array | null;
|
|
86
|
+
try { bytes = await getBlobBytes(dir, sha, MAX_IMAGE_SOURCE_BYTES); }
|
|
87
|
+
catch (error) { return { kind: "error", reason: error instanceof RangeError ? "limit" : "corrupt" }; }
|
|
88
|
+
if (!bytes) return { kind: "error", reason: "missing" };
|
|
89
|
+
if (bytes.length > MAX_IMAGE_SOURCE_BYTES) return { kind: "error", reason: "limit" };
|
|
90
|
+
const info = imageInfo(bytes);
|
|
91
|
+
if (info.width * info.height > MAX_IMAGE_SOURCE_PIXELS) return { kind: "error", reason: "limit" };
|
|
92
|
+
native = await NativeImage.load(bytes);
|
|
93
|
+
const target = modelImageDimensions(native);
|
|
94
|
+
if (target.width !== native.width || target.height !== native.height) {
|
|
95
|
+
const resized = native.resize(target); native.dispose(); native = resized;
|
|
96
|
+
}
|
|
97
|
+
if (this.closed) return { kind: "error", reason: "closed" };
|
|
98
|
+
if (native.width * native.height * 4 > this.budget) return { kind: "error", reason: "limit" };
|
|
99
|
+
const image = native; native = undefined;
|
|
100
|
+
return { kind: "ready", image };
|
|
101
|
+
} catch { return { kind: "error", reason: "decode" }; }
|
|
102
|
+
finally {
|
|
103
|
+
native?.dispose();
|
|
104
|
+
const next = this.waiting.shift();
|
|
105
|
+
if (next) next(); else this.active--;
|
|
106
|
+
}
|
|
60
107
|
}
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (this.used <= this.budget) break;
|
|
66
|
-
if (sha === keep) continue;
|
|
67
|
-
this.entries.delete(sha);
|
|
68
|
-
this.used -= entry.bytes;
|
|
69
|
-
void entry.image.then(native => native?.dispose());
|
|
70
|
-
}
|
|
109
|
+
private drop(sha: string, entry: Entry): void {
|
|
110
|
+
this.entries.delete(sha); this.used -= entry.bytes;
|
|
111
|
+
void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
|
|
71
112
|
}
|
|
72
113
|
|
|
73
114
|
dispose(): void {
|
|
74
|
-
|
|
115
|
+
this.closed = true;
|
|
116
|
+
for (const entry of this.entries.values()) if (entry.settled) void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
|
|
75
117
|
this.entries.clear();
|
|
76
118
|
this.used = 0;
|
|
119
|
+
this.waiting.splice(0).forEach(resolve => resolve());
|
|
77
120
|
}
|
|
78
121
|
}
|
package/src/apps/update-check.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Shared lifecycle for automatic hints and explicit checks; policy remains in core (D16). */
|
|
2
2
|
import { installKind, manualUpdateResult, updateDisabled, updatePlan, type UpdateInput } from "../core/update-check.ts";
|
|
3
3
|
import { packageLocation, readUpdateCache, writeUpdateCache } from "../store/update-cache.ts";
|
|
4
|
-
import { fetchLatestVersion } from "../runtime/update-check.ts";
|
|
4
|
+
import { fetchLatestVersion, UPDATE_TIMEOUT_MS } from "../runtime/update-check.ts";
|
|
5
5
|
import { XENO_VERSION } from "../runtime/oauth.ts";
|
|
6
6
|
|
|
7
7
|
export interface UpdateOptions {
|
|
@@ -9,6 +9,12 @@ export interface UpdateOptions {
|
|
|
9
9
|
readonly enabled?: boolean | undefined;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export type ManualUpdateActivity =
|
|
13
|
+
| { readonly state: "checking"; readonly current: string; readonly timeoutMs: number }
|
|
14
|
+
| { readonly state: "idle" };
|
|
15
|
+
|
|
16
|
+
type ManualUpdateReporter = (text: string, tone?: "info" | "error") => void;
|
|
17
|
+
|
|
12
18
|
export class UpdateChecks {
|
|
13
19
|
private readonly controller = new AbortController();
|
|
14
20
|
private pending: Promise<string | null> | undefined;
|
|
@@ -36,14 +42,14 @@ export class UpdateChecks {
|
|
|
36
42
|
return pending;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
manual(options: UpdateOptions, say: (
|
|
45
|
+
manual(options: UpdateOptions, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
|
|
40
46
|
if (this.manualTask) return this.manualTask;
|
|
41
|
-
const task = this.runManual(options, say).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
|
|
47
|
+
const task = this.runManual(options, say, onActivity).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
|
|
42
48
|
this.manualTask = task;
|
|
43
49
|
return task;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
private async runManual(options: UpdateOptions, say: (
|
|
52
|
+
private async runManual(options: UpdateOptions, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
|
|
47
53
|
const { input, plan, disabled } = await this.prepare(options, true);
|
|
48
54
|
if (this.controller.signal.aborted) return 130;
|
|
49
55
|
if (!plan.check) {
|
|
@@ -51,12 +57,17 @@ export class UpdateChecks {
|
|
|
51
57
|
: `Update check skipped: ${disabled ?? "check unavailable"} (xeno ${input.current}).`);
|
|
52
58
|
return input.install === "checkout" ? 0 : 1;
|
|
53
59
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
if (onActivity) onActivity({ state: "checking", current: input.current, timeoutMs: UPDATE_TIMEOUT_MS });
|
|
61
|
+
else say(`Checking for xeno updates (current ${input.current})...`);
|
|
62
|
+
try {
|
|
63
|
+
const latest = await this.refresh(input);
|
|
64
|
+
if (this.controller.signal.aborted) return 130;
|
|
65
|
+
const result = manualUpdateResult(latest, input.current, input.install);
|
|
66
|
+
say(result.text, result.status === "failed" ? "error" : "info");
|
|
67
|
+
return result.status === "failed" ? 1 : 0;
|
|
68
|
+
} finally {
|
|
69
|
+
if (onActivity) onActivity({ state: "idle" });
|
|
70
|
+
}
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
cancel(): void { this.controller.abort(); }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A finer view of OSC status, derived from the same runtime facts (D11). */
|
|
2
|
+
import { programStatus, type ProgramStatus } from "./program-status.ts";
|
|
3
|
+
import type { ExecutionOutcome } from "./outcome.ts";
|
|
4
|
+
|
|
5
|
+
export interface ActivityFacts {
|
|
6
|
+
readonly running: boolean;
|
|
7
|
+
readonly waiting?: "permission" | "question" | "auth" | null;
|
|
8
|
+
readonly outcome?: ExecutionOutcome;
|
|
9
|
+
readonly goal?: string;
|
|
10
|
+
readonly phase?: "connecting" | "thinking" | "streaming" | "tool";
|
|
11
|
+
readonly tool?: string;
|
|
12
|
+
readonly retry?: { readonly attempt: number; readonly seconds: number };
|
|
13
|
+
readonly queued: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
|
|
16
|
+
|
|
17
|
+
export function activityStatus(facts: ActivityFacts): ActivityStatus {
|
|
18
|
+
const status = programStatus(facts), queued = Math.max(0, Math.floor(facts.queued));
|
|
19
|
+
const view = (label: string, detail = status.msg ?? ""): ActivityStatus => ({ state: status.state, label, detail, queued });
|
|
20
|
+
if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval" : "Awaiting input");
|
|
21
|
+
if (status.state === "error") return view("Failed");
|
|
22
|
+
if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
|
|
23
|
+
if (facts.retry) return view("Retrying", `attempt ${facts.retry.attempt} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
|
|
24
|
+
if (facts.phase === "tool") return view("Running tool", facts.tool ?? "Tool execution");
|
|
25
|
+
if (facts.phase === "thinking") return view("Thinking");
|
|
26
|
+
if (facts.phase === "streaming") return view("Responding");
|
|
27
|
+
return view("Connecting", "Waiting for the first response");
|
|
28
|
+
}
|