@visual-z/xeno 0.8.1 → 0.8.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.
@@ -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 { join } from "node:path";
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
- const created = await mkdtemp(join(tmpdir(), `xeno-wt-${name}-`));
111
- // Resolved through symlinks, because git reports the real path and a
112
- // caller comparing the two would never match. On macOS /var is a symlink
113
- // to /private/var, so every temp path differs by default.
114
- const path = await realpath(created);
115
- // mkdtemp created it; git worktree add needs the path not to exist.
116
- await rm(path, { recursive: true, force: true });
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]);
@@ -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 = [{ segs: [{ text: this.status.activity.label, role: this.status.activity.state === "error" ? "error" : this.status.activity.state === "blocked" ? "warning" : "muted" }], priority: 1, min: 4 }];
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 ? [
@@ -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
+ }