@visual-z/xeno 0.7.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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.7.0 — 2026-10-09
4
10
 
5
11
  - Keep fullscreen exit output bounded to the execution state and resume command. Preserve full transcript export as an explicit choice.
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.7.0**. The on-disk session format will still change; `0.x` is that
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
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` 与模型路由头遵循官方代理合同。依据:[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)。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visual-z/xeno",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "Segmented append-only agent log with pure context and handoff projections",
@@ -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 letters, digits, - and _",
158
- validate: id => !validProviderId(id) ? "Use lowercase letters, digits, - and _ (start with a letter or digit)"
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;
@@ -361,9 +361,19 @@ export class InteractiveApp implements Ui {
361
361
  checkForUpdate(): void {
362
362
  // The explicit action owns its result even when it joins a startup request.
363
363
  this.manualUpdateRequested = true;
364
- void this.updateChecks.manual({ offline: this.app.args.offline === true }, text => {
365
- if (!this.disposed) this.notice(text);
366
- }).catch(() => { if (!this.disposed) this.notice("Could not check for xeno updates. Try /update again.", "error"); });
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
+ });
367
377
  }
368
378
 
369
379
  private emptySession(): boolean {
@@ -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: (text: string) => void): Promise<number> {
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: (text: string) => void): Promise<number> {
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
- say(`Checking for xeno updates (current ${input.current})...`);
55
- const latest = await this.refresh(input);
56
- if (this.controller.signal.aborted) return 130;
57
- const result = manualUpdateResult(latest, input.current, input.install);
58
- say(result.text);
59
- return result.status === "failed" ? 1 : 0;
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(); }
@@ -18,7 +18,7 @@ import { pollDeviceCode } from "./oauth-device.ts";
18
18
  import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
19
19
 
20
20
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
21
- export const XENO_VERSION = "0.7.0";
21
+ export const XENO_VERSION = "0.7.1";
22
22
 
23
23
  /**
24
24
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -114,6 +114,24 @@ export const GROK_PROVIDER = "xai-grok";
114
114
  export const GROK_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
115
115
  /** Official HTTP timeout: issuer requests cannot keep a cancelled or expired grant hanging indefinitely. */
116
116
  const GROK_REQUEST_TIMEOUT_MS = 15_000;
117
+ /**
118
+ * The proxy's version gate reads only `x-grok-client-version`
119
+ * (grok-build xai-grok-sampler/src/client.rs; agent/proxy_headers.rs: "required
120
+ * by the proxy's version-gate check"); without it every inference is HTTP 426
121
+ * "version (none) is outdated". It names the protocol revision xeno speaks,
122
+ * not xeno's own version -- the User-Agent and client identifier still say
123
+ * xeno (D18). Pinned, not fetched: a registry lookup would put a network
124
+ * call in front of every session. 1.0.46 is what CLIProxyAPI and Cherry
125
+ * Studio send (minimum 1.0.13 as of 2026-10-09; stable 1.0.50). When the
126
+ * minimum passes it, 426 comes back and grokVersionHint() says so.
127
+ */
128
+ export const GROK_PROTOCOL_VERSION = "1.0.46";
129
+ /** Shown when the proxy's version gate refuses a request. */
130
+ export function grokVersionHint(status: number | undefined, provider: string): string | undefined {
131
+ return status === 426 && provider === GROK_PROVIDER
132
+ ? `the Grok proxy now requires a newer client protocol than xeno sends (${GROK_PROTOCOL_VERSION}); update xeno, or use the xai provider with XAI_API_KEY meanwhile`
133
+ : undefined;
134
+ }
117
135
 
118
136
  async function grokPost(url: string, fields: Record<string, string>, effects: Pick<RefreshEffects, "fetch" | "signal">): Promise<{ status: number; json: unknown }> {
119
137
  const timeout = AbortSignal.timeout(GROK_REQUEST_TIMEOUT_MS);
@@ -17,6 +17,7 @@ import type { AssistantBlock } from "../core/events.ts";
17
17
  import { toolCallIdentityViolations, type FrozenPrefix, type Message } from "../core/project.ts";
18
18
  import type { Api, ModelSpec } from "./model.ts";
19
19
  import { providerRequest } from "./provider-request.ts";
20
+ import { grokVersionHint } from "./oauth.ts";
20
21
  import type { ResolveEffects } from "./auth.ts";
21
22
  import { imageData } from "./images.ts";
22
23
  import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
@@ -373,7 +374,11 @@ export class PiProvider implements Provider {
373
374
  case "error":
374
375
  if (event.reason === "aborted" || signal?.aborted) throw new DOMException("Request aborted", "AbortError");
375
376
  status ??= nativeStatus(event.error.errorMessage ?? "");
376
- if (status && status >= 400) throw new HttpError(status, responseHeaders, event.error.errorMessage ?? "Provider request failed");
377
+ if (status && status >= 400) {
378
+ // The proxy's own text says "run `grok update`", which a xeno user cannot act on.
379
+ const hint = grokVersionHint(status, this.config.spec.provider);
380
+ throw new HttpError(status, responseHeaders, (event.error.errorMessage ?? "Provider request failed") + (hint ? `\n ${hint}` : ""));
381
+ }
377
382
  throw new StreamError(event.error.errorMessage ?? "Provider stream failed");
378
383
  case "start": case "text_start": case "text_end": case "thinking_start": case "thinking_end": break;
379
384
  default: assertNever(event, "pi stream event");
@@ -74,6 +74,17 @@ export function validProviderId(id: string): boolean {
74
74
  return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(id);
75
75
  }
76
76
 
77
+ /**
78
+ * The valid id a rejected one most likely meant ("My Gateway" → "my-gateway"),
79
+ * or "" when nothing Latin survives ("网关"). Only offered in the error, never
80
+ * applied silently: the id becomes a config and auth.json key the user will
81
+ * type in /model, so they should see it before it is saved.
82
+ */
83
+ export function slugProviderId(raw: string): string {
84
+ if (validProviderId(raw)) return raw;
85
+ return raw.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, 64).replace(/-+$/, "");
86
+ }
87
+
77
88
  /** A readable default id from a URL host: api.example.com → example. */
78
89
  export function suggestProviderId(baseUrl: string, taken: ReadonlySet<string>): string {
79
90
  let host = "custom";
@@ -1,7 +1,7 @@
1
1
  import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
2
2
  import { providerAccess } from "./provider-access.ts";
3
3
  import type { ModelSpec } from "./model.ts";
4
- import { XENO_VERSION, GROK_PROVIDER, GROK_BASE_URL } from "./oauth.ts";
4
+ import { XENO_VERSION, GROK_PROVIDER, GROK_BASE_URL, GROK_PROTOCOL_VERSION } from "./oauth.ts";
5
5
 
6
6
  const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
7
7
 
@@ -45,6 +45,8 @@ export function providerRequest(spec: ModelSpec, key: string, effects: ResolveEf
45
45
  // These select the auth protocol and backend; the User-Agent still identifies xeno (D18).
46
46
  headers["X-XAI-Token-Auth"] = "xai-grok-cli";
47
47
  headers["x-grok-model-override"] = spec.wireId ?? spec.id;
48
+ headers["x-grok-client-version"] = GROK_PROTOCOL_VERSION;
49
+ headers["x-grok-client-identifier"] = "xeno";
48
50
  }
49
51
  const anthropicOAuth = spec.api === "anthropic-messages" && spec.provider !== "github-copilot" && (oauth || key.includes("sk-ant-oat"));
50
52
  const noAutomaticAuth = access.auth === false || access.authHeader === false || typeof access.authHeader === "string" || anthropicOAuth;
@@ -40,9 +40,20 @@ export type DialogSpec =
40
40
  | { kind: "busy"; title: string; message: string; onCancel?(): void }
41
41
  | { kind: "lines"; title: string; render(width: number): string[]; handleKey?(key: KeyPress): boolean | Promise<boolean>; hint?: string; onCancel?(): void };
42
42
 
43
+ /**
44
+ * One stacked dialog. `list` and `row` (a plain form's label + Input) are
45
+ * persistent: built once, they carry typed text and cursor across repaints,
46
+ * and `show()` only detaches them. Everything else a repaint builds is
47
+ * ephemeral and destroyed recursively on the next one, so a persistent node
48
+ * must never sit inside an ephemeral parent -- that destroyed the form's
49
+ * Input on the first validation error and every later key threw
50
+ * (docs/tasks/fix-dialog-form-and-grok-version.md).
51
+ */
43
52
  interface Open {
44
53
  spec: DialogSpec;
45
54
  list?: ListView;
55
+ row?: BoxRenderable;
56
+ label?: TextRenderable;
46
57
  input?: InputRenderable;
47
58
  secret?: string;
48
59
  error?: string | undefined;
@@ -112,6 +123,7 @@ export class DialogHost {
112
123
  for (const open of this.stack) {
113
124
  open.list?.setColors(colors);
114
125
  if (open.input) { open.input.textColor = colors.text; open.input.focusedTextColor = colors.text; open.input.placeholderColor = colors.muted; }
126
+ if (open.label && open.spec.kind === "form") open.label.content = this.formLabel(open.spec.label);
115
127
  }
116
128
  if (this.open) this.show();
117
129
  }
@@ -180,6 +192,10 @@ export class DialogHost {
180
192
  focusedTextColor: this.colors.text, backgroundColor: "transparent", focusedBackgroundColor: "transparent", flexGrow: 1 });
181
193
  // Prefilled values are edited from their end (a URL gets a path appended).
182
194
  open.input.gotoBufferEnd();
195
+ open.row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
196
+ open.label = new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: this.formLabel(spec.label), flexShrink: 0 });
197
+ open.row.add(open.label);
198
+ open.row.add(open.input);
183
199
  }
184
200
  if (spec.kind === "form" && spec.secret) open.secret = "";
185
201
  if (spec.kind === "confirm") open.yes = true;
@@ -228,17 +244,18 @@ export class DialogHost {
228
244
  }
229
245
  case "form": {
230
246
  if (spec.intro?.length) this.add(text(ansiText(spec.intro), { marginBottom: 1 }));
231
- const row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
232
- row.add(new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: parts({ text: `${spec.label} › `, fg: c.muted }), flexShrink: 0 }));
233
- if (top.input) { row.add(top.input); top.input.focus(); }
247
+ if (top.row) { this.slot.add(top.row); top.input!.focus(); }
234
248
  else {
235
- // The secret never reaches a renderable: only its length does.
249
+ // The secret never reaches a renderable: only its length does. The
250
+ // whole row is rebuilt per repaint, so it holds nothing persistent.
251
+ const row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
252
+ row.add(new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: this.formLabel(spec.label), flexShrink: 0 }));
236
253
  const mask = canvas([[{ text: "•".repeat(Math.min(top.secret!.length, 48)), role: "text" }, { text: "▏", role: "accent" }]]);
237
254
  mask.flexGrow = 1;
238
255
  row.add(mask);
256
+ this.add(row);
239
257
  this.renderer.currentFocusedRenderable?.blur();
240
258
  }
241
- this.add(row);
242
259
  if (top.error) this.add(text(parts({ text: top.error, fg: c.error })));
243
260
  break;
244
261
  }
@@ -272,6 +289,8 @@ export class DialogHost {
272
289
  this.renderer.requestRender();
273
290
  }
274
291
 
292
+ private formLabel(label: string): ReturnType<typeof parts> { return parts({ text: `${label} › `, fg: this.colors.muted }); }
293
+
275
294
  private paintChrome(): void {
276
295
  const top = this.stack.at(-1);
277
296
  const spec = top?.spec;
@@ -96,6 +96,7 @@ export class Screen {
96
96
  private sidebarWanted = true;
97
97
  private status: StatusModel | undefined;
98
98
  private dockState: DockState = { model: "", thinking: "", busy: false, activity: "" };
99
+ private transientActivity: { readonly label: string; readonly startedAt: number; readonly timeoutMs: number } | undefined;
99
100
  private home: Greeting | undefined;
100
101
  private greetingPlayed = false;
101
102
  private greetingFrame = MASCOT_FRAMES;
@@ -348,6 +349,12 @@ export class Screen {
348
349
  this.paintEdges();
349
350
  }
350
351
 
352
+ setTransientActivity(activity: { readonly label: string; readonly timeoutMs: number } | undefined): void {
353
+ this.transientActivity = activity ? { ...activity, startedAt: Date.now() } : undefined;
354
+ this.syncActivity();
355
+ this.paintEdges();
356
+ }
357
+
351
358
  private paintEdges(): void {
352
359
  const width = Math.max(2, this.renderer.width - (this.shape.tier === "sidebar" ? this.shape.sidebarWidth : 0) - 2);
353
360
  const d = this.dockState, dialog = this.dialogs.open;
@@ -371,8 +378,11 @@ export class Screen {
371
378
  const seconds = Math.max(0, Math.floor((Date.now() - this.busySince) / 1000));
372
379
  const activity = this.status?.activity;
373
380
  const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
374
- left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
381
+ const transient = this.transientActivity ? ` · ${this.transientActivityText()}` : "";
382
+ left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
375
383
  right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
384
+ } else if (this.transientActivity) {
385
+ left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${this.transientActivityText()}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
376
386
  } else {
377
387
  // A draft taller than the box says what scrolled out of view (fix-tui-composer-growth DoD 4).
378
388
  const { above, below } = this.composer.overflow;
@@ -397,7 +407,7 @@ export class Screen {
397
407
 
398
408
  /** The clock runs only while something on screen shows the activity glyph. */
399
409
  private syncActivity(): void {
400
- const wanted = this.animations && (this.dockState.busy || this.dialogs.animated);
410
+ const wanted = this.animations && (this.dockState.busy || this.transientActivity !== undefined || this.dialogs.animated);
401
411
  if (wanted && !this.activity.running) { this.lastEvent = Date.now(); this.startClock(ACTIVITY_MS); }
402
412
  else if (!wanted && this.activity.running) { this.activity.stop(); this.paintEdges(); }
403
413
  }
@@ -413,6 +423,13 @@ export class Screen {
413
423
  }, undefined, intervalMs);
414
424
  }
415
425
 
426
+ private transientActivityText(): string {
427
+ const activity = this.transientActivity!;
428
+ const elapsed = Math.max(0, Math.floor((Date.now() - activity.startedAt) / 1000));
429
+ const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
430
+ return `${activity.label} · ${elapsed}s / ${deadline}s max`;
431
+ }
432
+
416
433
  /** The spinner's current interval (tests). */
417
434
  get activityInterval(): number { return this.activity.running ? this.activityMs : 0; }
418
435