@visual-z/xeno 0.8.1 → 0.8.3
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 +66 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +31 -6
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +27 -3
- package/src/core/command-contract.ts +121 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/environment.ts +7 -0
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +131 -21
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +64 -3
- package/src/core/prompt-policy.ts +11 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/secrets.ts +188 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +46 -4
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +25 -4
- package/src/core/transcript.ts +3 -0
- package/src/core/update-check.ts +2 -1
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +69 -19
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +196 -26
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +139 -31
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/command.ts +3 -1
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/search.ts +9 -5
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
- package/src/tui/ui/screen.ts +6 -3
- package/src/tui/ui/status.ts +16 -0
package/src/store/search.ts
CHANGED
|
@@ -18,9 +18,12 @@ import { lstat, readdir, readFile } from "node:fs/promises";
|
|
|
18
18
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
19
19
|
import { Worker } from "node:worker_threads";
|
|
20
20
|
import { assertNever } from "../core/exhaustive.ts";
|
|
21
|
+
import { jsPatternFromRipgrep } from "../core/regex-dialect.ts";
|
|
21
22
|
import { buildFileIndex, gitIgnored } from "./file-index.ts";
|
|
22
23
|
import { DEFAULT_IGNORE } from "./workspace.ts";
|
|
23
24
|
import { blobAddress } from "./blobs.ts";
|
|
25
|
+
import { configDir } from "./config.ts";
|
|
26
|
+
import { findRipgrep } from "./tool-binaries.ts";
|
|
24
27
|
import { previewSearchObservation, type SearchObservation } from "../core/search-observation.ts";
|
|
25
28
|
import { failureDiagnostic, failureFromError, READ_ATTEMPTED, NOT_STARTED } from "../core/tool-failure.ts";
|
|
26
29
|
import type { ToolDiagnostic } from "../core/tool-contract.ts";
|
|
@@ -459,7 +462,7 @@ function rgText(value: Record<string, unknown>): string {
|
|
|
459
462
|
}
|
|
460
463
|
|
|
461
464
|
async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<SearchResult | null> {
|
|
462
|
-
const rg =
|
|
465
|
+
const rg = findRipgrep(configDir());
|
|
463
466
|
if (rg === null) return null;
|
|
464
467
|
const args = [
|
|
465
468
|
"--json",
|
|
@@ -491,8 +494,9 @@ function compareMatch(a: MatchLine, b: MatchLine): number {
|
|
|
491
494
|
}
|
|
492
495
|
|
|
493
496
|
async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
|
|
497
|
+
const pattern = jsPatternFromRipgrep(opts.pattern);
|
|
494
498
|
try {
|
|
495
|
-
new RegExp(
|
|
499
|
+
new RegExp(pattern);
|
|
496
500
|
} catch (err) {
|
|
497
501
|
return searchFailure("invalid_input", `invalid regex: ${(err as Error).message}`);
|
|
498
502
|
}
|
|
@@ -538,7 +542,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
|
|
|
538
542
|
// Reserve the worst-case formatted hits before matching, never discard already observed hits.
|
|
539
543
|
const reservation = Buffer.byteLength(text) + lines.length * (Buffer.byteLength(fileRel) + String(MAX_FILE_BYTES).length + 3);
|
|
540
544
|
if (capturedBytes + reservation > budget) { reasons.push({ code: "capture_budget", path: fileRel }); break; }
|
|
541
|
-
const matched = await matcher.match(
|
|
545
|
+
const matched = await matcher.match(pattern, "", lines, deadline - performance.now(), opts.signal);
|
|
542
546
|
if (matched === "cancelled") { reasons.push({ code: "cancelled", path: fileRel }); break; }
|
|
543
547
|
if (matched === "timeout") {
|
|
544
548
|
reasons.push({ code: "match_timeout", path: fileRel });
|
|
@@ -580,11 +584,11 @@ export async function grep(opts: GrepOpts): Promise<SearchResult> {
|
|
|
580
584
|
return grepWalk(opts, opts.path, target.kind, target.rel, limit);
|
|
581
585
|
case "ripgrep": {
|
|
582
586
|
const got = await grepRipgrep(opts, opts.path, limit);
|
|
583
|
-
if (got === null) return searchFailure("environment_missing", "ripgrep is not on PATH");
|
|
587
|
+
if (got === null) return searchFailure("environment_missing", "ripgrep is not on PATH or in xeno's bin directory");
|
|
584
588
|
return got;
|
|
585
589
|
}
|
|
586
590
|
case "auto": {
|
|
587
|
-
if (
|
|
591
|
+
if (findRipgrep(configDir()) !== null) {
|
|
588
592
|
const got = await grepRipgrep(opts, opts.path, limit);
|
|
589
593
|
if (got !== null) return got;
|
|
590
594
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-session vault behind `[[xeno-secret-N]]` placeholders (S.9).
|
|
3
|
+
*
|
|
4
|
+
* A separate append-only file, never a log event: projections, handoffs,
|
|
5
|
+
* exports and history pages read the log, so a value kept here cannot reach a
|
|
6
|
+
* model request or a shared transcript. 0600 like every session file. It is
|
|
7
|
+
* read back at start so a placeholder written by an earlier run still expands
|
|
8
|
+
* after a restart.
|
|
9
|
+
*/
|
|
10
|
+
import { copyFile, readFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { durableAppend } from "./atomic.ts";
|
|
13
|
+
import type { SecretEntry } from "../core/secrets.ts";
|
|
14
|
+
|
|
15
|
+
export const SECRETS_FILE = "secrets.jsonl";
|
|
16
|
+
|
|
17
|
+
export async function readVault(dir: string): Promise<SecretEntry[]> {
|
|
18
|
+
let text: string;
|
|
19
|
+
try { text = await readFile(join(dir, SECRETS_FILE), "utf8"); }
|
|
20
|
+
catch (error) { if ((error as { code?: string }).code === "ENOENT") return []; throw error; }
|
|
21
|
+
const entries: SecretEntry[] = [];
|
|
22
|
+
for (const line of text.split("\n")) {
|
|
23
|
+
if (!line) continue;
|
|
24
|
+
// A torn final line from a crash loses that one entry; its placeholder then
|
|
25
|
+
// stays literal rather than expanding to something else.
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(line) as Partial<SecretEntry>;
|
|
28
|
+
if (Number.isSafeInteger(value.n) && typeof value.value === "string" && typeof value.rule === "string" &&
|
|
29
|
+
!entries.some(entry => entry.n === value.n)) entries.push({ n: value.n!, value: value.value, rule: value.rule });
|
|
30
|
+
} catch { /* see above */ }
|
|
31
|
+
}
|
|
32
|
+
return entries;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function appendVault(dir: string, entries: readonly SecretEntry[]): Promise<void> {
|
|
36
|
+
if (entries.length === 0) return;
|
|
37
|
+
await durableAppend(join(dir, SECRETS_FILE), entries.map(entry => JSON.stringify(entry) + "\n").join(""));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A fork's log carries the parent's placeholders, so it needs their values. */
|
|
41
|
+
export async function copyVault(from: string, to: string): Promise<void> {
|
|
42
|
+
try { await copyFile(join(from, SECRETS_FILE), join(to, SECRETS_FILE)); }
|
|
43
|
+
catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
|
|
44
|
+
}
|
package/src/store/sessions.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
readCursor,
|
|
36
36
|
readCursorAt,
|
|
37
37
|
readNode,
|
|
38
|
+
forkedAt,
|
|
38
39
|
writeCursor,
|
|
39
40
|
setParentSession,
|
|
40
41
|
type Manifest,
|
|
@@ -44,6 +45,7 @@ import { replayContext } from "./replay.ts";
|
|
|
44
45
|
import { recoverCut } from "./recovery.ts";
|
|
45
46
|
import { planSessionEntry, type SessionEntryFlags } from "../core/session-entry.ts";
|
|
46
47
|
import { assertNever } from "../core/exhaustive.ts";
|
|
48
|
+
import type { LogEvent } from "../core/events.ts";
|
|
47
49
|
import { resolveHistoryPosition } from "./session-position.ts";
|
|
48
50
|
import { importSessionArchive } from "./session-archive.ts";
|
|
49
51
|
|
|
@@ -86,6 +88,10 @@ export type ListedSession =
|
|
|
86
88
|
workspace?: string;
|
|
87
89
|
parent_session?: string;
|
|
88
90
|
text?: string;
|
|
91
|
+
/** Details only: whether any node holds a user event (fix-skip-empty-sessions). */
|
|
92
|
+
conversation?: boolean;
|
|
93
|
+
/** Details only, forks only: when the copy was made (fix-fork-start-time). */
|
|
94
|
+
forked_at?: string;
|
|
89
95
|
}
|
|
90
96
|
| {
|
|
91
97
|
ok: false;
|
|
@@ -370,8 +376,7 @@ export async function resolveLogDir(
|
|
|
370
376
|
return { logDir: created.path, created: true };
|
|
371
377
|
}
|
|
372
378
|
case "continue": {
|
|
373
|
-
const
|
|
374
|
-
const newest = sessions.find((s) => s.ok);
|
|
379
|
+
const newest = await newestConversation(workspace, opts.sessionDir);
|
|
375
380
|
if (newest !== undefined) return { logDir: newest.path, created: false };
|
|
376
381
|
break;
|
|
377
382
|
}
|
|
@@ -399,7 +404,8 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
399
404
|
if ((err as { code?: string }).code === "ENOENT") {
|
|
400
405
|
return {
|
|
401
406
|
ok: true, id, path,
|
|
402
|
-
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
407
|
+
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
408
|
+
...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
|
|
403
409
|
};
|
|
404
410
|
}
|
|
405
411
|
return { ok: false, id, path, error: `${path} could not be read: ${(err as Error).message}` };
|
|
@@ -414,7 +420,8 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
414
420
|
if (cursor === undefined) {
|
|
415
421
|
return {
|
|
416
422
|
ok: true, id, path,
|
|
417
|
-
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
423
|
+
name: null, model: null, node: null, events: 0, updated_at: null,
|
|
424
|
+
...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
|
|
418
425
|
};
|
|
419
426
|
}
|
|
420
427
|
|
|
@@ -423,15 +430,18 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
423
430
|
// bad tree is the same shape as a list that throws for one bad file.
|
|
424
431
|
const events = (await readNode(path, cursor.node)).events.length;
|
|
425
432
|
const text: string[] = [];
|
|
433
|
+
let conversation = false;
|
|
426
434
|
if (details) for (const node of await listNodes(path)) {
|
|
427
435
|
if (details.signal?.aborted) break;
|
|
428
436
|
const read = await readNode(path, node);
|
|
429
437
|
if (read.corrupt.length) throw new Error(`${path} has corrupt events`);
|
|
430
438
|
for (const event of read.events) {
|
|
439
|
+
if (userActivity(event)) conversation = true;
|
|
431
440
|
if (event.t === "user") text.push(event.text);
|
|
432
441
|
else if (event.t === "assistant") for (const block of event.blocks) if (block.type === "text") text.push(block.text);
|
|
433
442
|
}
|
|
434
443
|
}
|
|
444
|
+
const forked = details ? await forkedAt(path) : null;
|
|
435
445
|
return {
|
|
436
446
|
ok: true,
|
|
437
447
|
id,
|
|
@@ -441,11 +451,44 @@ export async function readListed(id: string, path: string, details?: { workspace
|
|
|
441
451
|
node: cursor.node,
|
|
442
452
|
events,
|
|
443
453
|
updated_at: cursor.updated_at,
|
|
444
|
-
...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"),
|
|
445
|
-
...(manifest.parent_session ? { parent_session: manifest.parent_session } : {})
|
|
454
|
+
...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"), conversation,
|
|
455
|
+
...(manifest.parent_session ? { parent_session: manifest.parent_session } : {}),
|
|
456
|
+
...(forked === null ? {} : { forked_at: forked }) } : {}),
|
|
446
457
|
};
|
|
447
458
|
}
|
|
448
459
|
|
|
460
|
+
/**
|
|
461
|
+
* A session the user opened and left without a word still has a header,
|
|
462
|
+
* cursor and manifest: Session.start writes them before the first message.
|
|
463
|
+
* Pi avoids such files by not persisting until a message exists; xeno keeps
|
|
464
|
+
* its write order and filters on read instead, like Codex's resume picker,
|
|
465
|
+
* so -c and the pickers land on a conversation (fix-skip-empty-sessions).
|
|
466
|
+
* A shell command the user ran counts: it is their activity, not setup.
|
|
467
|
+
*/
|
|
468
|
+
function userActivity(event: LogEvent): boolean {
|
|
469
|
+
return event.t === "user" || event.t === "user_bash";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Stops at the first node holding user activity, so a real session costs one
|
|
474
|
+
* node read. An unreadable node counts as activity: hiding a session we
|
|
475
|
+
* cannot judge would silently skip it, while opening it reports the error.
|
|
476
|
+
*/
|
|
477
|
+
export async function hasConversation(path: string): Promise<boolean> {
|
|
478
|
+
for (const node of await listNodes(path)) {
|
|
479
|
+
try { if ((await readNode(path, node)).events.some(userActivity)) return true; }
|
|
480
|
+
catch { return true; }
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** What -c continues: the newest readable session the user actually spoke in. */
|
|
486
|
+
export async function newestConversation(workspace: string, sessionDir?: string): Promise<Extract<ListedSession, { ok: true }> | undefined> {
|
|
487
|
+
const { sessions } = await listSessions(workspace, sessionDir, Infinity);
|
|
488
|
+
for (const session of sessions) if (session.ok && await hasConversation(session.path)) return session;
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
449
492
|
function byRecordedTime(a: ListedSession, b: ListedSession): number {
|
|
450
493
|
const ta = a.ok ? a.updated_at : null;
|
|
451
494
|
const tb = b.ok ? b.updated_at : null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xeno's own copy of ripgrep, for machines that have none.
|
|
3
|
+
*
|
|
4
|
+
* Where it lives: `<configDir>/bin`, next to settings and auth, so one
|
|
5
|
+
* install serves every workspace. Who sees it: the grep tool (after PATH)
|
|
6
|
+
* and shell commands, whose PATH gets the directory appended -- appended so a
|
|
7
|
+
* user who later installs rg keeps theirs (core/command-contract.ts).
|
|
8
|
+
*
|
|
9
|
+
* The network fetch is injected: store owns the filesystem, the caller owns
|
|
10
|
+
* the request (as runtime/update-check.ts does for the registry).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { ripgrepAsset, type RipgrepAsset } from "../core/ripgrep-release.ts";
|
|
17
|
+
|
|
18
|
+
export function xenoBinDir(configDir: string): string {
|
|
19
|
+
return join(configDir, "bin");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function onPath(name: string, pathEnv: string): string | null {
|
|
23
|
+
for (const dir of pathEnv.split(process.platform === "win32" ? ";" : ":")) {
|
|
24
|
+
if (dir.length === 0) continue;
|
|
25
|
+
const candidate = join(dir, name);
|
|
26
|
+
if (existsSync(candidate)) return candidate;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The user's rg first, then xeno's; null when neither exists. */
|
|
32
|
+
export function findRipgrep(configDir: string, pathEnv = process.env["PATH"] ?? ""): string | null {
|
|
33
|
+
const own = join(xenoBinDir(configDir), "rg");
|
|
34
|
+
return onPath("rg", pathEnv) ?? (existsSync(own) ? own : null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ProvisionResult =
|
|
38
|
+
| { ok: true; path: string; installed: boolean }
|
|
39
|
+
| { ok: false; error: string };
|
|
40
|
+
|
|
41
|
+
export interface ProvisionOptions {
|
|
42
|
+
configDir: string;
|
|
43
|
+
fetchBytes: (url: string, signal?: AbortSignal) => Promise<Uint8Array>;
|
|
44
|
+
/** Defaults to the user's PATH; injected so tests are not decided by the machine. */
|
|
45
|
+
path?: string;
|
|
46
|
+
/** Defaults to the pinned build for this platform. */
|
|
47
|
+
asset?: RipgrepAsset | null;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Installs the pinned ripgrep unless one is already reachable.
|
|
53
|
+
*
|
|
54
|
+
* Nothing becomes visible until it is proven: the archive is hashed before
|
|
55
|
+
* extraction, only the one member is extracted, and the executable reaches
|
|
56
|
+
* `bin/rg` by rename, so a crash or a mismatch never leaves a partial or
|
|
57
|
+
* unverified binary where the shell would find it.
|
|
58
|
+
*/
|
|
59
|
+
export async function provisionRipgrep(options: ProvisionOptions): Promise<ProvisionResult> {
|
|
60
|
+
const existing = findRipgrep(options.configDir, options.path);
|
|
61
|
+
if (existing !== null) return { ok: true, path: existing, installed: false };
|
|
62
|
+
const asset = options.asset === undefined ? ripgrepAsset(process.platform, process.arch) : options.asset;
|
|
63
|
+
if (asset === null) return { ok: false, error: `no pinned ripgrep build for ${process.platform} ${process.arch}` };
|
|
64
|
+
|
|
65
|
+
const bin = xenoBinDir(options.configDir);
|
|
66
|
+
await mkdir(bin, { recursive: true });
|
|
67
|
+
// Inside bin, so the final rename stays on one filesystem and is atomic.
|
|
68
|
+
const stage = await mkdtemp(join(bin, ".rg-install-"));
|
|
69
|
+
try {
|
|
70
|
+
const bytes = await options.fetchBytes(asset.url, options.signal);
|
|
71
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
72
|
+
if (digest !== asset.sha256) return { ok: false, error: `ripgrep archive sha256 ${digest} does not match pinned ${asset.sha256}` };
|
|
73
|
+
const archive = join(stage, "rg.tar.gz");
|
|
74
|
+
await writeFile(archive, bytes);
|
|
75
|
+
const extracted = Bun.spawnSync(["tar", "-xzf", archive, "-C", stage, asset.member], { stdout: "ignore", stderr: "pipe" });
|
|
76
|
+
if (extracted.exitCode !== 0) return { ok: false, error: `cannot extract ripgrep: ${extracted.stderr.toString().trim()}` };
|
|
77
|
+
const executable = join(stage, asset.member);
|
|
78
|
+
// The digest pins the archive, but chmod and rename follow what tar
|
|
79
|
+
// wrote; a member that is not a plain file is refused before either.
|
|
80
|
+
if (!(await lstat(executable)).isFile()) return { ok: false, error: "ripgrep archive member is not a regular file" };
|
|
81
|
+
await chmod(executable, 0o755);
|
|
82
|
+
const target = join(bin, "rg");
|
|
83
|
+
await rename(executable, target);
|
|
84
|
+
return { ok: true, path: target, installed: true };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { ok: false, error: `cannot install ripgrep: ${error instanceof Error ? error.message : String(error)}` };
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(stage, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/store/worktree.ts
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
* is for cooperative branches, not adversarial ones.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { mkdtemp, rm, realpath } from "node:fs/promises";
|
|
24
|
-
import {
|
|
23
|
+
import { mkdir, mkdtemp, rm, realpath } from "node:fs/promises";
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { basename, join } from "node:path";
|
|
25
27
|
import { tmpdir } from "node:os";
|
|
26
28
|
|
|
27
29
|
/** Branch prefix, so a stale worktree is identifiable months later. */
|
|
@@ -37,6 +39,37 @@ export type CreateResult =
|
|
|
37
39
|
| { ok: true; path: string; branch: string; snapshot: { head: string; tree: string } }
|
|
38
40
|
| { ok: false; error: string };
|
|
39
41
|
|
|
42
|
+
export interface CreateOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Where the checkout goes. Default: a fresh temp directory, which suits a
|
|
45
|
+
* writing branch that lives for one subtask. A model-entered worktree is
|
|
46
|
+
* kept across sessions, so it names a stable parent and its own leaf.
|
|
47
|
+
*/
|
|
48
|
+
location?: { parent: string; leaf: string };
|
|
49
|
+
/** Carry the parent's uncommitted files (default, as a branch needs). */
|
|
50
|
+
includeChanges?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The stable home of model-entered worktrees for one repository.
|
|
55
|
+
*
|
|
56
|
+
* Under xeno's config directory rather than the project: inside `.xeno` it
|
|
57
|
+
* would collide with the rule that session state is never edited, and a
|
|
58
|
+
* sibling directory leaves xeno's checkouts in the user's project tree (Codex
|
|
59
|
+
* keeps its worktrees under its own home for the same reason). The digest of
|
|
60
|
+
* the repository path keeps two clones with the same name apart.
|
|
61
|
+
*/
|
|
62
|
+
export function persistentWorktreeParent(configDir: string, repo: string): string {
|
|
63
|
+
const digest = createHash("sha256").update(repo).digest("hex").slice(0, 8);
|
|
64
|
+
return join(configDir, "worktrees", `${basename(repo)}-${digest}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The repository's main checkout: `git worktree list` always lists it first. */
|
|
68
|
+
export async function mainWorktree(dir: string): Promise<string | null> {
|
|
69
|
+
const listed = await listWorktrees(dir).catch(() => []);
|
|
70
|
+
return listed[0]?.path ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
export type RemoveResult =
|
|
41
74
|
| { ok: true }
|
|
42
75
|
| { ok: false; error: string; dirty: boolean };
|
|
@@ -61,7 +94,7 @@ export async function isGitRepo(dir: string): Promise<boolean> {
|
|
|
61
94
|
return git(dir, ["rev-parse", "--git-dir"]).code === 0;
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
export async function createWorktree(repo: string, name: string): Promise<CreateResult> {
|
|
97
|
+
export async function createWorktree(repo: string, name: string, options: CreateOptions = {}): Promise<CreateResult> {
|
|
65
98
|
// Refused rather than degraded. Falling back to the shared directory is the
|
|
66
99
|
// exact failure isolation exists to prevent: parallel writing branches
|
|
67
100
|
// would overwrite each other with nothing to indicate isolation was never
|
|
@@ -107,13 +140,24 @@ export async function createWorktree(repo: string, name: string): Promise<Create
|
|
|
107
140
|
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
108
141
|
|
|
109
142
|
const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
143
|
+
if (git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).code === 0) {
|
|
144
|
+
return { ok: false, error: `branch ${branch} already exists; choose another name or work in its existing checkout` };
|
|
145
|
+
}
|
|
146
|
+
let path: string;
|
|
147
|
+
if (options.location) {
|
|
148
|
+
await mkdir(options.location.parent, { recursive: true });
|
|
149
|
+
// Resolved for the same reason as the temp path below.
|
|
150
|
+
path = join(await realpath(options.location.parent), options.location.leaf);
|
|
151
|
+
if (existsSync(path)) return { ok: false, error: `${path} already exists; choose another name` };
|
|
152
|
+
} else {
|
|
153
|
+
const created = await mkdtemp(join(tmpdir(), `xeno-wt-${name}-`));
|
|
154
|
+
// Resolved through symlinks, because git reports the real path and a
|
|
155
|
+
// caller comparing the two would never match. On macOS /var is a symlink
|
|
156
|
+
// to /private/var, so every temp path differs by default.
|
|
157
|
+
path = await realpath(created);
|
|
158
|
+
// mkdtemp created it; git worktree add needs the path not to exist.
|
|
159
|
+
await rm(path, { recursive: true, force: true });
|
|
160
|
+
}
|
|
117
161
|
|
|
118
162
|
// --lock in the same call: locking afterwards leaves a window in which a
|
|
119
163
|
// concurrent prune can remove the worktree that was just created.
|
|
@@ -121,6 +165,7 @@ export async function createWorktree(repo: string, name: string): Promise<Create
|
|
|
121
165
|
if (add.code !== 0) {
|
|
122
166
|
return { ok: false, error: `git worktree add failed: ${add.stderr.trim()}` };
|
|
123
167
|
}
|
|
168
|
+
if (options.includeChanges === false) return { ok: true, path, branch, snapshot: { head: head.stdout.trim(), tree: git(path, ["rev-parse", "HEAD^{tree}"]).stdout.trim() } };
|
|
124
169
|
// Only this freshly created disposable checkout is reset. The child index
|
|
125
170
|
// carries the parent's uncommitted input, so normal diff/inspection still sees it.
|
|
126
171
|
const hydrate = git(path, ["read-tree", "--reset", "-u", tree]);
|
package/src/tui/ui/image-size.ts
CHANGED
|
@@ -26,9 +26,13 @@ export function imageCells(pixels: { readonly width: number; readonly height: nu
|
|
|
26
26
|
return { cols: Math.max(1, Math.min(capCols, cols)), rows: Math.max(1, Math.min(capRows, rows)) };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Rows for pictures in the transcript: half the terminal, 4–20 (feat-tui-image-full-size).
|
|
31
|
+
* A quarter left a screenshot's text unreadable; 20 is Grok's ceiling, past which one picture
|
|
32
|
+
* pushes the conversation off screen. Assumed, not measured. Full size is a click away.
|
|
33
|
+
*/
|
|
30
34
|
export function transcriptImageRows(terminalRows: number): number {
|
|
31
|
-
return Math.max(4, Math.min(
|
|
35
|
+
return Math.max(4, Math.min(20, Math.floor(terminalRows / 2)));
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** Rows for the attachment strip above the input box: a sixth of the terminal, 3–6. Assumed. */
|
package/src/tui/ui/items.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
export type ToolStatus = "pending" | "ok" | "error";
|
|
9
9
|
|
|
10
10
|
/** A picture the view may draw: its blob address, name and pixel size (from the log's ImageRef). */
|
|
11
|
-
export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number
|
|
11
|
+
export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number;
|
|
12
|
+
/** The blob a click opens when it differs from `sha256`: what was attached, before any reduction for the model. */
|
|
13
|
+
readonly full?: string }
|
|
12
14
|
|
|
13
15
|
export type ViewItem =
|
|
14
16
|
| { readonly kind: "user"; readonly id: string; readonly rev: string; readonly text: string; readonly images?: readonly ImageView[]; readonly interrupted?: boolean }
|
package/src/tui/ui/pictures.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* pictures are off, or the terminal can only draw block characters, the
|
|
9
9
|
* row is a single muted line: `▣ shot.png · 1280×720 +2 more`.
|
|
10
10
|
*
|
|
11
|
+
* A click on a drawn picture asks the source to open its full-size original
|
|
12
|
+
* (docs/tasks/feat-tui-image-full-size.md): a thumbnail cannot show a
|
|
13
|
+
* screenshot's text, the system viewer can.
|
|
14
|
+
*
|
|
11
15
|
* The view never reads a file: the app's source loads and verifies bytes.
|
|
12
16
|
*/
|
|
13
17
|
import { BoxRenderable, ImageRenderable, type CliRenderer, type ImageRenderProtocol, type NativeImage } from "@opentui/core";
|
|
@@ -28,6 +32,8 @@ export interface PictureSource {
|
|
|
28
32
|
readonly reason?: string;
|
|
29
33
|
/** The decoded picture for a blob, shared by every node that shows it, or its preparation failure. */
|
|
30
34
|
load(sha256: string): Promise<PictureLoad>;
|
|
35
|
+
/** Show a blob at full size outside the terminal; absent where nothing can (tests, print mode). */
|
|
36
|
+
open?(sha256: string): void;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export type PictureLoad = { readonly kind: "ready"; readonly image: NativeImage } |
|
|
@@ -72,7 +78,7 @@ export class Pictures extends BoxRenderable {
|
|
|
72
78
|
const size = imageCells(image, Math.min(each, source.widthCells), options.maxRows, source.cell);
|
|
73
79
|
const picture = new ImageRenderable(ctx, { id: `${id}-${image.sha256.slice(0, 8)}-${serial++}`, width: size.cols, height: size.rows,
|
|
74
80
|
fit: "fit", protocol: source.protocol, flexShrink: 0,
|
|
75
|
-
onLoad: () => { states[index] = "decoded"; if (!caption.isDestroyed) paintCaption(); },
|
|
81
|
+
onLoad: () => { states[index] = source.open ? "click to open" : "decoded"; if (!caption.isDestroyed) paintCaption(); },
|
|
76
82
|
onError: () => { states[index] = "preview failed: decode"; picture.visible = false; if (!caption.isDestroyed) paintCaption(); } });
|
|
77
83
|
void source.load(image.sha256).then(result => {
|
|
78
84
|
if (picture.isDestroyed) return;
|
|
@@ -82,6 +88,13 @@ export class Pictures extends BoxRenderable {
|
|
|
82
88
|
if (picture.isDestroyed) return;
|
|
83
89
|
picture.visible = false; states[index] = "preview failed: decode"; paintCaption();
|
|
84
90
|
});
|
|
91
|
+
const open = source.open;
|
|
92
|
+
if (open) {
|
|
93
|
+
// Same rule as a tool card's head: press and release on one cell is a click; anything else is a selection.
|
|
94
|
+
let down: { x: number; y: number } | undefined;
|
|
95
|
+
picture.onMouseDown = event => { down = { x: event.x, y: event.y }; };
|
|
96
|
+
picture.onMouseUp = event => { if (down && down.x === event.x && down.y === event.y && picture.image) open(image.full ?? image.sha256); down = undefined; };
|
|
97
|
+
}
|
|
85
98
|
pictures.push(picture);
|
|
86
99
|
row.add(picture);
|
|
87
100
|
}
|
package/src/tui/ui/screen.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { screenLayout, sidebarContentWidth, type ScreenLayout } from "./layout.t
|
|
|
31
31
|
import { greetingLines, mascotFrame, MASCOT_FRAME_MS, MASCOT_FRAMES, type Greeting } from "./mascot.ts";
|
|
32
32
|
import { MessageList, type MessageFeatures } from "./messages.ts";
|
|
33
33
|
import { Motion } from "./motion.ts";
|
|
34
|
-
import { contextLabel, goalLabel, modeLabels, sidebarLines, speedText, topbarRow, type Line, type StatusModel } from "./status.ts";
|
|
34
|
+
import { contextLabel, goalLabel, idleActivityLabel, modeLabels, sidebarLines, speedText, topbarRow, type Line, type StatusModel } from "./status.ts";
|
|
35
35
|
import { DEFAULT_CELL, stripImageRows } from "./image-size.ts";
|
|
36
36
|
import { formatElapsed } from "./duration.ts";
|
|
37
37
|
import { Pictures, type PictureSource } from "./pictures.ts";
|
|
@@ -105,6 +105,8 @@ export class Screen {
|
|
|
105
105
|
private readonly activity = new Motion();
|
|
106
106
|
private spin = 0;
|
|
107
107
|
private busySince = 0;
|
|
108
|
+
/** The last turn's wall time, shown beside its outcome until the next turn starts. */
|
|
109
|
+
private lastTurnMs: number | undefined;
|
|
108
110
|
private lastEvent = 0;
|
|
109
111
|
private activityMs = ACTIVITY_MS;
|
|
110
112
|
private animations = true;
|
|
@@ -337,7 +339,8 @@ export class Screen {
|
|
|
337
339
|
setDock(state: DockState): void {
|
|
338
340
|
const wasBusy = this.dockState.busy;
|
|
339
341
|
this.dockState = state;
|
|
340
|
-
if (state.busy && !wasBusy) this.busySince = Date.now();
|
|
342
|
+
if (state.busy && !wasBusy) { this.busySince = Date.now(); this.lastTurnMs = undefined; }
|
|
343
|
+
if (!state.busy && wasBusy) this.lastTurnMs = Date.now() - this.busySince;
|
|
341
344
|
this.syncActivity();
|
|
342
345
|
this.paintEdges();
|
|
343
346
|
}
|
|
@@ -393,7 +396,7 @@ export class Screen {
|
|
|
393
396
|
const { above, below } = this.composer.overflow;
|
|
394
397
|
if (above) left = [{ segs: [{ text: `↑ ${above} more`, role: "muted" }], priority: 1 }];
|
|
395
398
|
if (below && !this.toastText) right.push({ segs: [{ text: `↓ ${below} more`, role: "muted" }], priority: 2 });
|
|
396
|
-
if (!above && this.status?.activity) left = [
|
|
399
|
+
if (!above && this.status?.activity) left = [idleActivityLabel(this.status.activity, this.lastTurnMs)];
|
|
397
400
|
}
|
|
398
401
|
this.topEdge.setRows([edgeLine({ width, corners: ["╭", "╮"], role, left, right })]);
|
|
399
402
|
const bottomLeft: Label[] = [...(this.status ? modeLabels(this.status) : []), ...(d.model ? [
|
package/src/tui/ui/status.ts
CHANGED
|
@@ -385,3 +385,19 @@ export function wrap(text: string, width: number, lines: number): string[] {
|
|
|
385
385
|
kept[lines - 1] = `${displayWidth(last) + 1 <= w ? last : cells(last, w - 1).trimEnd()}…`;
|
|
386
386
|
return kept;
|
|
387
387
|
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The input box's top-edge label while no turn runs: the outcome in its own
|
|
391
|
+
* colour, then how long the last turn took (Codex "Worked for 2m 5s", Claude
|
|
392
|
+
* Code "Cooked for 1m 6s"). A plain grey "Turn finished" read the same as
|
|
393
|
+
* "nothing happened" (fix-finished-turn-status).
|
|
394
|
+
*/
|
|
395
|
+
export function idleActivityLabel(activity: NonNullable<StatusModel["activity"]>, turnMs?: number): Label {
|
|
396
|
+
const tone = activity.state === "done" ? { glyph: "✓", role: "success" as const }
|
|
397
|
+
: activity.state === "error" ? { glyph: "✗", role: "error" as const }
|
|
398
|
+
: activity.state === "blocked" ? { glyph: activity.label === "Stopped" ? "■" : "", role: "warning" as const }
|
|
399
|
+
: { glyph: "", role: "muted" as const };
|
|
400
|
+
const text = activity.label === "Stopped" && activity.detail ? `Stopped · ${activity.detail}` : activity.label;
|
|
401
|
+
return { segs: [...(tone.glyph ? [{ text: tone.glyph, role: tone.role }, { text: " ", role: "muted" as const }] : []), { text, role: tone.role },
|
|
402
|
+
...(turnMs !== undefined ? [{ text: ` · ${formatElapsed(turnMs)}`, role: "muted" as const }] : [])], priority: 1, min: 4, shrink: tone.glyph ? 2 : 0 };
|
|
403
|
+
}
|