@visual-z/xeno 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +4 -4
- package/docs/install.md +18 -5
- package/docs/providers.md +1 -1
- package/package.json +1 -1
- package/src/apps/commands.ts +1 -1
- package/src/apps/provider-connect.ts +3 -3
- package/src/apps/shell.ts +1 -1
- package/src/apps/startup.ts +1 -1
- package/src/apps/ui/app.ts +18 -3
- package/src/apps/update-check.ts +52 -11
- package/src/core/update-check.ts +40 -0
- package/src/runtime/oauth.ts +19 -1
- package/src/runtime/pi-provider.ts +6 -1
- package/src/runtime/provider-presets.ts +11 -0
- package/src/runtime/provider-request.ts +3 -1
- package/src/runtime/self-update.ts +74 -0
- package/src/tui/ui/dialog.ts +24 -5
- package/src/tui/ui/screen.ts +20 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# xeno changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2 — 2026-10-10
|
|
4
|
+
|
|
5
|
+
- `/update` now installs the newer release when xeno was installed with bun or npm, instead of only printing the command. The status line shows the target version, elapsed time against a 3-minute limit and the package manager's latest output; success asks for a restart, and a failure gives the reason and the command to run by hand. Automatic checks still never install, and `xeno update --check` still only checks.
|
|
6
|
+
|
|
7
|
+
## 0.7.1 — 2026-10-09
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- Show progress, elapsed time and a 3-second limit for a manual `/update` check, with failures styled as errors.
|
|
12
|
+
|
|
3
13
|
## 0.7.0 — 2026-10-09
|
|
4
14
|
|
|
5
15
|
- 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.
|
|
20
|
+
Version **0.7.2**. 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
|
|
@@ -192,7 +192,7 @@ Slash commands that exist in the session (from `/help`):
|
|
|
192
192
|
/import <file> validate and open an archive as an independent session
|
|
193
193
|
/debug [file] write local terminal and message diagnostics
|
|
194
194
|
/changelog show bundled release notes
|
|
195
|
-
/update check
|
|
195
|
+
/update check for a newer xeno and install it (restart to use)
|
|
196
196
|
/trust [once|here|parent|never]
|
|
197
197
|
/reload refresh local resources at a durable context checkpoint
|
|
198
198
|
/config search resources and edit global or project overrides
|
|
@@ -479,10 +479,10 @@ unblocking affects new submissions, preserving historical request prefixes.
|
|
|
479
479
|
- **`/back` does not restore symlinks.**
|
|
480
480
|
- **`.xeno/` is not collected.** Snapshots and abandoned session branches
|
|
481
481
|
stay until you delete them.
|
|
482
|
-
- **No telemetry, `/share`,
|
|
482
|
+
- **No telemetry, `/share`, automatic updates, or `/compact`.** Those
|
|
483
483
|
are refusals (`docs/PI_PARITY.md` D15, D16), not backlog. xeno does say
|
|
484
484
|
when a newer version is published, checking automatically at most once a day.
|
|
485
|
-
`/update`
|
|
485
|
+
`/update` checks and installs when you ask; `xeno update --check` only checks;
|
|
486
486
|
`docs/install.md` (Updates) lists what that request reveals and every
|
|
487
487
|
way to turn it off.
|
|
488
488
|
|
package/docs/install.md
CHANGED
|
@@ -74,8 +74,9 @@ and restores the terminal. Start inside the intended project or pass
|
|
|
74
74
|
|
|
75
75
|
## Updates
|
|
76
76
|
|
|
77
|
-
xeno says when a newer version is published. It never installs one
|
|
78
|
-
notice names the command
|
|
77
|
+
xeno says when a newer version is published. It never installs one on its
|
|
78
|
+
own: the notice names the command. Typing `/update` is how you ask it to
|
|
79
|
+
install (below).
|
|
79
80
|
|
|
80
81
|
```
|
|
81
82
|
xeno 0.4.0 is available (you have 0.3.0)
|
|
@@ -87,7 +88,19 @@ The command follows where this copy is installed: `bun add -g` under
|
|
|
87
88
|
(`…/lib/node_modules/`, or `…\npm\node_modules\` on Windows). Anywhere else
|
|
88
89
|
the notice points to this file. A checkout updates with `git pull`.
|
|
89
90
|
|
|
90
|
-
To
|
|
91
|
+
To update now, type `/update` in the TUI. It checks the registry and, when
|
|
92
|
+
a newer release exists and xeno was installed with bun or npm, runs the
|
|
93
|
+
command from the notice pinned to that exact version (for example
|
|
94
|
+
`bun add -g @visual-z/xeno@0.7.1`). The status line shows the target version,
|
|
95
|
+
elapsed time against a 3-minute limit, and the package manager's latest output
|
|
96
|
+
line; the editor stays usable. Success says `Updated xeno A → B. Restart xeno
|
|
97
|
+
to use it.` — the running session keeps the old code until you restart. A
|
|
98
|
+
failure says why (exit code or timeout, with the package manager's last lines)
|
|
99
|
+
and repeats the command to run yourself. An unwritable install directory is
|
|
100
|
+
refused before anything runs. Other install locations and checkouts are never
|
|
101
|
+
installed into; they get the notice or `git pull` advice.
|
|
102
|
+
|
|
103
|
+
To check without installing:
|
|
91
104
|
|
|
92
105
|
```sh
|
|
93
106
|
xeno update --check
|
|
@@ -98,8 +111,8 @@ automatic-check environment switches, and CI suppression. It reports the
|
|
|
98
111
|
current version and the result, including a failed query; a stale cache is
|
|
99
112
|
never presented as a successful fresh check. A successful query refreshes
|
|
100
113
|
the same cache used by startup. Repeated checks while a request is running
|
|
101
|
-
share that request.
|
|
102
|
-
|
|
114
|
+
share that request. `xeno update --check` never installs, and needs no model
|
|
115
|
+
credential or session.
|
|
103
116
|
|
|
104
117
|
`--offline` / `XENO_OFFLINE` still prevent network access. Source checkouts
|
|
105
118
|
explain that they update with `git pull` instead of querying npm. Bare
|
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
package/src/apps/commands.ts
CHANGED
|
@@ -89,7 +89,7 @@ export const COMMANDS = [
|
|
|
89
89
|
{ name: "import", description: "Import a portable JSONL session", argumentHint: "<file>", busy: "idle" },
|
|
90
90
|
{ name: "debug", description: "Write local terminal diagnostics", argumentHint: "[file]", busy: "run" },
|
|
91
91
|
{ name: "changelog", description: "Show local release notes", busy: "run" },
|
|
92
|
-
{ name: "update", description: "Check
|
|
92
|
+
{ name: "update", description: "Check for a newer xeno and install it; restart to use it", busy: "run" },
|
|
93
93
|
{ name: "copy", description: "Copy the last assistant reply", busy: "run" },
|
|
94
94
|
{ name: "attachments", description: "Remove images from the current draft", busy: "run" },
|
|
95
95
|
{ name: "models", description: "Refresh the model catalog", argumentHint: "<refresh>", busy: "idle" },
|
|
@@ -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/shell.ts
CHANGED
|
@@ -656,7 +656,7 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
|
|
|
656
656
|
}
|
|
657
657
|
case "update":
|
|
658
658
|
if (tui) tui.checkForUpdate();
|
|
659
|
-
else await runManualUpdate({ offline: app.args.offline === true }, app.io.say);
|
|
659
|
+
else await runManualUpdate({ offline: app.args.offline === true, install: true }, app.io.say);
|
|
660
660
|
return "continue";
|
|
661
661
|
case "copy":
|
|
662
662
|
await copyLastReply(app, tui ?? undefined);
|
package/src/apps/startup.ts
CHANGED
|
@@ -1588,7 +1588,7 @@ export function printCliHelp(flags?: ReadonlyMap<string, ExtensionFlag>): void {
|
|
|
1588
1588
|
" [-n|--name name] [--session-id id] [--version|-v]\n" +
|
|
1589
1589
|
" [models --refresh]\n" +
|
|
1590
1590
|
" [install|remove <source>... [--local]] [update [source] [--extensions]] [list]\n" +
|
|
1591
|
-
" [update --check] Check for a newer xeno without installing\n" +
|
|
1591
|
+
" [update --check] Check for a newer xeno without installing (/update installs)\n" +
|
|
1592
1592
|
" [config [--local]] [--export session [output.html]] [--offline]\n" +
|
|
1593
1593
|
" [--system-prompt <text-or-file>] [--append-system-prompt <text>]\n" +
|
|
1594
1594
|
" [--no-context-files|-nc]\n" +
|
package/src/apps/ui/app.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallba
|
|
|
41
41
|
import { reportedCost } from "../../runtime/usage.ts";
|
|
42
42
|
import { UpdateChecks } from "../update-check.ts";
|
|
43
43
|
import { updateNotice } from "../../core/update-check.ts";
|
|
44
|
+
import { assertNever } from "../../core/exhaustive.ts";
|
|
44
45
|
import { isUpgrade } from "../../core/version.ts";
|
|
45
46
|
import type { Effort } from "../../runtime/model.ts";
|
|
46
47
|
import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
|
|
@@ -361,9 +362,23 @@ export class InteractiveApp implements Ui {
|
|
|
361
362
|
checkForUpdate(): void {
|
|
362
363
|
// The explicit action owns its result even when it joins a startup request.
|
|
363
364
|
this.manualUpdateRequested = true;
|
|
364
|
-
void this.updateChecks.manual({ offline: this.app.args.offline === true }, text => {
|
|
365
|
-
if (!this.disposed) this.notice(text);
|
|
366
|
-
}
|
|
365
|
+
void this.updateChecks.manual({ offline: this.app.args.offline === true, install: true }, (text, tone) => {
|
|
366
|
+
if (!this.disposed) this.notice(text, tone);
|
|
367
|
+
}, activity => {
|
|
368
|
+
if (this.disposed) return;
|
|
369
|
+
switch (activity.state) {
|
|
370
|
+
case "checking": this.screen.setTransientActivity({ label: `Checking for xeno updates (current ${activity.current})`, timeoutMs: activity.timeoutMs }); break;
|
|
371
|
+
case "installing": this.screen.setTransientActivity({ label: `Installing xeno ${activity.version}`, timeoutMs: activity.timeoutMs,
|
|
372
|
+
startedAt: activity.startedAt, ...(activity.detail ? { detail: activity.detail } : {}) }); break;
|
|
373
|
+
case "idle": this.screen.setTransientActivity(undefined); break;
|
|
374
|
+
default: assertNever(activity, "manual update activity");
|
|
375
|
+
}
|
|
376
|
+
}).catch(() => {
|
|
377
|
+
if (!this.disposed) {
|
|
378
|
+
this.screen.setTransientActivity(undefined);
|
|
379
|
+
this.notice("Could not check for xeno updates. Try /update again.", "error");
|
|
380
|
+
}
|
|
381
|
+
});
|
|
367
382
|
}
|
|
368
383
|
|
|
369
384
|
private emptySession(): boolean {
|
package/src/apps/update-check.ts
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
/** Shared lifecycle for automatic hints and explicit checks; policy remains in core (D16). */
|
|
2
|
-
import {
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { installArgv, installKind, installResult, manualUpdateResult, updateDisabled, updatePlan, type UpdateInput } from "../core/update-check.ts";
|
|
3
4
|
import { packageLocation, readUpdateCache, writeUpdateCache } from "../store/update-cache.ts";
|
|
4
|
-
import { fetchLatestVersion } from "../runtime/update-check.ts";
|
|
5
|
+
import { fetchLatestVersion, UPDATE_TIMEOUT_MS } from "../runtime/update-check.ts";
|
|
6
|
+
import { INSTALL_TIMEOUT_MS, runInstall } from "../runtime/self-update.ts";
|
|
5
7
|
import { XENO_VERSION } from "../runtime/oauth.ts";
|
|
6
8
|
|
|
7
9
|
export interface UpdateOptions {
|
|
8
10
|
readonly offline?: boolean;
|
|
9
11
|
readonly enabled?: boolean | undefined;
|
|
12
|
+
/** Install a newer release after the check: `/update` does, `xeno update --check` never does. */
|
|
13
|
+
readonly install?: boolean;
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
export type ManualUpdateActivity =
|
|
17
|
+
| { readonly state: "checking"; readonly current: string; readonly timeoutMs: number }
|
|
18
|
+
| { readonly state: "installing"; readonly version: string; readonly timeoutMs: number; readonly startedAt: number; readonly detail?: string }
|
|
19
|
+
| { readonly state: "idle" };
|
|
20
|
+
|
|
21
|
+
type ManualUpdateReporter = (text: string, tone?: "info" | "error") => void;
|
|
22
|
+
|
|
12
23
|
export class UpdateChecks {
|
|
13
24
|
private readonly controller = new AbortController();
|
|
14
25
|
private pending: Promise<string | null> | undefined;
|
|
15
26
|
private manualTask: Promise<number> | undefined;
|
|
27
|
+
/** The release this process already installed; the running code stays old until a restart. */
|
|
28
|
+
private installed: string | undefined;
|
|
16
29
|
|
|
17
30
|
async prepare(options: UpdateOptions, manual = false) {
|
|
18
31
|
const location = packageLocation();
|
|
@@ -36,14 +49,18 @@ export class UpdateChecks {
|
|
|
36
49
|
return pending;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
manual(options: UpdateOptions, say: (
|
|
52
|
+
manual(options: UpdateOptions, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
|
|
40
53
|
if (this.manualTask) return this.manualTask;
|
|
41
|
-
const task = this.runManual(options, say).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
|
|
54
|
+
const task = this.runManual(options, say, onActivity).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
|
|
42
55
|
this.manualTask = task;
|
|
43
56
|
return task;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
|
-
private async runManual(options: UpdateOptions, say: (
|
|
59
|
+
private async runManual(options: UpdateOptions, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
|
|
60
|
+
if (options.install && this.installed) {
|
|
61
|
+
say(`xeno ${this.installed} is already installed. Restart xeno to use it.`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
47
64
|
const { input, plan, disabled } = await this.prepare(options, true);
|
|
48
65
|
if (this.controller.signal.aborted) return 130;
|
|
49
66
|
if (!plan.check) {
|
|
@@ -51,12 +68,36 @@ export class UpdateChecks {
|
|
|
51
68
|
: `Update check skipped: ${disabled ?? "check unavailable"} (xeno ${input.current}).`);
|
|
52
69
|
return input.install === "checkout" ? 0 : 1;
|
|
53
70
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
if (onActivity) onActivity({ state: "checking", current: input.current, timeoutMs: UPDATE_TIMEOUT_MS });
|
|
72
|
+
else say(`Checking for xeno updates (current ${input.current})...`);
|
|
73
|
+
try {
|
|
74
|
+
const latest = await this.refresh(input);
|
|
75
|
+
if (this.controller.signal.aborted) return 130;
|
|
76
|
+
const result = manualUpdateResult(latest, input.current, input.install);
|
|
77
|
+
const argv = options.install && result.status === "available" && latest ? installArgv(input.install, latest) : undefined;
|
|
78
|
+
if (!argv || !latest) {
|
|
79
|
+
say(result.text, result.status === "failed" ? "error" : "info");
|
|
80
|
+
return result.status === "failed" ? 1 : 0;
|
|
81
|
+
}
|
|
82
|
+
return await this.install(argv, latest, input.current, say, onActivity);
|
|
83
|
+
} finally {
|
|
84
|
+
if (onActivity) onActivity({ state: "idle" });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async install(argv: readonly string[], latest: string, current: string, say: ManualUpdateReporter, onActivity?: (activity: ManualUpdateActivity) => void): Promise<number> {
|
|
89
|
+
const startedAt = Date.now();
|
|
90
|
+
if (onActivity) onActivity({ state: "installing", version: latest, timeoutMs: INSTALL_TIMEOUT_MS, startedAt });
|
|
91
|
+
else say(`Installing xeno ${latest} with ${argv.join(" ")}...`);
|
|
92
|
+
const outcome = await runInstall(argv, {
|
|
93
|
+
signal: this.controller.signal, timeoutMs: INSTALL_TIMEOUT_MS, writableDir: dirname(packageLocation().root),
|
|
94
|
+
...(onActivity ? { onLine: (detail: string) => onActivity({ state: "installing", version: latest, timeoutMs: INSTALL_TIMEOUT_MS, startedAt, detail }) } : {}),
|
|
95
|
+
});
|
|
96
|
+
if (outcome.kind === "cancelled") return 130;
|
|
97
|
+
const result = installResult(outcome, argv, current, latest);
|
|
98
|
+
if (result.ok) this.installed = latest;
|
|
99
|
+
say(result.text, result.ok ? "info" : "error");
|
|
100
|
+
return result.ok ? 0 : 1;
|
|
60
101
|
}
|
|
61
102
|
|
|
62
103
|
cancel(): void { this.controller.abort(); }
|
package/src/core/update-check.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* environment and the cached answer are all arguments, so every rule is a
|
|
9
9
|
* plain function of its inputs. Explicit checks bypass automatic scheduling
|
|
10
10
|
* and opt-outs, while retaining the offline and source-checkout boundaries.
|
|
11
|
+
* An explicit `/update` also installs (docs/tasks/fix-update-installs.md):
|
|
12
|
+
* typing the command is the consent automatic installation never has.
|
|
11
13
|
*/
|
|
12
14
|
import { compareVersions, isUpgrade, parseVersion } from "./version.ts";
|
|
13
15
|
|
|
@@ -76,6 +78,44 @@ export function upgradeCommand(install: InstallKind): string | undefined {
|
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* What an explicit `/update` runs: the package manager the notice names,
|
|
83
|
+
* pinned to the version just checked so `latest` cannot move between the
|
|
84
|
+
* check and the install. Argv, never a shell string. Undefined where xeno
|
|
85
|
+
* cannot know the right command and only the notice applies.
|
|
86
|
+
*/
|
|
87
|
+
export function installArgv(install: InstallKind, version: string): readonly string[] | undefined {
|
|
88
|
+
switch (install) {
|
|
89
|
+
case "bun": return ["bun", "add", "-g", `${PACKAGE_NAME}@${version}`];
|
|
90
|
+
case "npm": return ["npm", "i", "-g", `${PACKAGE_NAME}@${version}`];
|
|
91
|
+
case "other": case "checkout": return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type InstallOutcome =
|
|
96
|
+
| { readonly kind: "installed" }
|
|
97
|
+
| { readonly kind: "exit"; readonly code: number; readonly output: readonly string[] }
|
|
98
|
+
| { readonly kind: "timeout"; readonly ms: number; readonly output: readonly string[] }
|
|
99
|
+
| { readonly kind: "unavailable"; readonly reason: string }
|
|
100
|
+
| { readonly kind: "cancelled" };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How an install ended, in words a person can act on. A bare exit code hides
|
|
104
|
+
* ENOTEMPTY and EACCES (codex #5015, #21897), so failures carry the package
|
|
105
|
+
* manager's last lines and always the command to run by hand.
|
|
106
|
+
*/
|
|
107
|
+
export function installResult(outcome: InstallOutcome, argv: readonly string[], current: string, latest: string): { readonly ok: boolean; readonly text: string } {
|
|
108
|
+
const manual = `Run it yourself: ${argv.join(" ")}`;
|
|
109
|
+
const tail = (output: readonly string[]) => output.length ? `\n${output.map(line => ` ${line}`).join("\n")}` : "";
|
|
110
|
+
switch (outcome.kind) {
|
|
111
|
+
case "installed": return { ok: true, text: `Updated xeno ${current} → ${latest}. Restart xeno to use it.` };
|
|
112
|
+
case "exit": return { ok: false, text: `Could not update xeno: ${argv[0]} exited ${outcome.code}.${tail(outcome.output)}\n${manual}` };
|
|
113
|
+
case "timeout": return { ok: false, text: `Could not update xeno: ${argv[0]} did not finish within ${Math.round(outcome.ms / 1000)}s and was stopped.${tail(outcome.output)}\n${manual}` };
|
|
114
|
+
case "unavailable": return { ok: false, text: `Could not update xeno: ${outcome.reason}.\n${manual}` };
|
|
115
|
+
case "cancelled": return { ok: false, text: "xeno update cancelled." };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
79
119
|
/** Two lines: what is new, then the command on its own line so it can be copied whole. */
|
|
80
120
|
export function updateNotice(latest: string, current: string, install: InstallKind): string {
|
|
81
121
|
const command = upgradeCommand(install);
|
package/src/runtime/oauth.ts
CHANGED
|
@@ -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.
|
|
21
|
+
export const XENO_VERSION = "0.7.2";
|
|
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)
|
|
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;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the package manager for an explicit `/update` (docs/tasks/fix-update-installs.md).
|
|
3
|
+
*
|
|
4
|
+
* The argv comes from core's `installArgv`; this module only owns the process.
|
|
5
|
+
* Every competitor runs the install unbounded, and that is exactly what their
|
|
6
|
+
* hang reports are about (claude-code #80147, opencode #10921 `bun add`
|
|
7
|
+
* stuck forever), so the install has a deadline and streams its latest line
|
|
8
|
+
* to the caller as evidence that it is moving.
|
|
9
|
+
*/
|
|
10
|
+
import { access, constants } from "node:fs/promises";
|
|
11
|
+
import type { InstallOutcome } from "../core/update-check.ts";
|
|
12
|
+
|
|
13
|
+
/** A global bun/npm install normally takes seconds; three minutes covers a slow registry. Assumed. */
|
|
14
|
+
export const INSTALL_TIMEOUT_MS = 180_000;
|
|
15
|
+
/** Lines of package-manager output kept for a failure message: enough for an npm error block. */
|
|
16
|
+
const TAIL_LINES = 6;
|
|
17
|
+
|
|
18
|
+
export interface InstallOptions {
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
readonly timeoutMs?: number;
|
|
21
|
+
/** The package manager's newest non-empty output line. */
|
|
22
|
+
readonly onLine?: (line: string) => void;
|
|
23
|
+
/** Refuse before spawning when this directory cannot be written, as pi does. */
|
|
24
|
+
readonly writableDir?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g;
|
|
28
|
+
|
|
29
|
+
export async function runInstall(argv: readonly string[], options: InstallOptions = {}): Promise<InstallOutcome> {
|
|
30
|
+
if (options.signal?.aborted) return { kind: "cancelled" };
|
|
31
|
+
if (options.writableDir) {
|
|
32
|
+
try { await access(options.writableDir, constants.W_OK); }
|
|
33
|
+
catch { return { kind: "unavailable", reason: `${options.writableDir} is not writable by this user` }; }
|
|
34
|
+
}
|
|
35
|
+
// Resolve through PATH like the person's own shell would; a bun-run xeno can always fall back to its own bun.
|
|
36
|
+
const program = Bun.which(argv[0]!) ?? (argv[0] === "bun" ? process.execPath : null);
|
|
37
|
+
if (!program) return { kind: "unavailable", reason: `${argv[0]} was not found on PATH` };
|
|
38
|
+
let child: ReturnType<typeof Bun.spawn<"ignore", "pipe", "pipe">>;
|
|
39
|
+
try { child = Bun.spawn([program, ...argv.slice(1)], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); }
|
|
40
|
+
catch (error) { return { kind: "unavailable", reason: error instanceof Error ? error.message : String(error) }; }
|
|
41
|
+
|
|
42
|
+
const output: string[] = [];
|
|
43
|
+
const read = async (stream: ReadableStream<Uint8Array>) => {
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
let pending = "";
|
|
46
|
+
const take = (line: string) => {
|
|
47
|
+
const clean = line.replace(ANSI, "").trim();
|
|
48
|
+
if (!clean) return;
|
|
49
|
+
output.push(clean); if (output.length > TAIL_LINES) output.shift();
|
|
50
|
+
options.onLine?.(clean);
|
|
51
|
+
};
|
|
52
|
+
for await (const chunk of stream) {
|
|
53
|
+
const lines = (pending + decoder.decode(chunk, { stream: true })).split("\n");
|
|
54
|
+
pending = lines.pop()!;
|
|
55
|
+
lines.forEach(take);
|
|
56
|
+
}
|
|
57
|
+
take(pending + decoder.decode());
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let stopped: "timeout" | "cancelled" | undefined;
|
|
61
|
+
const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
|
|
62
|
+
const timer = setTimeout(() => { stopped = "timeout"; child.kill(); }, timeoutMs);
|
|
63
|
+
const abort = () => { stopped ??= "cancelled"; child.kill(); };
|
|
64
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
65
|
+
try {
|
|
66
|
+
const [code] = await Promise.all([child.exited, read(child.stdout), read(child.stderr)]);
|
|
67
|
+
if (stopped === "timeout") return { kind: "timeout", ms: timeoutMs, output };
|
|
68
|
+
if (stopped === "cancelled") return { kind: "cancelled" };
|
|
69
|
+
return code === 0 ? { kind: "installed" } : { kind: "exit", code, output };
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
options.signal?.removeEventListener("abort", abort);
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/tui/ui/dialog.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/tui/ui/screen.ts
CHANGED
|
@@ -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; readonly detail?: string } | undefined;
|
|
99
100
|
private home: Greeting | undefined;
|
|
100
101
|
private greetingPlayed = false;
|
|
101
102
|
private greetingFrame = MASCOT_FRAMES;
|
|
@@ -348,6 +349,13 @@ export class Screen {
|
|
|
348
349
|
this.paintEdges();
|
|
349
350
|
}
|
|
350
351
|
|
|
352
|
+
/** `startedAt` keeps the elapsed clock running when a long activity only refreshes its detail line. */
|
|
353
|
+
setTransientActivity(activity: { readonly label: string; readonly timeoutMs: number; readonly startedAt?: number; readonly detail?: string } | undefined): void {
|
|
354
|
+
this.transientActivity = activity ? { ...activity, startedAt: activity.startedAt ?? Date.now() } : undefined;
|
|
355
|
+
this.syncActivity();
|
|
356
|
+
this.paintEdges();
|
|
357
|
+
}
|
|
358
|
+
|
|
351
359
|
private paintEdges(): void {
|
|
352
360
|
const width = Math.max(2, this.renderer.width - (this.shape.tier === "sidebar" ? this.shape.sidebarWidth : 0) - 2);
|
|
353
361
|
const d = this.dockState, dialog = this.dialogs.open;
|
|
@@ -371,8 +379,11 @@ export class Screen {
|
|
|
371
379
|
const seconds = Math.max(0, Math.floor((Date.now() - this.busySince) / 1000));
|
|
372
380
|
const activity = this.status?.activity;
|
|
373
381
|
const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
|
|
374
|
-
|
|
382
|
+
const transient = this.transientActivity ? ` · ${this.transientActivityText()}` : "";
|
|
383
|
+
left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
|
|
375
384
|
right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
|
|
385
|
+
} else if (this.transientActivity) {
|
|
386
|
+
left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${this.transientActivityText()}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
|
|
376
387
|
} else {
|
|
377
388
|
// A draft taller than the box says what scrolled out of view (fix-tui-composer-growth DoD 4).
|
|
378
389
|
const { above, below } = this.composer.overflow;
|
|
@@ -397,7 +408,7 @@ export class Screen {
|
|
|
397
408
|
|
|
398
409
|
/** The clock runs only while something on screen shows the activity glyph. */
|
|
399
410
|
private syncActivity(): void {
|
|
400
|
-
const wanted = this.animations && (this.dockState.busy || this.dialogs.animated);
|
|
411
|
+
const wanted = this.animations && (this.dockState.busy || this.transientActivity !== undefined || this.dialogs.animated);
|
|
401
412
|
if (wanted && !this.activity.running) { this.lastEvent = Date.now(); this.startClock(ACTIVITY_MS); }
|
|
402
413
|
else if (!wanted && this.activity.running) { this.activity.stop(); this.paintEdges(); }
|
|
403
414
|
}
|
|
@@ -413,6 +424,13 @@ export class Screen {
|
|
|
413
424
|
}, undefined, intervalMs);
|
|
414
425
|
}
|
|
415
426
|
|
|
427
|
+
private transientActivityText(): string {
|
|
428
|
+
const activity = this.transientActivity!;
|
|
429
|
+
const elapsed = Math.max(0, Math.floor((Date.now() - activity.startedAt) / 1000));
|
|
430
|
+
const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
|
|
431
|
+
return `${activity.label} · ${elapsed}s / ${deadline}s max${activity.detail ? ` · ${activity.detail}` : ""}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
416
434
|
/** The spinner's current interval (tests). */
|
|
417
435
|
get activityInterval(): number { return this.activity.running ? this.activityMs : 0; }
|
|
418
436
|
|