skydive-cli 0.1.0-beta.380 → 0.1.0-beta.389

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.
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { A as HttpError, B as resolveWebUrl, C as noColorRequested, D as themeModeFromColorFgBg, E as themeMode, F as setActiveWorkspace, I as DEFAULT_API_URL, L as DEFAULT_APP_URL, M as errorDetail, N as getActiveWorkspaceId, O as themeVersion, P as listWorkspaces, R as getConfigPath, S as monoTheme, T as themeForMode, V as saveTheme, _ as errorMessage, b as applyTheme, d as cardActionErrorMessage, f as parseExternalOauthConnectParams, g as parseConnectCard, h as resolveConnectUrl, j as createRestClient, k as themesForMode, l as resolveAgent, m as reconcileMaskedInput, p as parseOauthConnectParams, t as SandboxStream, u as MASK_CHAR, v as isRecord, w as theme, x as findTheme, y as DEFAULT_THEME_ID, z as getSavedTheme } from "./bin.mjs";
3
- import { t as PortalClient } from "./client-XFsd0Wy9.mjs";
4
- import { n as runRawPtyPassthrough } from "./raw-pty-C1DXKms6.mjs";
2
+ import { D as getReviewStateDir, I as resolveWebUrl, O as getSavedTheme, S as DEFAULT_APP_URL, T as getConfigPath, _ as setActiveWorkspace, a as noColorRequested, c as themeMode, f as themesForMode, g as listWorkspaces, i as monoTheme, l as themeModeFromColorFgBg, m as getActiveWorkspaceId, n as applyTheme, o as theme, r as findTheme, s as themeForMode, t as DEFAULT_THEME_ID, u as themeVersion, x as DEFAULT_API_URL, z as saveTheme } from "./theme-DRuLtrTy.mjs";
3
+ import { n as createRestClient, r as errorDetail, t as HttpError } from "./rest-DTlkPko_.mjs";
4
+ import { n as isRecord, t as errorMessage } from "./util-z9Pne47f.mjs";
5
+ import { c as cardActionErrorMessage, d as reconcileMaskedInput, f as resolveConnectUrl, i as resolveAgent, l as parseExternalOauthConnectParams, p as parseConnectCard, s as MASK_CHAR, u as parseOauthConnectParams } from "./print-BhfjRrxI.mjs";
6
+ import "./api-DRpbKHz6.mjs";
7
+ import { t as SandboxStream } from "./client-BVOAwU8M.mjs";
8
+ import { t as PortalClient } from "./client-DyRs3o5E.mjs";
9
+ import { t as runRawPtyPassthrough } from "./raw-pty-Dbi2kb9v.mjs";
5
10
  import * as os$1 from "node:os";
6
11
  import { homedir, platform, release, tmpdir } from "node:os";
7
12
  import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
@@ -15,7 +20,7 @@ import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDime
15
20
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
16
21
  import { create } from "zustand";
17
22
  import { createConnection } from "node:net";
18
- import { access, appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
23
+ import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
19
24
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
20
25
  import fuzzysort from "fuzzysort";
21
26
  import { fileURLToPath } from "node:url";
@@ -2435,8 +2440,8 @@ function diffPalette() {
2435
2440
  }
2436
2441
  /** Blend `hex` toward `baseHex`, keeping `weight` (0..1) of `hex`. */
2437
2442
  function mix(hex, baseHex, weight) {
2438
- const a = parseHex(hex);
2439
- const b = parseHex(baseHex);
2443
+ const a = parseHex$1(hex);
2444
+ const b = parseHex$1(baseHex);
2440
2445
  const blend = (x, y) => Math.round(x * weight + y * (1 - weight));
2441
2446
  return `#${[
2442
2447
  blend(a[0], b[0]),
@@ -2444,7 +2449,7 @@ function mix(hex, baseHex, weight) {
2444
2449
  blend(a[2], b[2])
2445
2450
  ].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
2446
2451
  }
2447
- function parseHex(hex) {
2452
+ function parseHex$1(hex) {
2448
2453
  const h = hex.replace("#", "");
2449
2454
  const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
2450
2455
  return [
@@ -2454,6 +2459,74 @@ function parseHex(hex) {
2454
2459
  ];
2455
2460
  }
2456
2461
 
2462
+ //#endregion
2463
+ //#region src/chat/review/edit-pairs.ts
2464
+ function pickString(obj, keys) {
2465
+ for (const key of keys) {
2466
+ const value = obj[key];
2467
+ if (typeof value === "string") return value;
2468
+ }
2469
+ return null;
2470
+ }
2471
+ /** File path from whichever key the tool used. */
2472
+ function extractEditFilePath(input) {
2473
+ return pickString(input, [
2474
+ "filePath",
2475
+ "path",
2476
+ "file"
2477
+ ]);
2478
+ }
2479
+ /**
2480
+ * Pull old/new text pairs out of whichever input shape the tool used:
2481
+ * multi-edit `edits[]`, single `oldString`/`newString` (and aliases), or
2482
+ * write's bare `content`. Null means "shape not recognized" — callers
2483
+ * fall back to generic handling.
2484
+ */
2485
+ function extractEditPairs(toolName, input) {
2486
+ const edits = input["edits"];
2487
+ if (Array.isArray(edits)) {
2488
+ const pairs = [];
2489
+ for (const entry of edits) {
2490
+ if (!isRecord(entry)) continue;
2491
+ const oldText = pickString(entry, [
2492
+ "oldText",
2493
+ "oldString",
2494
+ "before"
2495
+ ]);
2496
+ const newText = pickString(entry, [
2497
+ "newText",
2498
+ "newString",
2499
+ "after"
2500
+ ]);
2501
+ if (oldText !== null && newText !== null) pairs.push({
2502
+ oldText,
2503
+ newText
2504
+ });
2505
+ }
2506
+ return pairs.length > 0 ? pairs : null;
2507
+ }
2508
+ const oldText = pickString(input, [
2509
+ "oldString",
2510
+ "oldText",
2511
+ "before"
2512
+ ]);
2513
+ const newText = pickString(input, [
2514
+ "newString",
2515
+ "newText",
2516
+ "after",
2517
+ "content"
2518
+ ]);
2519
+ if (oldText !== null && newText !== null) return [{
2520
+ oldText,
2521
+ newText
2522
+ }];
2523
+ if (toolName === "write" && newText !== null) return [{
2524
+ oldText: "",
2525
+ newText
2526
+ }];
2527
+ return null;
2528
+ }
2529
+
2457
2530
  //#endregion
2458
2531
  //#region src/chat/tui/chunks/tool-edit.tsx
2459
2532
  /**
@@ -2481,11 +2554,7 @@ function parseHex(hex) {
2481
2554
  function ToolEdit({ item }) {
2482
2555
  const glyph = toolGlyph(item.state);
2483
2556
  const input = isRecord(item.input) ? item.input : {};
2484
- const filePath = pickString(input, [
2485
- "filePath",
2486
- "path",
2487
- "file"
2488
- ]) ?? "(unknown file)";
2557
+ const filePath = extractEditFilePath(input) ?? "(unknown file)";
2489
2558
  const inputReady = item.state !== "streaming";
2490
2559
  const pairs = inputReady ? extractEditPairs(item.toolName, input) : null;
2491
2560
  const [expanded, setExpanded] = useState(false);
@@ -2566,56 +2635,6 @@ function ToolEdit({ item }) {
2566
2635
  });
2567
2636
  }
2568
2637
  /**
2569
- * Pull old/new text pairs out of whichever input shape the tool used:
2570
- * multi-edit `edits[]`, single `oldString`/`newString` (and aliases), or
2571
- * write's bare `content`. Null means "shape not recognized" — the caller
2572
- * falls back to the generic output summary.
2573
- */
2574
- function extractEditPairs(toolName, input) {
2575
- const edits = input["edits"];
2576
- if (Array.isArray(edits)) {
2577
- const pairs = [];
2578
- for (const entry of edits) {
2579
- if (!isRecord(entry)) continue;
2580
- const oldText = pickString(entry, [
2581
- "oldText",
2582
- "oldString",
2583
- "before"
2584
- ]);
2585
- const newText = pickString(entry, [
2586
- "newText",
2587
- "newString",
2588
- "after"
2589
- ]);
2590
- if (oldText !== null && newText !== null) pairs.push({
2591
- oldText,
2592
- newText
2593
- });
2594
- }
2595
- return pairs.length > 0 ? pairs : null;
2596
- }
2597
- const oldText = pickString(input, [
2598
- "oldString",
2599
- "oldText",
2600
- "before"
2601
- ]);
2602
- const newText = pickString(input, [
2603
- "newString",
2604
- "newText",
2605
- "after",
2606
- "content"
2607
- ]);
2608
- if (oldText !== null && newText !== null) return [{
2609
- oldText,
2610
- newText
2611
- }];
2612
- if (toolName === "write" && newText !== null) return [{
2613
- oldText: "",
2614
- newText
2615
- }];
2616
- return null;
2617
- }
2618
- /**
2619
2638
  * Diff each pair, sharing one line budget across all blocks. Pass an
2620
2639
  * override (e.g. MAX_SAFE_INTEGER when expanded) to lift the cap.
2621
2640
  */
@@ -2729,13 +2748,6 @@ function PlainDiff({ diff }) {
2729
2748
  }, idx))
2730
2749
  });
2731
2750
  }
2732
- function pickString(obj, keys) {
2733
- for (const key of keys) {
2734
- const value = obj[key];
2735
- if (typeof value === "string") return value;
2736
- }
2737
- return null;
2738
- }
2739
2751
 
2740
2752
  //#endregion
2741
2753
  //#region src/chat/tui/chunks/tool-bash.tsx
@@ -3878,6 +3890,21 @@ async function appendLineSafely(filePath, line) {
3878
3890
  } catch (_error) {}
3879
3891
  }
3880
3892
 
3893
+ //#endregion
3894
+ //#region src/chat/tui/composer-focus.ts
3895
+ /**
3896
+ * Take the keyboard away from `composer` until the returned restore
3897
+ * function runs. Restore only re-enables focusability — re-focusing is the
3898
+ * caller's decision (the chat screen's pin-focus effect does it).
3899
+ */
3900
+ function suppressComposerFocus(composer) {
3901
+ composer.blur();
3902
+ composer.focusable = false;
3903
+ return () => {
3904
+ composer.focusable = true;
3905
+ };
3906
+ }
3907
+
3881
3908
  //#endregion
3882
3909
  //#region src/chat/tui/keybinds.ts
3883
3910
  const keybindGroups = [
@@ -3916,6 +3943,10 @@ const keybindGroups = [
3916
3943
  keys: "ctrl+c ctrl+c",
3917
3944
  action: "quit"
3918
3945
  },
3946
+ {
3947
+ keys: "ctrl+g",
3948
+ action: "toggle file review pane"
3949
+ },
3919
3950
  {
3920
3951
  keys: "exit",
3921
3952
  action: "quit (type it as a message)"
@@ -3966,6 +3997,75 @@ const keybindGroups = [
3966
3997
  }
3967
3998
  ]
3968
3999
  },
4000
+ {
4001
+ title: "review",
4002
+ binds: [
4003
+ {
4004
+ keys: "j / k, ↑ / ↓",
4005
+ action: "move in focused files/source pane"
4006
+ },
4007
+ {
4008
+ keys: "/",
4009
+ action: "search files in active review tab"
4010
+ },
4011
+ {
4012
+ keys: "ctrl+d / ctrl+u",
4013
+ action: "half-page down / up in focused pane"
4014
+ },
4015
+ {
4016
+ keys: "] / [",
4017
+ action: "next / previous edit hunk"
4018
+ },
4019
+ {
4020
+ keys: "f / F",
4021
+ action: "next / previous changed file"
4022
+ },
4023
+ {
4024
+ keys: "v",
4025
+ action: "start / clear line selection"
4026
+ },
4027
+ {
4028
+ keys: "c",
4029
+ action: "comment on selection or current line"
4030
+ },
4031
+ {
4032
+ keys: "e / d",
4033
+ action: "edit / delete comment at cursor"
4034
+ },
4035
+ {
4036
+ keys: "s",
4037
+ action: "insert all comments into composer (either tab)"
4038
+ },
4039
+ {
4040
+ keys: "y",
4041
+ action: "copy comments to clipboard"
4042
+ },
4043
+ {
4044
+ keys: "t",
4045
+ action: "toggle session / last-turn scope (Changes)"
4046
+ },
4047
+ {
4048
+ keys: "1 / 2",
4049
+ action: "switch Changes / Files"
4050
+ },
4051
+ {
4052
+ keys: "↵ / o / l",
4053
+ action: "focus selected file preview"
4054
+ },
4055
+ {
4056
+ keys: "h / ←",
4057
+ action: "return focus to file navigator"
4058
+ },
4059
+ {
4060
+ keys: "esc",
4061
+ action: "preview → tree → close"
4062
+ },
4063
+ {
4064
+ keys: "q / ctrl+g",
4065
+ action: "close file pane"
4066
+ }
4067
+ ]
4068
+ },
3969
4069
  {
3970
4070
  title: "command menu (type / to open)",
3971
4071
  binds: [
@@ -4954,51 +5054,1996 @@ function ThemePicker({ onClose }) {
4954
5054
  }
4955
5055
 
4956
5056
  //#endregion
4957
- //#region src/chat/shell/snapshot.ts
5057
+ //#region src/chat/review/changeset.ts
5058
+ const hunkStatsCache = /* @__PURE__ */ new Map();
5059
+ const HUNK_STATS_CACHE_MAX = 4096;
5060
+ function hunkStats(hunk) {
5061
+ const key = `${hunk.toolCallId}:${hunk.pairIndex}:${hunk.oldText.length}:${hunk.newText.length}`;
5062
+ const cached = hunkStatsCache.get(key);
5063
+ if (cached) return cached;
5064
+ const diff = buildEditDiff(hunk.file, hunk.oldText, hunk.newText);
5065
+ const stats = diff ? {
5066
+ additions: diff.additions,
5067
+ deletions: diff.deletions
5068
+ } : {
5069
+ additions: 0,
5070
+ deletions: 0
5071
+ };
5072
+ if (hunkStatsCache.size >= HUNK_STATS_CACHE_MAX) hunkStatsCache.clear();
5073
+ hunkStatsCache.set(key, stats);
5074
+ return stats;
5075
+ }
5076
+ function fileChangeStats(file) {
5077
+ return file.hunks.reduce((total, hunk) => {
5078
+ const stats = hunkStats(hunk);
5079
+ return {
5080
+ additions: total.additions + stats.additions,
5081
+ deletions: total.deletions + stats.deletions
5082
+ };
5083
+ }, {
5084
+ additions: 0,
5085
+ deletions: 0
5086
+ });
5087
+ }
4958
5088
  /**
4959
- * Shell snapshot for the `!` local-shell escape.
4960
- *
4961
- * The chat composer runs on the user's own machine. When they type `! <cmd>`,
4962
- * we want that command to see their real interactive-shell setup — aliases,
4963
- * functions, shell options, PATH — the same way it would in their terminal.
4964
- *
4965
- * Aliases and functions live in ~/.zshrc / ~/.bashrc, which non-interactive
4966
- * shells don't source (or source without alias expansion), and are never
4967
- * exported to child processes. Re-sourcing the rc file on every command is slow
4968
- * (100s of ms with nvm/rbenv/oh-my-zsh) and can hang on interactive prompts.
5089
+ * Only edits that actually applied count. A failed edit (`error`) never
5090
+ * touched the file; a still-streaming one isn't final. `input-ready` is
5091
+ * included so the pane reflects an in-flight-but-parsed edit — it flips to
5092
+ * error and drops out if the tool then fails.
5093
+ */
5094
+ function isReviewableState(state) {
5095
+ return state === "input-ready" || state === "output-ready";
5096
+ }
5097
+ /** Fold scrollback items into the per-file changeset. Pure. */
5098
+ function buildChangeset(items) {
5099
+ const byFile = /* @__PURE__ */ new Map();
5100
+ const contents = /* @__PURE__ */ new Map();
5101
+ let turn = -1;
5102
+ for (const item of items) {
5103
+ if (item.kind === "user" || item.kind === "pending-steer") {
5104
+ turn += 1;
5105
+ continue;
5106
+ }
5107
+ if (item.kind !== "tool") continue;
5108
+ if (!isReviewableState(item.state)) continue;
5109
+ if (item.output?.kind === "error") continue;
5110
+ if (!isRecord(item.input)) continue;
5111
+ const file = extractEditFilePath(item.input);
5112
+ if (file === null) continue;
5113
+ const pairs = extractEditPairs(item.toolName, item.input);
5114
+ if (pairs === null) continue;
5115
+ let entry = byFile.get(file);
5116
+ if (!entry) {
5117
+ entry = {
5118
+ file,
5119
+ hunks: []
5120
+ };
5121
+ byFile.set(file, entry);
5122
+ }
5123
+ pairs.forEach((pair, pairIndex) => {
5124
+ const tracked = contents.get(file);
5125
+ let oldText = pair.oldText;
5126
+ let lineBase = 0;
5127
+ if (pair.oldText === "" && item.toolName === "write") {
5128
+ if (tracked !== void 0) oldText = tracked;
5129
+ contents.set(file, pair.newText);
5130
+ } else if (tracked !== void 0 && pair.oldText === "") contents.delete(file);
5131
+ else if (tracked !== void 0) {
5132
+ const at = tracked.indexOf(pair.oldText);
5133
+ if (at >= 0) {
5134
+ lineBase = countLines(tracked.slice(0, at));
5135
+ contents.set(file, tracked.slice(0, at) + pair.newText + tracked.slice(at + pair.oldText.length));
5136
+ } else contents.delete(file);
5137
+ }
5138
+ entry.hunks.push({
5139
+ toolCallId: item.id,
5140
+ pairIndex,
5141
+ file,
5142
+ oldText,
5143
+ newText: pair.newText,
5144
+ lineBase,
5145
+ turn: Math.max(turn, 0)
5146
+ });
5147
+ });
5148
+ }
5149
+ return {
5150
+ files: [...byFile.values()],
5151
+ lastTurn: Math.max(turn, 0)
5152
+ };
5153
+ }
5154
+ /** Complete lines before a snippet: the count of newlines in the prefix. */
5155
+ function countLines(prefix) {
5156
+ let count = 0;
5157
+ for (let i = 0; i < prefix.length; i++) if (prefix.charCodeAt(i) === 10) count += 1;
5158
+ return count;
5159
+ }
5160
+ /** Narrow a changeset to a scope. `session` returns it unchanged. */
5161
+ function scopeChangeset(changeset, scope) {
5162
+ if (scope === "session") return changeset;
5163
+ return {
5164
+ files: changeset.files.map((f) => ({
5165
+ file: f.file,
5166
+ hunks: f.hunks.filter((h) => h.turn === changeset.lastTurn)
5167
+ })).filter((f) => f.hunks.length > 0),
5168
+ lastTurn: changeset.lastTurn
5169
+ };
5170
+ }
5171
+
5172
+ //#endregion
5173
+ //#region src/chat/review/comments.ts
5174
+ function createComment(fields) {
5175
+ return {
5176
+ ...fields,
5177
+ id: crypto.randomUUID(),
5178
+ stale: false,
5179
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5180
+ };
5181
+ }
5182
+ /**
5183
+ * Export comments in herdr-reviewr's block format — one block per comment,
5184
+ * sorted by file then start line, blank line between blocks, no preamble:
4969
5185
  *
4970
- * So, once per session, we run the user's login shell, source their rc, and
4971
- * serialize the resulting state into a standalone .sh file. Every `!` command
4972
- * then sources that cheap file and runs. This mirrors the approach Claude Code
4973
- * uses for its bash mode.
5186
+ * path/to/file.py:40-41
5187
+ * -from .z import w
5188
+ * +from .x import y
5189
+ * this import path looks wrong
4974
5190
  *
4975
- * The snapshot is cached on disk keyed by (shell, rc-file mtime), so it is
4976
- * built once and reused across CLI sessions, and regenerated automatically when
4977
- * the user edits their rc file. There is deliberately no `!!` sigil — a second
4978
- * bang has a base shell meaning (history expansion) we do not override; the
4979
- * agent-sandbox counterpart is the `/sandbox` slash command, not a sigil.
5191
+ * Single-line ranges render as `:40` (no dash); old-side ranges get a
5192
+ * ` (removed)` suffix on the header.
4980
5193
  */
4981
- const SNAPSHOT_CREATION_TIMEOUT_MS = 1e4;
4982
- const SNAPSHOT_MAX_BUFFER = 1024 * 1024;
5194
+ function formatCommentsForExport(comments) {
5195
+ return [...comments].sort((a, b) => a.file.localeCompare(b.file) || a.start - b.start).map(formatComment).join("\n\n");
5196
+ }
5197
+ function formatComment(c) {
5198
+ const range = c.start === c.end ? `${c.start}` : `${c.start}-${c.end}`;
5199
+ const removed = c.side === "old" ? " (removed)" : "";
5200
+ return `${`${c.file}:${range}${removed}`}\n${c.lines.length > 0 ? `${c.lines.replace(/\n$/, "")}\n` : ""}${c.text}`;
5201
+ }
5202
+
5203
+ //#endregion
5204
+ //#region src/chat/review/pane-model.ts
4983
5205
  /**
4984
- * Resolve which shell the `!` escape uses. Order:
4985
- * $SKYDIVE_SHELL (if it names bash/zsh and is executable)
4986
- * $SHELL (if it names bash/zsh and is executable)
4987
- * `zsh` / `bash` found on $PATH
4988
- * `zsh` / `bash` in common install locations
4989
- * /bin/sh as a last resort
4990
- * Preference is bash-first when $SHELL mentions bash, else zsh-first — matching
4991
- * the user's actual login shell where we can tell.
5206
+ * Flatten the changeset into pane rows. Comments render directly under the
5207
+ * diff row their range ends on (matched by toolCallId + side + line);
5208
+ * unmatched comments collect under a stale header at the bottom.
4992
5209
  */
4993
- async function resolveShell(env = process.env) {
4994
- const override = env.SKYDIVE_SHELL;
4995
- if (override && /bash|zsh/.test(override) && await isExecutable(override)) return describeShell(override);
4996
- const shellEnv = env.SHELL;
4997
- if (shellEnv && /bash|zsh/.test(shellEnv) && await isExecutable(shellEnv)) return describeShell(shellEnv);
4998
- const candidates = !!shellEnv && shellEnv.includes("bash") ? ["bash", "zsh"] : ["zsh", "bash"];
4999
- for (const name of candidates) {
5000
- const found = await whichOnPath(name, env);
5001
- if (found) return describeShell(found);
5210
+ function buildPaneRows(changeset, comments) {
5211
+ const rows = [];
5212
+ const placed = /* @__PURE__ */ new Set();
5213
+ for (const file of changeset.files) {
5214
+ rows.push({
5215
+ kind: "file",
5216
+ file: file.file,
5217
+ hunkCount: file.hunks.length
5218
+ });
5219
+ file.hunks.forEach((hunk, hunkIndex) => {
5220
+ if (hunkIndex > 0) rows.push({
5221
+ kind: "hunk-sep",
5222
+ hunk
5223
+ });
5224
+ const diff = buildEditDiff(hunk.file, hunk.oldText, hunk.newText, Number.MAX_SAFE_INTEGER);
5225
+ if (!diff) return;
5226
+ diff.rows.forEach((rawRow, rowInHunk) => {
5227
+ const row = hunk.lineBase > 0 ? {
5228
+ ...rawRow,
5229
+ lineNo: rawRow.lineNo + hunk.lineBase
5230
+ } : rawRow;
5231
+ rows.push({
5232
+ kind: "diff",
5233
+ hunk,
5234
+ row,
5235
+ rowInHunk
5236
+ });
5237
+ for (const comment of comments) {
5238
+ if (placed.has(comment.id)) continue;
5239
+ if (comment.toolCallId !== hunk.toolCallId) continue;
5240
+ if (comment.file !== hunk.file) continue;
5241
+ if (!rowMatchesSide(row, comment.side)) continue;
5242
+ if (row.lineNo !== comment.end) continue;
5243
+ rows.push({
5244
+ kind: "comment",
5245
+ comment
5246
+ });
5247
+ placed.add(comment.id);
5248
+ }
5249
+ });
5250
+ });
5251
+ }
5252
+ const stale = comments.filter((c) => !placed.has(c.id));
5253
+ if (stale.length > 0) {
5254
+ rows.push({
5255
+ kind: "stale-header",
5256
+ count: stale.length
5257
+ });
5258
+ for (const comment of stale) rows.push({
5259
+ kind: "stale-comment",
5260
+ comment
5261
+ });
5262
+ }
5263
+ return rows;
5264
+ }
5265
+ function rowMatchesSide(row, side) {
5266
+ return side === "old" ? row.kind === "del" : row.kind !== "del";
5267
+ }
5268
+ /** Whether a saved comment's range covers this exact diff row. */
5269
+ function commentCoversDiffRow(comment, row) {
5270
+ return comment.toolCallId === row.hunk.toolCallId && comment.file === row.hunk.file && rowMatchesSide(row.row, comment.side) && row.row.lineNo >= comment.start && row.row.lineNo <= comment.end;
5271
+ }
5272
+ /** Diff rows are the only rows the cursor can rest on. */
5273
+ function isSelectableRow(row) {
5274
+ return row.kind === "diff";
5275
+ }
5276
+ /** Nearest selectable index at/after `from` (falling back backwards). */
5277
+ function nearestSelectable(rows, from) {
5278
+ if (rows.length === 0) return -1;
5279
+ const clamped = Math.min(Math.max(from, 0), rows.length - 1);
5280
+ for (let i = clamped; i < rows.length; i++) {
5281
+ const row = rows[i];
5282
+ if (row && isSelectableRow(row)) return i;
5283
+ }
5284
+ for (let i = clamped - 1; i >= 0; i--) {
5285
+ const row = rows[i];
5286
+ if (row && isSelectableRow(row)) return i;
5287
+ }
5288
+ return -1;
5289
+ }
5290
+ /** Next selectable index in `dir` from `from`, or `from` when at the edge. */
5291
+ function moveCursor(rows, from, dir) {
5292
+ for (let i = from + dir; i >= 0 && i < rows.length; i += dir) {
5293
+ const row = rows[i];
5294
+ if (row && isSelectableRow(row)) return i;
5295
+ }
5296
+ return from;
5297
+ }
5298
+ /** First diff row of the next/previous hunk (falls back to `from`). */
5299
+ function moveHunk(rows, from, dir) {
5300
+ const current = rows[from];
5301
+ const currentHunk = current && current.kind === "diff" ? current.hunk : null;
5302
+ const inSameHunk = (row) => row.kind === "diff" && currentHunk !== null && row.hunk.toolCallId === currentHunk.toolCallId && row.hunk.pairIndex === currentHunk.pairIndex;
5303
+ for (let i = from + dir; i >= 0 && i < rows.length; i += dir) {
5304
+ const row = rows[i];
5305
+ if (!row || !isSelectableRow(row) || inSameHunk(row)) continue;
5306
+ if (dir === 1) return i;
5307
+ let start = i;
5308
+ while (start > 0) {
5309
+ const prev = rows[start - 1];
5310
+ if (prev?.kind === "diff" && prev.hunk.toolCallId === row.hunk.toolCallId && prev.hunk.pairIndex === row.hunk.pairIndex) start -= 1;
5311
+ else break;
5312
+ }
5313
+ return start;
5314
+ }
5315
+ return from;
5316
+ }
5317
+ /**
5318
+ * Normalize an anchor/cursor pair into a selection, or null when invalid.
5319
+ * A selection must be contiguous diff rows of a single hunk — comments
5320
+ * can't span hunks because the snippet must stay verbatim (herdr rule).
5321
+ * Interleaved comment rows are allowed (skipped), everything else breaks
5322
+ * the selection.
5323
+ */
5324
+ function resolveSelection(rows, anchor, cursor) {
5325
+ const lo = Math.min(anchor, cursor);
5326
+ const hi = Math.max(anchor, cursor);
5327
+ const first = rows[lo];
5328
+ if (!first || first.kind !== "diff") return null;
5329
+ for (let i = lo; i <= hi; i++) {
5330
+ const row = rows[i];
5331
+ if (!row) return null;
5332
+ if (row.kind === "comment") continue;
5333
+ if (row.kind !== "diff") return null;
5334
+ if (row.hunk.toolCallId !== first.hunk.toolCallId || row.hunk.pairIndex !== first.hunk.pairIndex) return null;
5335
+ }
5336
+ return {
5337
+ lo,
5338
+ hi
5339
+ };
5340
+ }
5341
+ /**
5342
+ * Derive the comment anchor for a selection: side is `old` only when every
5343
+ * selected row is a deletion; line numbers come from the rows matching the
5344
+ * chosen side; `lines` is the verbatim marked snippet of the whole range.
5345
+ */
5346
+ function anchorForSelection(rows, selection) {
5347
+ const diffRows = [];
5348
+ for (let i = selection.lo; i <= selection.hi; i++) {
5349
+ const row = rows[i];
5350
+ if (row && row.kind === "diff") diffRows.push(row);
5351
+ }
5352
+ if (diffRows.length === 0) return null;
5353
+ const side = diffRows.every((r) => r.row.kind === "del") ? "old" : "new";
5354
+ const sideRows = diffRows.filter((r) => rowMatchesSide(r.row, side));
5355
+ if (sideRows.length === 0) return null;
5356
+ const lineNos = sideRows.map((r) => r.row.lineNo);
5357
+ const lines = diffRows.map((r) => `${markerFor(r.row)}${r.row.text}`).join("\n");
5358
+ const first = diffRows[0];
5359
+ if (!first) return null;
5360
+ return {
5361
+ file: first.hunk.file,
5362
+ side,
5363
+ start: Math.min(...lineNos),
5364
+ end: Math.max(...lineNos),
5365
+ lines,
5366
+ toolCallId: first.hunk.toolCallId
5367
+ };
5368
+ }
5369
+ function markerFor(row) {
5370
+ return row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
5371
+ }
5372
+ /** The pending comment whose rendered row is at/adjacent to the cursor. */
5373
+ function commentAtCursor(rows, cursor) {
5374
+ const here = rows[cursor];
5375
+ if (here?.kind === "comment" || here?.kind === "stale-comment") return here.comment;
5376
+ const next = rows[cursor + 1];
5377
+ if (next?.kind === "comment") return next.comment;
5378
+ return null;
5379
+ }
5380
+
5381
+ //#endregion
5382
+ //#region src/chat/review/store.ts
5383
+ /**
5384
+ * Persistence for pending review comments.
5385
+ *
5386
+ * One JSON file per conversation under `<configDir>/review/`, so switching
5387
+ * conversations (or killing the TUI — see the ctrl+c segfault) never loses a
5388
+ * pending comment, and two TUIs on *different* conversations never contend
5389
+ * for the same file. Every mutation writes through to disk immediately;
5390
+ * nothing waits for exit.
5391
+ *
5392
+ * Same best-effort load semantics as prompt history: a corrupt or
5393
+ * wrong-shaped file yields an empty list rather than crashing the chat.
5394
+ * Writes go through temp-file + rename so a crash mid-write leaves the old
5395
+ * state intact, not a truncated file.
5396
+ */
5397
+ const commentSchema = z.object({
5398
+ id: z.string(),
5399
+ file: z.string(),
5400
+ side: z.enum(["new", "old"]),
5401
+ start: z.number().int(),
5402
+ end: z.number().int(),
5403
+ lines: z.string(),
5404
+ text: z.string(),
5405
+ toolCallId: z.string().nullable(),
5406
+ stale: z.boolean(),
5407
+ createdAt: z.string()
5408
+ });
5409
+ const fileSchema = z.object({
5410
+ version: z.literal(1),
5411
+ comments: z.array(commentSchema)
5412
+ });
5413
+ /** Conversations untouched this long are GC'd at store startup. */
5414
+ const GC_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
5415
+ /** Temp files older than this are crashed-write leftovers, safe to reap. */
5416
+ const TMP_MAX_AGE_MS = 3600 * 1e3;
5417
+ /** Conversation ids are UUIDs today, but sanitize defensively anyway. */
5418
+ function fileNameFor(conversationId) {
5419
+ return `${conversationId.replace(/[^\w.-]/g, "_")}.json`;
5420
+ }
5421
+ var ReviewCommentStore = class {
5422
+ /** Serialize writes per conversation so rapid mutations stay ordered. */
5423
+ writeQueues = /* @__PURE__ */ new Map();
5424
+ constructor(dir) {
5425
+ this.dir = dir;
5426
+ }
5427
+ pathFor(conversationId) {
5428
+ return path.join(this.dir, fileNameFor(conversationId));
5429
+ }
5430
+ /** Load pending comments for a conversation. Missing/corrupt → []. */
5431
+ async load(conversationId) {
5432
+ const pendingWrite = this.writeQueues.get(conversationId);
5433
+ if (pendingWrite) try {
5434
+ await pendingWrite;
5435
+ } catch (_error) {}
5436
+ try {
5437
+ const raw = await readFile(this.pathFor(conversationId), "utf8");
5438
+ const parsed = fileSchema.safeParse(JSON.parse(raw));
5439
+ return parsed.success ? parsed.data.comments : [];
5440
+ } catch (_error) {
5441
+ return [];
5442
+ }
5443
+ }
5444
+ /**
5445
+ * Replace the conversation's comments on disk. Called on every mutation
5446
+ * (add/edit/delete/consume) — the dataset is a handful of comments, so a
5447
+ * full rewrite is cheap and keeps recovery semantics trivial. An empty
5448
+ * list removes the file so GC has nothing to track.
5449
+ */
5450
+ save(conversationId, comments) {
5451
+ const snapshot = [...comments];
5452
+ const previous = this.writeQueues.get(conversationId) ?? Promise.resolve();
5453
+ const next = (async () => {
5454
+ try {
5455
+ await previous;
5456
+ } catch (_error) {}
5457
+ await this.saveNow(conversationId, snapshot);
5458
+ })();
5459
+ this.writeQueues.set(conversationId, next);
5460
+ const cleanUp = () => {
5461
+ if (this.writeQueues.get(conversationId) === next) this.writeQueues.delete(conversationId);
5462
+ };
5463
+ next.then(cleanUp, cleanUp);
5464
+ return next;
5465
+ }
5466
+ async saveNow(conversationId, comments) {
5467
+ const target = this.pathFor(conversationId);
5468
+ if (comments.length === 0) {
5469
+ try {
5470
+ await unlink(target);
5471
+ } catch (_error) {}
5472
+ return;
5473
+ }
5474
+ await mkdir(this.dir, { recursive: true });
5475
+ const payload = JSON.stringify({
5476
+ version: 1,
5477
+ comments: [...comments]
5478
+ }, null, 2);
5479
+ const tmp = `${target}.${process.pid}.tmp`;
5480
+ await writeFile(tmp, payload, "utf8");
5481
+ await rename(tmp, target);
5482
+ }
5483
+ /**
5484
+ * Drop state for conversations untouched for GC_MAX_AGE_MS, plus any
5485
+ * stray temp files from crashed writes. Best-effort; call once at TUI
5486
+ * startup, off the critical path.
5487
+ */
5488
+ async gc(now = Date.now()) {
5489
+ let entries;
5490
+ try {
5491
+ entries = await readdir(this.dir);
5492
+ } catch (_error) {
5493
+ return;
5494
+ }
5495
+ await Promise.all(entries.map(async (name) => {
5496
+ const full = path.join(this.dir, name);
5497
+ try {
5498
+ if (name.endsWith(".tmp")) {
5499
+ if (now - (await stat(full)).mtimeMs > TMP_MAX_AGE_MS) await unlink(full);
5500
+ return;
5501
+ }
5502
+ if (!name.endsWith(".json")) return;
5503
+ if (now - (await stat(full)).mtimeMs > GC_MAX_AGE_MS) await unlink(full);
5504
+ } catch (_error) {}
5505
+ }));
5506
+ }
5507
+ };
5508
+
5509
+ //#endregion
5510
+ //#region src/chat/review/visuals.ts
5511
+ /** Blend a foreground accent over a background hex color. */
5512
+ function blendHex(foreground, background, weight) {
5513
+ const fg = parseHex(foreground);
5514
+ const bg = parseHex(background);
5515
+ if (!fg || !bg) return background ?? foreground;
5516
+ const w = Math.max(0, Math.min(1, weight));
5517
+ const channel = (a, b) => Math.round(a * w + b * (1 - w)).toString(16).padStart(2, "0");
5518
+ return `#${channel(fg[0], bg[0])}${channel(fg[1], bg[1])}${channel(fg[2], bg[2])}`;
5519
+ }
5520
+ function parseHex(color) {
5521
+ if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return null;
5522
+ return [
5523
+ Number.parseInt(color.slice(1, 3), 16),
5524
+ Number.parseInt(color.slice(3, 5), 16),
5525
+ Number.parseInt(color.slice(5, 7), 16)
5526
+ ];
5527
+ }
5528
+
5529
+ //#endregion
5530
+ //#region src/chat/tui/screens/review-comment-card.tsx
5531
+ /**
5532
+ * Saved review note rendered directly beneath the last line in its range.
5533
+ * Mirrors herdr-reviewr's quiet titled card rather than leaving a loose
5534
+ * `┃ note` row that can be mistaken for source.
5535
+ */
5536
+ function ReviewCommentCard({ comment, width }) {
5537
+ const range = comment.start === comment.end ? `${comment.start}` : `${comment.start}-${comment.end}`;
5538
+ return /* @__PURE__ */ jsx("box", {
5539
+ title: ` comment · ${comment.file}:${range} `,
5540
+ style: {
5541
+ flexDirection: "column",
5542
+ border: true,
5543
+ borderColor: blendHex(theme.warning, theme.surface, .6),
5544
+ marginLeft: 2,
5545
+ width: Math.max(12, width - 2),
5546
+ paddingLeft: 1,
5547
+ paddingRight: 1
5548
+ },
5549
+ children: comment.text.split("\n").map((line, index) => /* @__PURE__ */ jsx("text", {
5550
+ fg: theme.fg,
5551
+ children: line.length > 0 ? line : " "
5552
+ }, index))
5553
+ });
5554
+ }
5555
+
5556
+ //#endregion
5557
+ //#region src/chat/tui/screens/review-source-row.tsx
5558
+ const SOURCE_GUTTER_WIDTH = 9;
5559
+ function reviewSourceContentWidth(width) {
5560
+ return Math.max(1, width - SOURCE_GUTTER_WIDTH);
5561
+ }
5562
+ /** Shared source row for Changes diffs and Files full-content previews. */
5563
+ function ReviewSourceRow({ file, row, cursor, selected, commented, width }) {
5564
+ const palette = diffPalette();
5565
+ const semanticBg = row.kind === "add" ? palette?.addedLineBg : row.kind === "del" ? palette?.removedLineBg : void 0;
5566
+ const bg = cursor ? blendHex(theme.fg, theme.surface, .22) : selected ? blendHex(theme.fg, theme.surface, .12) : semanticBg;
5567
+ const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
5568
+ const signColor = row.kind === "add" ? palette?.addedSignColor ?? theme.success : row.kind === "del" ? palette?.removedSignColor ?? theme.error : theme.faint;
5569
+ const filetype = filetypeForPath(file);
5570
+ const contentWidth = reviewSourceContentWidth(width);
5571
+ return /* @__PURE__ */ jsxs("box", {
5572
+ style: {
5573
+ flexDirection: "row",
5574
+ backgroundColor: bg,
5575
+ width
5576
+ },
5577
+ children: [/* @__PURE__ */ jsx("box", {
5578
+ style: {
5579
+ width: SOURCE_GUTTER_WIDTH,
5580
+ flexShrink: 0
5581
+ },
5582
+ children: /* @__PURE__ */ jsxs("text", {
5583
+ bg,
5584
+ children: [
5585
+ /* @__PURE__ */ jsx("span", {
5586
+ fg: cursor ? theme.accent : selected ? theme.fg : theme.faint,
5587
+ children: cursor ? "›" : selected ? "▌" : " "
5588
+ }),
5589
+ /* @__PURE__ */ jsx("span", {
5590
+ fg: commented ? theme.warning : palette?.lineNumberFg ?? theme.muted,
5591
+ children: String(row.lineNo).padStart(5)
5592
+ }),
5593
+ /* @__PURE__ */ jsxs("span", {
5594
+ fg: signColor,
5595
+ children: [
5596
+ " ",
5597
+ sign,
5598
+ " "
5599
+ ]
5600
+ })
5601
+ ]
5602
+ })
5603
+ }), /* @__PURE__ */ jsx("box", {
5604
+ style: {
5605
+ width: contentWidth,
5606
+ flexShrink: 0
5607
+ },
5608
+ children: filetype ? /* @__PURE__ */ jsx("code", {
5609
+ content: row.text.length > 0 ? row.text : " ",
5610
+ filetype,
5611
+ syntaxStyle: syntaxStyle(),
5612
+ fg: theme.fg,
5613
+ bg,
5614
+ width: contentWidth,
5615
+ height: 1
5616
+ }) : /* @__PURE__ */ jsx("text", {
5617
+ fg: theme.fg,
5618
+ bg,
5619
+ children: row.text
5620
+ })
5621
+ })]
5622
+ });
5623
+ }
5624
+
5625
+ //#endregion
5626
+ //#region src/chat/tui/screens/review-shell.tsx
5627
+ function filterReviewFiles(values, query, pathFor) {
5628
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
5629
+ if (terms.length === 0) return [...values];
5630
+ return values.filter((value) => {
5631
+ const path = pathFor(value).toLowerCase();
5632
+ return terms.every((term) => path.includes(term));
5633
+ });
5634
+ }
5635
+ function ReviewFileSearch({ query, onQueryChange, placeholder = "filter files…" }) {
5636
+ return /* @__PURE__ */ jsxs("box", {
5637
+ style: {
5638
+ flexDirection: "row",
5639
+ height: 1
5640
+ },
5641
+ children: [/* @__PURE__ */ jsx("text", {
5642
+ fg: theme.accent,
5643
+ children: "/ "
5644
+ }), /* @__PURE__ */ jsx("input", {
5645
+ placeholder,
5646
+ focused: true,
5647
+ width: "100%",
5648
+ value: query,
5649
+ onInput: onQueryChange
5650
+ })]
5651
+ });
5652
+ }
5653
+ /**
5654
+ * Group flat paths into a directory tree with herdr-reviewr's compression
5655
+ * rules: a chain of single-child directories folds into one row (`a/b/c`),
5656
+ * and a chain ending in a lone file folds all the way into a single file
5657
+ * row (`a/b/x.ts`). Directories sort before files, alphabetically within a
5658
+ * parent, so deep nesting never costs a column of near-empty rows.
5659
+ */
5660
+ function buildReviewTree(values, pathFor) {
5661
+ const root = {
5662
+ dirs: /* @__PURE__ */ new Map(),
5663
+ files: /* @__PURE__ */ new Map()
5664
+ };
5665
+ for (const value of values) {
5666
+ const path = pathFor(value);
5667
+ const parts = path.split("/").filter(Boolean);
5668
+ const base = parts.at(-1);
5669
+ if (base === void 0) continue;
5670
+ let node = root;
5671
+ for (const segment of parts.slice(0, -1)) {
5672
+ let child = node.dirs.get(segment);
5673
+ if (!child) {
5674
+ child = {
5675
+ dirs: /* @__PURE__ */ new Map(),
5676
+ files: /* @__PURE__ */ new Map()
5677
+ };
5678
+ node.dirs.set(segment, child);
5679
+ }
5680
+ node = child;
5681
+ }
5682
+ node.files.set(base, {
5683
+ value,
5684
+ path
5685
+ });
5686
+ }
5687
+ const rows = [];
5688
+ flattenTree(rows, root, "", 0);
5689
+ return rows;
5690
+ }
5691
+ function sortedNames(names) {
5692
+ return [...names].sort((a, b) => a.localeCompare(b));
5693
+ }
5694
+ function flattenTree(rows, dir, prefix, depth) {
5695
+ for (const name of sortedNames(dir.dirs.keys())) {
5696
+ const start = dir.dirs.get(name);
5697
+ if (!start) continue;
5698
+ let display = name;
5699
+ let path = prefix ? `${prefix}/${name}` : name;
5700
+ let node = start;
5701
+ while (node.files.size === 0 && node.dirs.size === 1) {
5702
+ const next = node.dirs.entries().next().value;
5703
+ if (!next) break;
5704
+ const [childName, child] = next;
5705
+ display = `${display}/${childName}`;
5706
+ path = `${path}/${childName}`;
5707
+ node = child;
5708
+ }
5709
+ const loneFile = node.dirs.size === 0 && node.files.size === 1 ? node.files.entries().next().value : void 0;
5710
+ if (loneFile) {
5711
+ const [fileName, entry] = loneFile;
5712
+ rows.push({
5713
+ kind: "file",
5714
+ path: entry.path,
5715
+ name: `${display}/${fileName}`,
5716
+ depth,
5717
+ value: entry.value
5718
+ });
5719
+ continue;
5720
+ }
5721
+ rows.push({
5722
+ kind: "dir",
5723
+ path,
5724
+ name: display,
5725
+ depth
5726
+ });
5727
+ flattenTree(rows, node, path, depth + 1);
5728
+ }
5729
+ for (const name of sortedNames(dir.files.keys())) {
5730
+ const entry = dir.files.get(name);
5731
+ if (entry === void 0) continue;
5732
+ rows.push({
5733
+ kind: "file",
5734
+ path: entry.path,
5735
+ name,
5736
+ depth,
5737
+ value: entry.value
5738
+ });
5739
+ }
5740
+ }
5741
+ /** Translate a wheel event into signed rows; null for horizontal wheels. */
5742
+ function scrollDelta(event) {
5743
+ const scroll = event.scroll;
5744
+ if (!scroll) return null;
5745
+ if (scroll.direction === "down") return scroll.delta;
5746
+ if (scroll.direction === "up") return -scroll.delta;
5747
+ return null;
5748
+ }
5749
+ /** Stable navigator/source layout shared by both authoring tabs. */
5750
+ function ReviewShell({ width, height, layout, focus, onFocus, navigator, source, navigatorPreferredWidth, navigatorSize, onNavigatorResize, onNavigatorScroll, onSourceScroll }) {
5751
+ const dimensions = reviewShellDimensions({
5752
+ width,
5753
+ height,
5754
+ layout,
5755
+ focus,
5756
+ navigatorPreferredWidth,
5757
+ navigatorSize
5758
+ });
5759
+ const shellRef = useRef(null);
5760
+ const dragRef = useRef(null);
5761
+ return /* @__PURE__ */ jsxs("box", {
5762
+ ref: shellRef,
5763
+ onMouseDown: (event) => {
5764
+ const shell = shellRef.current;
5765
+ if (!shell || !onNavigatorResize || focus !== "navigator") return;
5766
+ if (layout === "side-by-side") {
5767
+ const border = shell.screenX + dimensions.navigatorWidth - 1;
5768
+ if (event.x === border || event.x === border + 1) dragRef.current = {
5769
+ start: event.x,
5770
+ size: dimensions.navigatorWidth
5771
+ };
5772
+ } else {
5773
+ const border = shell.screenY + dimensions.navigatorHeight - 1;
5774
+ if (event.y === border || event.y === border + 1) dragRef.current = {
5775
+ start: event.y,
5776
+ size: dimensions.navigatorHeight
5777
+ };
5778
+ }
5779
+ },
5780
+ onMouseDrag: (event) => {
5781
+ const drag = dragRef.current;
5782
+ if (!drag || !onNavigatorResize) return;
5783
+ const position = layout === "stacked" ? event.y : event.x;
5784
+ onNavigatorResize(drag.size + (position - drag.start));
5785
+ },
5786
+ onMouseDragEnd: () => {
5787
+ dragRef.current = null;
5788
+ },
5789
+ onMouseUp: () => {
5790
+ dragRef.current = null;
5791
+ },
5792
+ style: {
5793
+ flexDirection: layout === "stacked" ? "column" : "row",
5794
+ width,
5795
+ height,
5796
+ flexGrow: 1
5797
+ },
5798
+ children: [focus === "navigator" ? /* @__PURE__ */ jsx("box", {
5799
+ title: " files ",
5800
+ onMouseDown: () => onFocus("navigator"),
5801
+ onMouseScroll: (event) => {
5802
+ const delta = scrollDelta(event);
5803
+ if (delta !== null) onNavigatorScroll?.(delta);
5804
+ },
5805
+ style: {
5806
+ flexDirection: "column",
5807
+ width: dimensions.navigatorWidth,
5808
+ height: dimensions.navigatorHeight,
5809
+ border: true,
5810
+ borderColor: theme.accent,
5811
+ paddingLeft: 1,
5812
+ paddingRight: 1
5813
+ },
5814
+ children: navigator
5815
+ }) : null, /* @__PURE__ */ jsx("box", {
5816
+ title: " preview ",
5817
+ onMouseScroll: (event) => {
5818
+ const delta = scrollDelta(event);
5819
+ if (delta !== null) onSourceScroll?.(delta);
5820
+ },
5821
+ style: {
5822
+ flexDirection: "column",
5823
+ width: dimensions.sourceWidth,
5824
+ height: dimensions.sourceHeight,
5825
+ border: true,
5826
+ borderColor: focus === "source" ? theme.accent : theme.faint,
5827
+ paddingLeft: 1,
5828
+ paddingRight: 1
5829
+ },
5830
+ children: source
5831
+ })]
5832
+ });
5833
+ }
5834
+ function reviewShellDimensions({ width, height, layout, focus = "navigator", navigatorPreferredWidth, navigatorSize }) {
5835
+ if (focus === "source") return {
5836
+ navigatorWidth: 0,
5837
+ navigatorHeight: 0,
5838
+ sourceWidth: width,
5839
+ sourceHeight: height
5840
+ };
5841
+ if (layout === "stacked") {
5842
+ const navigatorHeight = navigatorSize === void 0 ? Math.max(5, Math.floor(height * .45)) : Math.max(4, Math.min(height - 5, navigatorSize));
5843
+ return {
5844
+ navigatorWidth: width,
5845
+ navigatorHeight,
5846
+ sourceWidth: width,
5847
+ sourceHeight: Math.max(4, height - navigatorHeight)
5848
+ };
5849
+ }
5850
+ const navigatorWidth = navigatorSize === void 0 ? reviewNavigatorWidth(width, navigatorPreferredWidth) : Math.max(20, Math.min(width - 20, navigatorSize));
5851
+ return {
5852
+ navigatorWidth,
5853
+ navigatorHeight: height,
5854
+ sourceWidth: Math.max(16, width - navigatorWidth - 1),
5855
+ sourceHeight: height
5856
+ };
5857
+ }
5858
+ function reviewNavigatorWidth(width, preferredContentWidth) {
5859
+ const minimum = 24;
5860
+ const maximum = Math.max(minimum, Math.min(64, Math.floor(width * .46)));
5861
+ const desired = preferredContentWidth === void 0 ? Math.floor(width * .32) : preferredContentWidth + 4;
5862
+ return Math.max(minimum, Math.min(maximum, desired));
5863
+ }
5864
+ /**
5865
+ * Estimate a useful tree width without letting one pathological filename own
5866
+ * the layout. Includes row marker, indentation, label, and optional metadata.
5867
+ */
5868
+ function reviewTreePreferredWidth(rows, suffixWidth = () => 0) {
5869
+ if (rows.length === 0) return 0;
5870
+ const widths = rows.map((row) => {
5871
+ const base = 2 + row.depth * 2 + row.name.length;
5872
+ return row.kind === "dir" ? base + 2 : base + suffixWidth(row.value);
5873
+ }).sort((a, b) => a - b);
5874
+ return widths[Math.max(0, Math.ceil(widths.length * .9) - 1)] ?? 0;
5875
+ }
5876
+ function halfPageStep(visibleRows) {
5877
+ return Math.max(1, Math.floor(visibleRows / 2));
5878
+ }
5879
+ function moveIndexByPage({ current, total, visibleRows, direction }) {
5880
+ if (total <= 0) return 0;
5881
+ return Math.max(0, Math.min(total - 1, current + direction * halfPageStep(visibleRows)));
5882
+ }
5883
+
5884
+ //#endregion
5885
+ //#region src/chat/tui/screens/workspace-files-model.ts
5886
+ /**
5887
+ * Build a stable yazi-style tree from the API's flat path list, sharing the
5888
+ * Changes tab's herdr-style compression (single-child directory chains fold
5889
+ * into one row).
5890
+ */
5891
+ function buildWorkspaceTree(files) {
5892
+ return buildReviewTree(files, (file) => file.path).map((row) => row.kind === "dir" ? {
5893
+ kind: "dir",
5894
+ path: row.path,
5895
+ name: row.name,
5896
+ depth: row.depth
5897
+ } : {
5898
+ kind: "file",
5899
+ file: row.value,
5900
+ name: row.name,
5901
+ depth: row.depth
5902
+ });
5903
+ }
5904
+ /**
5905
+ * The nearest preview line matching `query` (case-insensitive substring),
5906
+ * scanning from `from` in `direction` and wrapping around the buffer.
5907
+ */
5908
+ function findPreviewMatch({ lines, query, from, direction }) {
5909
+ const needle = query.trim().toLowerCase();
5910
+ if (needle.length === 0 || lines.length === 0) return null;
5911
+ const total = lines.length;
5912
+ for (let step = 0; step < total; step++) {
5913
+ const index = ((from + direction * step) % total + total) % total;
5914
+ if (lines[index]?.toLowerCase().includes(needle)) return index;
5915
+ }
5916
+ return null;
5917
+ }
5918
+ function nearestFileRow(rows, from) {
5919
+ if (rows.length === 0) return -1;
5920
+ const clamped = Math.min(Math.max(from, 0), rows.length - 1);
5921
+ for (let i = clamped; i < rows.length; i++) if (rows[i]?.kind === "file") return i;
5922
+ for (let i = clamped - 1; i >= 0; i--) if (rows[i]?.kind === "file") return i;
5923
+ return -1;
5924
+ }
5925
+ function moveFileRow(rows, from, direction) {
5926
+ for (let i = from + direction; i >= 0 && i < rows.length; i += direction) if (rows[i]?.kind === "file") return i;
5927
+ return from;
5928
+ }
5929
+ /** Stable whole-file comment anchor (space-prefixed verbatim source lines). */
5930
+ function workspaceFileCommentAnchor({ file, lines, start, end }) {
5931
+ const lo = Math.max(0, Math.min(start, end));
5932
+ const hi = Math.min(lines.length - 1, Math.max(start, end));
5933
+ if (lines.length === 0 || lo > hi) return null;
5934
+ return {
5935
+ file,
5936
+ side: "new",
5937
+ start: lo + 1,
5938
+ end: hi + 1,
5939
+ lines: lines.slice(lo, hi + 1).map((line) => ` ${line}`).join("\n"),
5940
+ toolCallId: null
5941
+ };
5942
+ }
5943
+
5944
+ //#endregion
5945
+ //#region src/chat/tui/screens/workspace-files.tsx
5946
+ function canPreview(file) {
5947
+ if (file.sizeBytes > 5 * 1024 * 1024) return false;
5948
+ if (file.mediaType.startsWith("text/")) return true;
5949
+ if (/\/(json|xml|javascript|typescript|x-sh|yaml)$/.test(file.mediaType)) return true;
5950
+ return /\.(md|mdx|txt|json|jsonl|ya?ml|toml|ini|env|csv|ts|tsx|js|jsx|mjs|cjs|css|scss|html|xml|svg|py|rb|rs|go|java|kt|swift|c|cc|cpp|h|hpp|sh|zsh|fish|sql|graphql|gql)$/i.test(file.path);
5951
+ }
5952
+ function formatBytes$1(bytes) {
5953
+ if (bytes < 1024) return `${bytes} B`;
5954
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
5955
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
5956
+ }
5957
+ /** Durable `~/Documents` browser: tree navigation → focusable file preview. */
5958
+ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, shellLayout, selectedFilePath, onSelectedFilePathChange, navigatorSize, onNavigatorResize, comments, onCommentsChange, onComposingChange, onClose }) {
5959
+ const rest = useStore((s) => s.rest);
5960
+ const width = viewportWidth;
5961
+ const height = viewportHeight;
5962
+ const shellHeight = Math.max(6, height - 1);
5963
+ const [load, setLoad] = useState({ kind: "loading" });
5964
+ const [focus, setFocus] = useState("tree");
5965
+ const [fileSearch, setFileSearch] = useState(null);
5966
+ const [lineCursor, setLineCursor] = useState(0);
5967
+ const [lineAnchor, setLineAnchor] = useState(null);
5968
+ const [commentDraft, setCommentDraft] = useState(null);
5969
+ const [editingId, setEditingId] = useState(null);
5970
+ const [preview, setPreview] = useState({
5971
+ kind: "none",
5972
+ message: "select a file"
5973
+ });
5974
+ const [refreshKey, setRefreshKey] = useState(0);
5975
+ const [status, setStatus] = useState(null);
5976
+ const [previewSearch, setPreviewSearch] = useState(null);
5977
+ const [committedSearch, setCommittedSearch] = useState("");
5978
+ const [searchOrigin, setSearchOrigin] = useState(0);
5979
+ const [treeScroll, setTreeScroll] = useState(null);
5980
+ const [lineScroll, setLineScroll] = useState(null);
5981
+ useEffect(() => {
5982
+ if (status === null) return;
5983
+ const timer = setTimeout(() => setStatus(null), 2500);
5984
+ return () => clearTimeout(timer);
5985
+ }, [status]);
5986
+ useEffect(() => {
5987
+ onComposingChange(commentDraft !== null || fileSearch !== null || previewSearch !== null);
5988
+ return () => onComposingChange(false);
5989
+ }, [
5990
+ commentDraft,
5991
+ fileSearch,
5992
+ previewSearch,
5993
+ onComposingChange
5994
+ ]);
5995
+ useEffect(() => {
5996
+ if (!rest) return;
5997
+ let cancelled = false;
5998
+ setLoad({ kind: "loading" });
5999
+ rest.listWorkspaceFiles({ agentId }).then((files) => {
6000
+ if (!cancelled) setLoad({
6001
+ kind: "ready",
6002
+ files
6003
+ });
6004
+ }).catch((error) => {
6005
+ if (!cancelled) setLoad({
6006
+ kind: "error",
6007
+ message: errorMessage(error)
6008
+ });
6009
+ });
6010
+ return () => {
6011
+ cancelled = true;
6012
+ };
6013
+ }, [
6014
+ rest,
6015
+ agentId,
6016
+ refreshKey
6017
+ ]);
6018
+ const visibleFiles = useMemo(() => load.kind === "ready" ? filterReviewFiles(load.files, fileSearch ?? "", (file) => file.path) : [], [load, fileSearch]);
6019
+ const rows = useMemo(() => buildWorkspaceTree(visibleFiles), [visibleFiles]);
6020
+ const navigatorPreferredWidth = reviewTreePreferredWidth(useMemo(() => buildReviewTree(visibleFiles, (file) => file.path), [visibleFiles]), (file) => ` · ${formatBytes$1(file.sizeBytes)}`.length);
6021
+ const shellDimensions = reviewShellDimensions({
6022
+ width,
6023
+ height: shellHeight,
6024
+ layout: shellLayout,
6025
+ focus: focus === "tree" ? "navigator" : "source",
6026
+ navigatorPreferredWidth,
6027
+ navigatorSize: navigatorSize ?? void 0
6028
+ });
6029
+ const persistedIndex = rows.findIndex((row) => row.kind === "file" && row.file.path === selectedFilePath);
6030
+ const selectedIndex = persistedIndex >= 0 ? persistedIndex : nearestFileRow(rows, 0);
6031
+ const selected = rows[selectedIndex];
6032
+ const selectedFile = selected?.kind === "file" ? selected.file : null;
6033
+ const selectFileRow = (index) => {
6034
+ const row = rows[index];
6035
+ if (row?.kind === "file") onSelectedFilePathChange(row.file.path);
6036
+ };
6037
+ useEffect(() => {
6038
+ setTreeScroll(null);
6039
+ }, [selectedIndex]);
6040
+ useEffect(() => {
6041
+ setLineScroll(null);
6042
+ }, [lineCursor]);
6043
+ useEffect(() => {
6044
+ setFocus("tree");
6045
+ setLineCursor(0);
6046
+ setLineAnchor(null);
6047
+ setPreviewSearch(null);
6048
+ setCommittedSearch("");
6049
+ if (!rest || !selectedFile) {
6050
+ setPreview({
6051
+ kind: "none",
6052
+ message: "select a file"
6053
+ });
6054
+ return;
6055
+ }
6056
+ if (!canPreview(selectedFile)) {
6057
+ setPreview({
6058
+ kind: "none",
6059
+ message: `${selectedFile.mediaType} · ${formatBytes$1(selectedFile.sizeBytes)}\npreview unavailable for binary or large files`
6060
+ });
6061
+ return;
6062
+ }
6063
+ let cancelled = false;
6064
+ setPreview({ kind: "loading" });
6065
+ rest.readWorkspaceFile({ fileId: selectedFile.id }).then((result) => {
6066
+ if (!cancelled) setPreview({
6067
+ kind: "ready",
6068
+ ...result
6069
+ });
6070
+ }).catch((error) => {
6071
+ if (!cancelled) setPreview({
6072
+ kind: "error",
6073
+ message: errorMessage(error)
6074
+ });
6075
+ });
6076
+ return () => {
6077
+ cancelled = true;
6078
+ };
6079
+ }, [rest, selectedFile]);
6080
+ const lines = preview.kind === "ready" ? preview.text.split("\n") : [];
6081
+ const clampedLine = Math.min(lineCursor, Math.max(0, lines.length - 1));
6082
+ const selectionStart = Math.min(lineAnchor ?? clampedLine, clampedLine);
6083
+ const selectionEnd = Math.max(lineAnchor ?? clampedLine, clampedLine);
6084
+ const fileComments = selectedFile ? comments.filter((comment) => comment.file === selectedFile.path && comment.toolCallId === null) : [];
6085
+ const saveDraft = () => {
6086
+ const text = commentDraft?.trim() ?? "";
6087
+ if (!selectedFile || text.length === 0 || lines.length === 0) {
6088
+ setCommentDraft(null);
6089
+ setEditingId(null);
6090
+ return;
6091
+ }
6092
+ if (editingId) onCommentsChange(comments.map((comment) => comment.id === editingId ? {
6093
+ ...comment,
6094
+ text
6095
+ } : comment));
6096
+ else {
6097
+ const anchor = workspaceFileCommentAnchor({
6098
+ file: selectedFile.path,
6099
+ lines,
6100
+ start: selectionStart,
6101
+ end: selectionEnd
6102
+ });
6103
+ if (anchor) onCommentsChange([...comments, createComment({
6104
+ ...anchor,
6105
+ text
6106
+ })]);
6107
+ }
6108
+ setCommentDraft(null);
6109
+ setEditingId(null);
6110
+ setLineAnchor(null);
6111
+ };
6112
+ useKeyboard((key) => {
6113
+ if (!active) return;
6114
+ if (commentDraft !== null) {
6115
+ if (key.name === "escape") {
6116
+ if (editingId !== null) setLineAnchor(null);
6117
+ setCommentDraft(null);
6118
+ setEditingId(null);
6119
+ } else if (key.name === "return" || key.name === "kpenter") saveDraft();
6120
+ return;
6121
+ }
6122
+ if (fileSearch !== null) {
6123
+ if (key.name === "escape") setFileSearch(null);
6124
+ else if (key.name === "return" || key.name === "kpenter") setFileSearch(null);
6125
+ else if (key.name === "up") selectFileRow(moveFileRow(rows, selectedIndex, -1));
6126
+ else if (key.name === "down") selectFileRow(moveFileRow(rows, selectedIndex, 1));
6127
+ return;
6128
+ }
6129
+ if (previewSearch !== null) {
6130
+ if (key.name === "escape") {
6131
+ setPreviewSearch(null);
6132
+ setLineCursor(searchOrigin);
6133
+ } else if (key.name === "return" || key.name === "kpenter") {
6134
+ setCommittedSearch(previewSearch);
6135
+ setPreviewSearch(null);
6136
+ }
6137
+ return;
6138
+ }
6139
+ if (key.name === "/") {
6140
+ if (focus === "preview" && lines.length > 0) {
6141
+ setSearchOrigin(clampedLine);
6142
+ setPreviewSearch("");
6143
+ } else {
6144
+ setFocus("tree");
6145
+ setFileSearch("");
6146
+ }
6147
+ return;
6148
+ }
6149
+ const pageDirection = key.ctrl && key.name === "d" ? 1 : key.ctrl && key.name === "u" ? -1 : null;
6150
+ if (pageDirection !== null) {
6151
+ if (focus === "preview") setLineCursor(moveIndexByPage({
6152
+ current: clampedLine,
6153
+ total: lines.length,
6154
+ visibleRows: Math.max(2, shellDimensions.sourceHeight - 3),
6155
+ direction: pageDirection
6156
+ }));
6157
+ else {
6158
+ let next = selectedIndex;
6159
+ const steps = Math.max(1, Math.floor(Math.max(2, shellDimensions.navigatorHeight - 2) / 2));
6160
+ for (let i = 0; i < steps; i++) next = moveFileRow(rows, next, pageDirection);
6161
+ selectFileRow(next);
6162
+ }
6163
+ return;
6164
+ }
6165
+ if (focus === "preview") {
6166
+ if (key.name === "escape" || key.name === "h" || key.name === "left") {
6167
+ setFocus("tree");
6168
+ setLineAnchor(null);
6169
+ return;
6170
+ }
6171
+ if (key.name === "j" || key.name === "down") {
6172
+ setLineCursor(Math.min(Math.max(0, lines.length - 1), clampedLine + 1));
6173
+ return;
6174
+ }
6175
+ if (key.name === "k" || key.name === "up") {
6176
+ setLineCursor(Math.max(0, clampedLine - 1));
6177
+ return;
6178
+ }
6179
+ if (key.name === "v") {
6180
+ setLineAnchor((anchor) => anchor === null ? clampedLine : null);
6181
+ return;
6182
+ }
6183
+ if ((key.name === "n" || key.name === "N") && committedSearch.length > 0) {
6184
+ const direction = key.name === "N" || key.shift ? -1 : 1;
6185
+ const match = findPreviewMatch({
6186
+ lines,
6187
+ query: committedSearch,
6188
+ from: clampedLine + direction,
6189
+ direction
6190
+ });
6191
+ if (match !== null) setLineCursor(match);
6192
+ else setStatus(`no match: ${committedSearch}`);
6193
+ return;
6194
+ }
6195
+ if (key.name === "c" && lines.length > 0) {
6196
+ setCommentDraft("");
6197
+ setEditingId(null);
6198
+ return;
6199
+ }
6200
+ const commentAtLine = fileComments.find((comment) => comment.end === clampedLine + 1);
6201
+ if (key.name === "e" && commentAtLine) {
6202
+ setLineAnchor(commentAtLine.start - 1);
6203
+ setLineCursor(commentAtLine.end - 1);
6204
+ setCommentDraft(commentAtLine.text);
6205
+ setEditingId(commentAtLine.id);
6206
+ return;
6207
+ }
6208
+ if (key.name === "d" && commentAtLine) onCommentsChange(comments.filter((comment) => comment.id !== commentAtLine.id));
6209
+ return;
6210
+ }
6211
+ if (key.name === "escape") {
6212
+ onClose();
6213
+ return;
6214
+ }
6215
+ if (key.name === "j" || key.name === "down") {
6216
+ selectFileRow(moveFileRow(rows, selectedIndex, 1));
6217
+ return;
6218
+ }
6219
+ if (key.name === "k" || key.name === "up") {
6220
+ selectFileRow(moveFileRow(rows, selectedIndex, -1));
6221
+ return;
6222
+ }
6223
+ if ((key.name === "return" || key.name === "o" || key.name === "l" || key.name === "right") && (preview.kind === "loading" || preview.kind === "ready")) {
6224
+ setFocus("preview");
6225
+ setLineCursor(0);
6226
+ return;
6227
+ }
6228
+ if (key.name === "r") {
6229
+ setRefreshKey((n) => n + 1);
6230
+ return;
6231
+ }
6232
+ if (key.name === "y" && selectedFile) writeClipboardText(selectedFile.shareUrl).then((ok) => {
6233
+ setStatus(ok ? "share link copied" : "clipboard unavailable");
6234
+ });
6235
+ });
6236
+ const treeVisible = Math.max(2, shellDimensions.navigatorHeight - 2 - (fileSearch !== null ? 1 : 0));
6237
+ const treeStart = treeScroll === null ? windowStart(Math.max(selectedIndex, 0), rows.length, treeVisible) : Math.max(0, Math.min(rows.length - treeVisible, treeScroll));
6238
+ const windowedTree = rows.slice(treeStart, treeStart + treeVisible);
6239
+ const previewHeight = Math.max(3, shellDimensions.sourceHeight - (commentDraft !== null ? 4 : 2) - (previewSearch !== null ? 1 : 0));
6240
+ const sourceWidth = Math.max(16, shellDimensions.sourceWidth - 4);
6241
+ const lineStart = lineScroll === null ? windowStart(commentDraft !== null ? selectionEnd : clampedLine, lines.length, previewHeight - 1) : Math.max(0, Math.min(lines.length - (previewHeight - 1), lineScroll));
6242
+ const windowedLines = lines.slice(lineStart, lineStart + previewHeight - 1);
6243
+ const liveSearch = (previewSearch ?? "").trim().toLowerCase();
6244
+ const treeView = /* @__PURE__ */ jsxs("box", {
6245
+ style: {
6246
+ flexDirection: "column",
6247
+ flexGrow: 1
6248
+ },
6249
+ children: [fileSearch !== null ? /* @__PURE__ */ jsx(ReviewFileSearch, {
6250
+ query: fileSearch,
6251
+ onQueryChange: (query) => {
6252
+ setFileSearch(query);
6253
+ const first = load.kind === "ready" ? filterReviewFiles(load.files, query, (file) => file.path)[0] : null;
6254
+ if (first) onSelectedFilePathChange(first.path);
6255
+ }
6256
+ }) : null, load.kind === "loading" ? /* @__PURE__ */ jsx("text", {
6257
+ fg: theme.muted,
6258
+ children: "loading workspace files…"
6259
+ }) : load.kind === "error" ? /* @__PURE__ */ jsx("text", {
6260
+ fg: theme.error,
6261
+ children: load.message
6262
+ }) : load.files.length === 0 ? /* @__PURE__ */ jsx("text", {
6263
+ fg: theme.muted,
6264
+ children: "no files in the agent’s workspace volume"
6265
+ }) : rows.length === 0 ? /* @__PURE__ */ jsxs("text", {
6266
+ fg: theme.muted,
6267
+ children: [
6268
+ "no files match “",
6269
+ fileSearch,
6270
+ "”"
6271
+ ]
6272
+ }) : windowedTree.map((row, index) => {
6273
+ const absolute = treeStart + index;
6274
+ const indent = " ".repeat(row.depth);
6275
+ if (row.kind === "dir") return /* @__PURE__ */ jsxs("text", {
6276
+ fg: theme.muted,
6277
+ children: [
6278
+ indent,
6279
+ "▾ ",
6280
+ row.name,
6281
+ "/"
6282
+ ]
6283
+ }, `d-${row.path}`);
6284
+ const isCursorRow = absolute === selectedIndex;
6285
+ return /* @__PURE__ */ jsxs("text", {
6286
+ onMouseDown: () => {
6287
+ onSelectedFilePathChange(row.file.path);
6288
+ setFocus("tree");
6289
+ },
6290
+ fg: isCursorRow ? theme.accent : theme.fg,
6291
+ bg: isCursorRow ? theme.surface : void 0,
6292
+ children: [
6293
+ isCursorRow ? "› " : " ",
6294
+ indent,
6295
+ row.name,
6296
+ /* @__PURE__ */ jsxs("span", {
6297
+ fg: theme.faint,
6298
+ children: [" · ", formatBytes$1(row.file.sizeBytes)]
6299
+ })
6300
+ ]
6301
+ }, row.file.id);
6302
+ })]
6303
+ });
6304
+ const previewView = /* @__PURE__ */ jsxs("box", {
6305
+ style: {
6306
+ flexDirection: "column",
6307
+ flexGrow: 1,
6308
+ height: previewHeight
6309
+ },
6310
+ children: [
6311
+ selectedFile ? /* @__PURE__ */ jsxs("text", {
6312
+ fg: focus === "preview" ? theme.accent : theme.muted,
6313
+ children: [selectedFile.path, focus === "preview" ? " · preview focused" : ""]
6314
+ }) : null,
6315
+ previewSearch !== null ? /* @__PURE__ */ jsx(ReviewFileSearch, {
6316
+ query: previewSearch,
6317
+ placeholder: "search buffer…",
6318
+ onQueryChange: (query) => {
6319
+ setPreviewSearch(query);
6320
+ const match = findPreviewMatch({
6321
+ lines,
6322
+ query,
6323
+ from: searchOrigin,
6324
+ direction: 1
6325
+ });
6326
+ if (match !== null) setLineCursor(match);
6327
+ }
6328
+ }) : null,
6329
+ preview.kind === "loading" ? /* @__PURE__ */ jsx("text", {
6330
+ fg: theme.muted,
6331
+ children: "loading preview…"
6332
+ }) : preview.kind === "ready" ? windowedLines.map((line, index) => {
6333
+ const absolute = lineStart + index;
6334
+ const isCursorLine = focus === "preview" && absolute === clampedLine;
6335
+ const selectedLine = focus === "preview" && lineAnchor !== null && absolute >= selectionStart && absolute <= selectionEnd || liveSearch.length > 0 && line.toLowerCase().includes(liveSearch);
6336
+ const commented = fileComments.some((comment) => absolute + 1 >= comment.start && absolute + 1 <= comment.end);
6337
+ const commentsHere = fileComments.filter((comment) => comment.end === absolute + 1);
6338
+ return /* @__PURE__ */ jsxs("box", {
6339
+ style: { flexDirection: "column" },
6340
+ children: [
6341
+ /* @__PURE__ */ jsx(ReviewSourceRow, {
6342
+ file: selectedFile?.path ?? "",
6343
+ row: {
6344
+ kind: "ctx",
6345
+ text: line,
6346
+ lineNo: absolute + 1
6347
+ },
6348
+ cursor: isCursorLine,
6349
+ selected: selectedLine,
6350
+ commented,
6351
+ width: sourceWidth
6352
+ }),
6353
+ commentDraft !== null && absolute === selectionEnd ? /* @__PURE__ */ jsx("box", {
6354
+ title: ` comment · ${selectedFile?.path ?? ""}:${selectionStart + 1}${selectionStart === selectionEnd ? "" : `-${selectionEnd + 1}`} `,
6355
+ style: {
6356
+ border: true,
6357
+ borderColor: theme.warning,
6358
+ marginLeft: 2,
6359
+ paddingLeft: 1,
6360
+ paddingRight: 1,
6361
+ height: 3
6362
+ },
6363
+ children: /* @__PURE__ */ jsx("input", {
6364
+ placeholder: "Leave a comment…",
6365
+ focused: true,
6366
+ width: Math.max(8, width - 10),
6367
+ value: commentDraft,
6368
+ onInput: setCommentDraft
6369
+ })
6370
+ }) : null,
6371
+ commentsHere.filter((comment) => comment.id !== editingId).map((comment) => /* @__PURE__ */ jsx(ReviewCommentCard, {
6372
+ comment,
6373
+ width: sourceWidth
6374
+ }, comment.id))
6375
+ ]
6376
+ }, absolute);
6377
+ }) : /* @__PURE__ */ jsx("text", {
6378
+ fg: preview.kind === "error" ? theme.error : theme.muted,
6379
+ children: preview.message
6380
+ }),
6381
+ preview.kind === "ready" && preview.truncated ? /* @__PURE__ */ jsx("text", {
6382
+ fg: theme.warning,
6383
+ children: "… preview truncated"
6384
+ }) : null
6385
+ ]
6386
+ });
6387
+ const hint = commentDraft !== null ? "↵ save comment · esc cancel · ctrl+g close" : previewSearch !== null ? "↵ jump · esc cancel" : focus === "preview" ? "j/k lines · esc file tree · / search · n/N match · v select · c comment · ctrl+g close" : "j/k files · / search · l/↵ preview · ctrl+d/u page · ctrl+g close";
6388
+ return /* @__PURE__ */ jsxs("box", {
6389
+ style: {
6390
+ flexDirection: "column",
6391
+ flexGrow: 1
6392
+ },
6393
+ children: [/* @__PURE__ */ jsx(ReviewShell, {
6394
+ width,
6395
+ height: shellHeight,
6396
+ layout: shellLayout,
6397
+ navigatorPreferredWidth,
6398
+ navigatorSize: navigatorSize ?? void 0,
6399
+ onNavigatorResize,
6400
+ onNavigatorScroll: (delta) => setTreeScroll(Math.max(0, Math.min(Math.max(0, rows.length - treeVisible), treeStart + delta))),
6401
+ onSourceScroll: (delta) => setLineScroll(Math.max(0, Math.min(Math.max(0, lines.length - (previewHeight - 1)), lineStart + delta))),
6402
+ focus: focus === "tree" ? "navigator" : "source",
6403
+ onFocus: (next) => setFocus(next === "navigator" ? "tree" : "preview"),
6404
+ navigator: treeView,
6405
+ source: previewView
6406
+ }), /* @__PURE__ */ jsx("text", {
6407
+ fg: status ? theme.warning : theme.dim,
6408
+ children: status ?? hint
6409
+ })]
6410
+ });
6411
+ }
6412
+
6413
+ //#endregion
6414
+ //#region src/chat/tui/screens/review.tsx
6415
+ /**
6416
+ * Review pane (opened with ctrl+g): every edit the agent made in this
6417
+ * conversation as reviewable hunks, with inline comments that export back
6418
+ * into the composer (herdr-reviewr model — see src/chat/review/*). Rendered
6419
+ * as an overlay in place of the transcript+composer while the chat screen
6420
+ * stays mounted, so the conversation and any live run survive it.
6421
+ *
6422
+ * Pending comments persist per conversation (write-through on every
6423
+ * mutation), so switching conversations or killing the TUI never loses one.
6424
+ */
6425
+ const store = new ReviewCommentStore(getReviewStateDir());
6426
+ /** Run comment GC once per process, off the open path. */
6427
+ let gcStarted = false;
6428
+ function gcOnce() {
6429
+ if (gcStarted) return;
6430
+ gcStarted = true;
6431
+ store.gc();
6432
+ }
6433
+ function ReviewPane({ agentId, conversationId, items, viewportWidth, viewportHeight, active, shellLayout, tab, onTabChange, selectedChangeFile, onSelectedChangeFileChange, selectedWorkspaceFile, onSelectedWorkspaceFileChange, navigatorSize, onNavigatorResize, onInsertToComposer, onRequestChatFocus, onClose }) {
6434
+ const width = viewportWidth;
6435
+ const height = viewportHeight;
6436
+ const shellHeight = Math.max(6, height - 2);
6437
+ const [scope, setScope] = useState("session");
6438
+ const [comments, setComments] = useState([]);
6439
+ const [loaded, setLoaded] = useState(false);
6440
+ const [reviewFocus, setReviewFocus] = useState("navigator");
6441
+ const [changeSearch, setChangeSearch] = useState(null);
6442
+ const [cursor, setCursor] = useState(0);
6443
+ const [anchor, setAnchor] = useState(null);
6444
+ const [composing, setComposing] = useState(null);
6445
+ const [draft, setDraft] = useState("");
6446
+ const [filesComposing, setFilesComposing] = useState(false);
6447
+ const [status, setStatus] = useState(null);
6448
+ const [navScroll, setNavScroll] = useState(null);
6449
+ const [sourceScroll, setSourceScroll] = useState(null);
6450
+ useEffect(() => {
6451
+ if (status === null) return;
6452
+ const timer = setTimeout(() => setStatus(null), 2500);
6453
+ return () => clearTimeout(timer);
6454
+ }, [status]);
6455
+ useEffect(() => {
6456
+ setSourceScroll(null);
6457
+ }, [cursor, selectedChangeFile]);
6458
+ useEffect(() => {
6459
+ setNavScroll(null);
6460
+ }, [selectedChangeFile]);
6461
+ useEffect(() => {
6462
+ gcOnce();
6463
+ let cancelled = false;
6464
+ (async () => {
6465
+ const persisted = conversationId ? await store.load(conversationId) : [];
6466
+ if (cancelled) return;
6467
+ setComments(persisted);
6468
+ setLoaded(true);
6469
+ })();
6470
+ return () => {
6471
+ cancelled = true;
6472
+ };
6473
+ }, [conversationId]);
6474
+ const persist = (next) => {
6475
+ setComments(next);
6476
+ if (conversationId) store.save(conversationId, next);
6477
+ };
6478
+ const changeset = useMemo(() => scopeChangeset(buildChangeset(items), scope), [items, scope]);
6479
+ const orderedFiles = useMemo(() => [...changeset.files].sort((a, b) => a.file.localeCompare(b.file)), [changeset]);
6480
+ const visibleFiles = useMemo(() => filterReviewFiles(orderedFiles, changeSearch ?? "", (file) => file.file), [orderedFiles, changeSearch]);
6481
+ const selectedFileIndex = Math.max(0, visibleFiles.findIndex((file) => file.file === selectedChangeFile));
6482
+ const selectedFile = visibleFiles[selectedFileIndex] ?? null;
6483
+ const selectChangedFile = (index) => {
6484
+ onSelectedChangeFileChange(visibleFiles[index]?.file ?? null);
6485
+ };
6486
+ const selectedChangeset = useMemo(() => ({
6487
+ ...changeset,
6488
+ files: selectedFile ? [selectedFile] : []
6489
+ }), [changeset, selectedFile]);
6490
+ const rows = useMemo(() => buildPaneRows(selectedChangeset, comments.filter((comment) => comment.toolCallId !== null && comment.file === selectedFile?.file)).filter((row) => row.kind !== "file"), [
6491
+ selectedChangeset,
6492
+ comments,
6493
+ selectedFile?.file
6494
+ ]);
6495
+ const clamped = useMemo(() => {
6496
+ const row = rows[cursor];
6497
+ if (row && isSelectableRow(row)) return cursor;
6498
+ return nearestSelectable(rows, cursor);
6499
+ }, [rows, cursor]);
6500
+ const selection = anchor !== null && clamped >= 0 ? resolveSelection(rows, anchor, clamped) : null;
6501
+ const commitDraft = () => {
6502
+ const text = draft.trim();
6503
+ if (!composing || text.length === 0) {
6504
+ setComposing(null);
6505
+ setDraft("");
6506
+ return;
6507
+ }
6508
+ if (composing.kind === "edit") persist(comments.map((c) => c.id === composing.comment.id ? {
6509
+ ...c,
6510
+ text
6511
+ } : c));
6512
+ else persist([...comments, createComment({
6513
+ ...composing.anchor,
6514
+ text
6515
+ })]);
6516
+ setComposing(null);
6517
+ setDraft("");
6518
+ setAnchor(null);
6519
+ };
6520
+ const changesTree = useMemo(() => buildReviewTree(visibleFiles, (file) => file.file), [visibleFiles]);
6521
+ const navigatorPreferredWidth = reviewTreePreferredWidth(changesTree, (file) => {
6522
+ const stats = fileChangeStats(file);
6523
+ return ` +${stats.additions} -${stats.deletions}`.length;
6524
+ });
6525
+ const shellDimensions = reviewShellDimensions({
6526
+ width,
6527
+ height: shellHeight,
6528
+ layout: shellLayout,
6529
+ focus: reviewFocus,
6530
+ navigatorPreferredWidth,
6531
+ navigatorSize: navigatorSize ?? void 0
6532
+ });
6533
+ const selectedTreeIndex = Math.max(0, changesTree.findIndex((row) => row.kind === "file" && row.path === selectedFile?.file));
6534
+ const visible = Math.max(3, shellDimensions.sourceHeight - (composing ? 5 : 2));
6535
+ const start = sourceScroll === null ? windowStart(Math.max(clamped, 0), rows.length, visible) : Math.max(0, Math.min(rows.length - visible, sourceScroll));
6536
+ const windowed = rows.slice(start, start + visible);
6537
+ const navigatorVisible = Math.max(2, shellDimensions.navigatorHeight - 2 - (changeSearch !== null ? 1 : 0));
6538
+ const navigatorStart = navScroll === null ? windowStart(selectedTreeIndex, changesTree.length, navigatorVisible) : Math.max(0, Math.min(changesTree.length - navigatorVisible, navScroll));
6539
+ const navigatorRows = changesTree.slice(navigatorStart, navigatorStart + navigatorVisible);
6540
+ const sourceWidth = Math.max(16, shellDimensions.sourceWidth - 4);
6541
+ useKeyboard((key) => {
6542
+ if (!active) return;
6543
+ if (composing) {
6544
+ if (key.name === "escape") {
6545
+ setComposing(null);
6546
+ setDraft("");
6547
+ } else if (key.name === "return" || key.name === "kpenter") commitDraft();
6548
+ return;
6549
+ }
6550
+ if (tab === "files" && filesComposing) return;
6551
+ if (tab === "changes" && changeSearch !== null) {
6552
+ if (key.name === "escape") setChangeSearch(null);
6553
+ else if (key.name === "return" || key.name === "kpenter") setChangeSearch(null);
6554
+ else if (key.name === "up") selectChangedFile(Math.max(0, selectedFileIndex - 1));
6555
+ else if (key.name === "down") selectChangedFile(Math.min(visibleFiles.length - 1, selectedFileIndex + 1));
6556
+ return;
6557
+ }
6558
+ if (key.name === "/") {
6559
+ if (tab === "changes") {
6560
+ setReviewFocus("navigator");
6561
+ setChangeSearch("");
6562
+ }
6563
+ return;
6564
+ }
6565
+ if (key.name === "q") {
6566
+ onClose();
6567
+ return;
6568
+ }
6569
+ if (key.name === "escape") {
6570
+ if (tab === "changes" && reviewFocus === "source") {
6571
+ setReviewFocus("navigator");
6572
+ setAnchor(null);
6573
+ } else if (tab === "changes") onClose();
6574
+ return;
6575
+ }
6576
+ if (key.name === "1") {
6577
+ onTabChange("changes");
6578
+ return;
6579
+ }
6580
+ if (key.name === "2") {
6581
+ onTabChange("files");
6582
+ return;
6583
+ }
6584
+ if (key.name === "s") {
6585
+ if (comments.length === 0) {
6586
+ setStatus("no pending comments");
6587
+ return;
6588
+ }
6589
+ onInsertToComposer(formatCommentsForExport(comments));
6590
+ persist([]);
6591
+ onRequestChatFocus();
6592
+ return;
6593
+ }
6594
+ if (tab === "files") return;
6595
+ const pageDirection = key.ctrl && key.name === "d" ? 1 : key.ctrl && key.name === "u" ? -1 : null;
6596
+ if (pageDirection !== null) {
6597
+ if (reviewFocus === "navigator") {
6598
+ selectChangedFile(moveIndexByPage({
6599
+ current: selectedFileIndex,
6600
+ total: visibleFiles.length,
6601
+ visibleRows: Math.max(2, shellDimensions.navigatorHeight - 2),
6602
+ direction: pageDirection
6603
+ }));
6604
+ setCursor(0);
6605
+ setAnchor(null);
6606
+ } else {
6607
+ let next = clamped;
6608
+ const steps = Math.max(1, Math.floor(visible / 2));
6609
+ for (let i = 0; i < steps; i++) next = moveCursor(rows, next, pageDirection);
6610
+ setCursor(next);
6611
+ }
6612
+ return;
6613
+ }
6614
+ if (reviewFocus === "navigator") {
6615
+ if (key.name === "j" || key.name === "down") {
6616
+ selectChangedFile(Math.min(visibleFiles.length - 1, selectedFileIndex + 1));
6617
+ setCursor(0);
6618
+ setAnchor(null);
6619
+ return;
6620
+ }
6621
+ if (key.name === "k" || key.name === "up") {
6622
+ selectChangedFile(Math.max(0, selectedFileIndex - 1));
6623
+ setCursor(0);
6624
+ setAnchor(null);
6625
+ return;
6626
+ }
6627
+ if (key.name === "return" || key.name === "o" || key.name === "l" || key.name === "right") setReviewFocus("source");
6628
+ return;
6629
+ }
6630
+ if (key.name === "h" || key.name === "left") {
6631
+ setReviewFocus("navigator");
6632
+ setAnchor(null);
6633
+ return;
6634
+ }
6635
+ if (key.name === "j" || key.name === "down") {
6636
+ setCursor(moveCursor(rows, clamped, 1));
6637
+ return;
6638
+ }
6639
+ if (key.name === "k" || key.name === "up") {
6640
+ setCursor(moveCursor(rows, clamped, -1));
6641
+ return;
6642
+ }
6643
+ if (key.name === "]") {
6644
+ setCursor(moveHunk(rows, clamped, 1));
6645
+ return;
6646
+ }
6647
+ if (key.name === "[") {
6648
+ setCursor(moveHunk(rows, clamped, -1));
6649
+ return;
6650
+ }
6651
+ if (key.name === "f" && !key.shift) {
6652
+ selectChangedFile(Math.min(visibleFiles.length - 1, selectedFileIndex + 1));
6653
+ setCursor(0);
6654
+ setAnchor(null);
6655
+ return;
6656
+ }
6657
+ if (key.name === "f" && key.shift || key.name === "F") {
6658
+ selectChangedFile(Math.max(0, selectedFileIndex - 1));
6659
+ setCursor(0);
6660
+ setAnchor(null);
6661
+ return;
6662
+ }
6663
+ if (key.name === "t") {
6664
+ setScope((s) => s === "session" ? "last-turn" : "session");
6665
+ setAnchor(null);
6666
+ return;
6667
+ }
6668
+ if (key.name === "v") {
6669
+ setAnchor((a) => a === null && clamped >= 0 ? clamped : null);
6670
+ return;
6671
+ }
6672
+ if (key.name === "c") {
6673
+ if (clamped < 0) return;
6674
+ if (anchor !== null && selection === null) {
6675
+ setStatus("selection must stay within one hunk");
6676
+ return;
6677
+ }
6678
+ const sel = selection ?? resolveSelection(rows, clamped, clamped);
6679
+ const anchorInfo = sel ? anchorForSelection(rows, sel) : null;
6680
+ if (!sel || !anchorInfo) {
6681
+ setStatus("selection must stay within one hunk");
6682
+ return;
6683
+ }
6684
+ setComposing({
6685
+ kind: "new",
6686
+ anchorLo: sel.lo,
6687
+ anchorHi: sel.hi,
6688
+ anchor: anchorInfo
6689
+ });
6690
+ setDraft("");
6691
+ return;
6692
+ }
6693
+ if (key.name === "e") {
6694
+ const target = commentAtCursor(rows, clamped);
6695
+ if (!target) return;
6696
+ setComposing({
6697
+ kind: "edit",
6698
+ comment: target
6699
+ });
6700
+ setDraft(target.text);
6701
+ return;
6702
+ }
6703
+ if (key.name === "d") {
6704
+ const target = commentAtCursor(rows, clamped);
6705
+ if (!target) return;
6706
+ persist(comments.filter((c) => c.id !== target.id));
6707
+ return;
6708
+ }
6709
+ if (key.name === "y") {
6710
+ if (comments.length === 0) return;
6711
+ writeClipboardText(formatCommentsForExport(comments)).then((ok) => {
6712
+ if (ok) {
6713
+ persist([]);
6714
+ setStatus("copied to clipboard");
6715
+ } else setStatus("clipboard unavailable — comments kept");
6716
+ });
6717
+ return;
6718
+ }
6719
+ });
6720
+ const scopeLabel = scope === "session" ? "session" : "last turn";
6721
+ const hint = composing ? "↵ save · esc cancel · ctrl+g close" : reviewFocus === "source" ? "j/k move · esc file tree · v select · c comment · s send · ctrl+g close" : "j/k files · / search · l/↵ preview · ctrl+d/u page · ctrl+g close";
6722
+ return /* @__PURE__ */ jsxs("box", {
6723
+ style: {
6724
+ flexDirection: "column",
6725
+ flexGrow: 1
6726
+ },
6727
+ children: [
6728
+ /* @__PURE__ */ jsxs("box", {
6729
+ style: {
6730
+ flexDirection: "row",
6731
+ height: 1
6732
+ },
6733
+ children: [
6734
+ /* @__PURE__ */ jsx("text", {
6735
+ fg: theme.accent,
6736
+ children: "file pane"
6737
+ }),
6738
+ /* @__PURE__ */ jsxs("text", {
6739
+ fg: tab === "changes" ? theme.accent : theme.muted,
6740
+ onMouseDown: () => onTabChange("changes"),
6741
+ children: [" ", "1 changes"]
6742
+ }),
6743
+ /* @__PURE__ */ jsxs("text", {
6744
+ fg: tab === "files" ? theme.accent : theme.muted,
6745
+ onMouseDown: () => onTabChange("files"),
6746
+ children: [" ", "2 files"]
6747
+ }),
6748
+ /* @__PURE__ */ jsxs("text", {
6749
+ fg: theme.muted,
6750
+ children: [
6751
+ " ",
6752
+ tab === "changes" ? `· ${changeset.files.length} changed · ` : "· ",
6753
+ comments.length,
6754
+ " comment",
6755
+ comments.length === 1 ? "" : "s",
6756
+ tab === "changes" ? ` · ${scopeLabel}` : " · s sends all"
6757
+ ]
6758
+ })
6759
+ ]
6760
+ }),
6761
+ tab === "files" ? /* @__PURE__ */ jsx(WorkspaceFilesTab, {
6762
+ agentId,
6763
+ viewportWidth: width,
6764
+ viewportHeight: height - 1,
6765
+ active,
6766
+ shellLayout,
6767
+ selectedFilePath: selectedWorkspaceFile,
6768
+ onSelectedFilePathChange: onSelectedWorkspaceFileChange,
6769
+ navigatorSize,
6770
+ onNavigatorResize,
6771
+ comments,
6772
+ onCommentsChange: persist,
6773
+ onComposingChange: setFilesComposing,
6774
+ onClose
6775
+ }) : /* @__PURE__ */ jsx(ReviewShell, {
6776
+ width,
6777
+ height: shellHeight,
6778
+ layout: shellLayout,
6779
+ navigatorPreferredWidth,
6780
+ navigatorSize: navigatorSize ?? void 0,
6781
+ onNavigatorResize,
6782
+ onNavigatorScroll: (delta) => setNavScroll(Math.max(0, Math.min(Math.max(0, changesTree.length - navigatorVisible), navigatorStart + delta))),
6783
+ onSourceScroll: (delta) => setSourceScroll(Math.max(0, Math.min(Math.max(0, rows.length - visible), start + delta))),
6784
+ focus: reviewFocus,
6785
+ onFocus: setReviewFocus,
6786
+ navigator: /* @__PURE__ */ jsxs("box", {
6787
+ style: {
6788
+ flexDirection: "column",
6789
+ flexGrow: 1
6790
+ },
6791
+ children: [changeSearch !== null ? /* @__PURE__ */ jsx(ReviewFileSearch, {
6792
+ query: changeSearch,
6793
+ onQueryChange: (query) => {
6794
+ setChangeSearch(query);
6795
+ const first = filterReviewFiles(orderedFiles, query, (file) => file.file)[0];
6796
+ if (first) onSelectedChangeFileChange(first.file);
6797
+ }
6798
+ }) : null, !loaded ? /* @__PURE__ */ jsx("text", {
6799
+ fg: theme.muted,
6800
+ children: "loading…"
6801
+ }) : changeset.files.length === 0 ? /* @__PURE__ */ jsx("text", {
6802
+ fg: theme.muted,
6803
+ children: "no changed files"
6804
+ }) : navigatorRows.length === 0 ? /* @__PURE__ */ jsxs("text", {
6805
+ fg: theme.muted,
6806
+ children: [
6807
+ "no files match “",
6808
+ changeSearch,
6809
+ "”"
6810
+ ]
6811
+ }) : navigatorRows.map((row) => {
6812
+ const indent = " ".repeat(row.depth);
6813
+ if (row.kind === "dir") return /* @__PURE__ */ jsxs("text", {
6814
+ fg: theme.muted,
6815
+ children: [
6816
+ indent,
6817
+ "▾ ",
6818
+ row.name,
6819
+ "/"
6820
+ ]
6821
+ }, `d-${row.path}`);
6822
+ const selected = row.path === selectedFile?.file;
6823
+ const stats = fileChangeStats(row.value);
6824
+ return /* @__PURE__ */ jsxs("text", {
6825
+ onMouseDown: () => {
6826
+ onSelectedChangeFileChange(row.path);
6827
+ setCursor(0);
6828
+ setAnchor(null);
6829
+ setReviewFocus("navigator");
6830
+ },
6831
+ fg: selected ? theme.accent : theme.fg,
6832
+ bg: selected ? reviewFocus === "navigator" ? blendHex(theme.fg, theme.surface, .22) : blendHex(theme.fg, theme.surface, .12) : void 0,
6833
+ children: [
6834
+ selected ? "› " : " ",
6835
+ indent,
6836
+ row.name,
6837
+ " ",
6838
+ /* @__PURE__ */ jsxs("span", {
6839
+ fg: theme.success,
6840
+ children: ["+", stats.additions]
6841
+ }),
6842
+ " ",
6843
+ /* @__PURE__ */ jsxs("span", {
6844
+ fg: theme.error,
6845
+ children: ["-", stats.deletions]
6846
+ })
6847
+ ]
6848
+ }, row.path);
6849
+ })]
6850
+ }),
6851
+ source: /* @__PURE__ */ jsxs("box", {
6852
+ style: {
6853
+ flexDirection: "column",
6854
+ flexGrow: 1
6855
+ },
6856
+ children: [selectedFile ? /* @__PURE__ */ jsx("text", {
6857
+ fg: theme.accent,
6858
+ children: selectedFile.file
6859
+ }) : null, rows.length === 0 ? /* @__PURE__ */ jsxs("text", {
6860
+ fg: theme.muted,
6861
+ children: ["no edits in this conversation yet", scope === "last-turn" ? " (t widens)" : ""]
6862
+ }) : windowed.map((row, i) => {
6863
+ const absolute = start + i;
6864
+ const composingNewHere = composing?.kind === "new" && absolute === composing.anchorHi;
6865
+ const editingHere = composing?.kind === "edit" && (row.kind === "comment" || row.kind === "stale-comment") && row.comment.id === composing.comment.id;
6866
+ const composeAfter = composingNewHere || editingHere;
6867
+ return /* @__PURE__ */ jsxs("box", {
6868
+ style: { flexDirection: "column" },
6869
+ children: [!editingHere ? /* @__PURE__ */ jsx(PaneRowView, {
6870
+ row,
6871
+ width: sourceWidth,
6872
+ isCursor: reviewFocus === "source" && absolute === clamped,
6873
+ inSelection: selection !== null && absolute >= selection.lo && absolute <= selection.hi,
6874
+ isAnchor: anchor === absolute,
6875
+ isCommented: row.kind === "diff" && comments.some((comment) => commentCoversDiffRow(comment, row))
6876
+ }) : null, composeAfter ? /* @__PURE__ */ jsx("box", {
6877
+ title: editingHere ? " edit comment " : " comment ",
6878
+ style: {
6879
+ border: true,
6880
+ borderColor: theme.warning,
6881
+ marginLeft: 2,
6882
+ paddingLeft: 1,
6883
+ paddingRight: 1,
6884
+ height: 3
6885
+ },
6886
+ children: /* @__PURE__ */ jsx("input", {
6887
+ placeholder: "Leave a comment…",
6888
+ focused: true,
6889
+ width: Math.max(8, sourceWidth - 8),
6890
+ value: draft,
6891
+ onInput: setDraft
6892
+ })
6893
+ }) : null]
6894
+ }, absolute);
6895
+ })]
6896
+ })
6897
+ }),
6898
+ tab === "changes" ? /* @__PURE__ */ jsx("text", {
6899
+ fg: status ? theme.warning : theme.dim,
6900
+ children: status ?? hint
6901
+ }) : null
6902
+ ]
6903
+ });
6904
+ }
6905
+ function PaneRowView({ row, width, isCursor, inSelection, isAnchor, isCommented }) {
6906
+ if (row.kind === "file") return /* @__PURE__ */ jsxs("text", {
6907
+ fg: theme.accent,
6908
+ children: [
6909
+ "▎",
6910
+ row.file,
6911
+ /* @__PURE__ */ jsxs("span", {
6912
+ fg: theme.muted,
6913
+ children: [
6914
+ " ",
6915
+ "(",
6916
+ row.hunkCount,
6917
+ " edit",
6918
+ row.hunkCount === 1 ? "" : "s",
6919
+ ")"
6920
+ ]
6921
+ })
6922
+ ]
6923
+ });
6924
+ if (row.kind === "hunk-sep") return /* @__PURE__ */ jsx("text", {
6925
+ fg: theme.faint,
6926
+ children: " ⋯"
6927
+ });
6928
+ if (row.kind === "stale-header") return /* @__PURE__ */ jsxs("text", {
6929
+ fg: theme.warning,
6930
+ children: [
6931
+ "▎",
6932
+ row.count,
6933
+ " comment",
6934
+ row.count === 1 ? "" : "s",
6935
+ " on edits no longer in scope"
6936
+ ]
6937
+ });
6938
+ if (row.kind === "comment") return /* @__PURE__ */ jsx(ReviewCommentCard, {
6939
+ comment: row.comment,
6940
+ width
6941
+ });
6942
+ if (row.kind === "stale-comment") return /* @__PURE__ */ jsxs("text", {
6943
+ fg: theme.muted,
6944
+ children: [
6945
+ " ",
6946
+ "┃ ",
6947
+ row.comment.text,
6948
+ /* @__PURE__ */ jsxs("span", {
6949
+ fg: theme.faint,
6950
+ children: [
6951
+ " ",
6952
+ "(",
6953
+ row.comment.file,
6954
+ ":",
6955
+ row.comment.start,
6956
+ ")"
6957
+ ]
6958
+ })
6959
+ ]
6960
+ });
6961
+ return /* @__PURE__ */ jsx(ReviewSourceRow, {
6962
+ file: row.hunk.file,
6963
+ row: row.row,
6964
+ cursor: isCursor,
6965
+ selected: inSelection || isAnchor,
6966
+ commented: isCommented,
6967
+ width
6968
+ });
6969
+ }
6970
+
6971
+ //#endregion
6972
+ //#region src/chat/tui/file-pane-layout.ts
6973
+ /**
6974
+ * Responsive placement for the file pane.
6975
+ *
6976
+ * Wide terminals keep the conversation readable and place review beside it.
6977
+ * Tall terminals without enough horizontal room stack review underneath.
6978
+ * Cramped terminals give review the full body rather than rendering two
6979
+ * unusably small surfaces.
6980
+ */
6981
+ function filePanePlacement({ width, height }) {
6982
+ if (width >= 140 && height >= 24) return "side";
6983
+ if (width >= 50 && height >= 40) return "below";
6984
+ return "fullscreen";
6985
+ }
6986
+ /** Keep a dragged side pane wide enough to use and leave the chat usable. */
6987
+ function clampSidePaneWidth(width, bodyWidth) {
6988
+ return Math.max(44, Math.min(bodyWidth - 30, width));
6989
+ }
6990
+ /** Keep a dragged below pane tall enough to use and leave the chat usable. */
6991
+ function clampBelowPaneHeight(height, bodyHeight) {
6992
+ return Math.max(9, Math.min(bodyHeight - 12, height));
6993
+ }
6994
+ function sidePaneWidth(width) {
6995
+ return Math.max(64, Math.floor(width * .5));
6996
+ }
6997
+ function belowPaneHeight(height) {
6998
+ return Math.max(14, Math.floor(height * .5));
6999
+ }
7000
+
7001
+ //#endregion
7002
+ //#region src/chat/shell/snapshot.ts
7003
+ /**
7004
+ * Shell snapshot for the `!` local-shell escape.
7005
+ *
7006
+ * The chat composer runs on the user's own machine. When they type `! <cmd>`,
7007
+ * we want that command to see their real interactive-shell setup — aliases,
7008
+ * functions, shell options, PATH — the same way it would in their terminal.
7009
+ *
7010
+ * Aliases and functions live in ~/.zshrc / ~/.bashrc, which non-interactive
7011
+ * shells don't source (or source without alias expansion), and are never
7012
+ * exported to child processes. Re-sourcing the rc file on every command is slow
7013
+ * (100s of ms with nvm/rbenv/oh-my-zsh) and can hang on interactive prompts.
7014
+ *
7015
+ * So, once per session, we run the user's login shell, source their rc, and
7016
+ * serialize the resulting state into a standalone .sh file. Every `!` command
7017
+ * then sources that cheap file and runs. This mirrors the approach Claude Code
7018
+ * uses for its bash mode.
7019
+ *
7020
+ * The snapshot is cached on disk keyed by (shell, rc-file mtime), so it is
7021
+ * built once and reused across CLI sessions, and regenerated automatically when
7022
+ * the user edits their rc file. There is deliberately no `!!` sigil — a second
7023
+ * bang has a base shell meaning (history expansion) we do not override; the
7024
+ * agent-sandbox counterpart is the `/sandbox` slash command, not a sigil.
7025
+ */
7026
+ const SNAPSHOT_CREATION_TIMEOUT_MS = 1e4;
7027
+ const SNAPSHOT_MAX_BUFFER = 1024 * 1024;
7028
+ /**
7029
+ * Resolve which shell the `!` escape uses. Order:
7030
+ * $SKYDIVE_SHELL (if it names bash/zsh and is executable)
7031
+ * $SHELL (if it names bash/zsh and is executable)
7032
+ * `zsh` / `bash` found on $PATH
7033
+ * `zsh` / `bash` in common install locations
7034
+ * /bin/sh as a last resort
7035
+ * Preference is bash-first when $SHELL mentions bash, else zsh-first — matching
7036
+ * the user's actual login shell where we can tell.
7037
+ */
7038
+ async function resolveShell(env = process.env) {
7039
+ const override = env.SKYDIVE_SHELL;
7040
+ if (override && /bash|zsh/.test(override) && await isExecutable(override)) return describeShell(override);
7041
+ const shellEnv = env.SHELL;
7042
+ if (shellEnv && /bash|zsh/.test(shellEnv) && await isExecutable(shellEnv)) return describeShell(shellEnv);
7043
+ const candidates = !!shellEnv && shellEnv.includes("bash") ? ["bash", "zsh"] : ["zsh", "bash"];
7044
+ for (const name of candidates) {
7045
+ const found = await whichOnPath(name, env);
7046
+ if (found) return describeShell(found);
5002
7047
  }
5003
7048
  const searchDirs = [
5004
7049
  "/bin",
@@ -5418,7 +7463,7 @@ function isActionableCard(m) {
5418
7463
  }
5419
7464
  function ChatScreen({ agent, conversation }) {
5420
7465
  const renderer = useRenderer();
5421
- const { height } = useTerminalDimensions();
7466
+ const { width, height } = useTerminalDimensions();
5422
7467
  const rest = useStore((s) => s.rest);
5423
7468
  const history = usePromptHistory({ filePath: useStore((s) => s.promptHistoryPath) });
5424
7469
  const appUrl = useStore((s) => s.appUrl);
@@ -5443,6 +7488,23 @@ function ChatScreen({ agent, conversation }) {
5443
7488
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
5444
7489
  const [themePickerOpen, setThemePickerOpen] = useState(false);
5445
7490
  const [helpOpen, setHelpOpen] = useState(false);
7491
+ const [reviewOpen, setReviewOpen] = useState(false);
7492
+ const [reviewFocused, setReviewFocused] = useState(false);
7493
+ const [reviewTab, setReviewTab] = useState("changes");
7494
+ const [reviewChangeFile, setReviewChangeFile] = useState(null);
7495
+ const [reviewSizePref, setReviewSizePref] = useState({
7496
+ side: null,
7497
+ below: null
7498
+ });
7499
+ const [reviewNavSizePref, setReviewNavSizePref] = useState({
7500
+ side: null,
7501
+ stacked: null
7502
+ });
7503
+ const [reviewWorkspaceFile, setReviewWorkspaceFile] = useState(null);
7504
+ useEffect(() => {
7505
+ setReviewChangeFile(null);
7506
+ setReviewWorkspaceFile(null);
7507
+ }, [initialConversationId]);
5446
7508
  const [ctrlCArmed, setCtrlCArmed] = useState(false);
5447
7509
  const [composerRows, setComposerRows] = useState(1);
5448
7510
  const [attachments, setAttachments] = useState([]);
@@ -5481,16 +7543,42 @@ function ChatScreen({ agent, conversation }) {
5481
7543
  }, [agentHost, conversationId]);
5482
7544
  const scrollRef = useRef(null);
5483
7545
  const composerRef = useRef(null);
7546
+ const reviewSideBoxRef = useRef(null);
7547
+ const reviewBelowBoxRef = useRef(null);
7548
+ const paneDragRef = useRef(null);
5484
7549
  const composerBoxHeight = composerRows + 2;
5485
7550
  const pendingVisible = !grantPrompt && !credPrompt && attachments.length > 0;
5486
7551
  const pendingRows = pendingVisible ? attachments.length + 2 : 0;
5487
7552
  const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
5488
7553
  const bottomBoxHeight = credPrompt ? credPromptHeight : composerBoxHeight;
7554
+ const bodyWidth = Math.max(1, width - 2);
7555
+ const bodyHeight = Math.max(1, height - 3);
7556
+ const reviewPlacement = filePanePlacement({
7557
+ width,
7558
+ height
7559
+ });
7560
+ const reviewWidth = reviewPlacement === "side" ? clampSidePaneWidth(reviewSizePref.side ?? sidePaneWidth(bodyWidth), bodyWidth) : bodyWidth;
7561
+ const reviewHeight = reviewPlacement === "below" ? clampBelowPaneHeight(reviewSizePref.below ?? belowPaneHeight(bodyHeight), bodyHeight) : bodyHeight;
7562
+ const reviewContentWidth = reviewPlacement === "side" ? Math.max(1, reviewWidth - 4) : reviewPlacement === "below" ? Math.max(1, bodyWidth - 4) : bodyWidth;
7563
+ const reviewContentHeight = reviewPlacement === "fullscreen" ? bodyHeight : Math.max(1, reviewHeight - 2);
7564
+ const chatHeight = bodyHeight;
7565
+ const inlineReviewHeight = reviewOpen && reviewPlacement === "below" ? reviewHeight : 0;
5489
7566
  const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
5490
- const scrollHeight = Math.max(3, height - 5 - bottomBoxHeight - pendingRows - menuRows);
7567
+ const scrollHeight = Math.max(3, chatHeight - 2 - bottomBoxHeight - pendingRows - inlineReviewHeight - menuRows);
5491
7568
  useEffect(() => {
7569
+ if (reviewOpen && reviewFocused) {
7570
+ const composer = composerRef.current;
7571
+ if (!composer) return;
7572
+ return suppressComposerFocus(composer);
7573
+ }
5492
7574
  const composer = composerRef.current;
5493
7575
  if (!composer) return;
7576
+ const draft = inputRef.current;
7577
+ if (composer.plainText !== draft) {
7578
+ composer.setText(draft);
7579
+ composer.cursorOffset = draft.length;
7580
+ setComposerRows(Math.min(Math.max(composer.editorView.getTotalVirtualLineCount(), 1), maxComposerRows));
7581
+ }
5494
7582
  composer.focus();
5495
7583
  const refocus = () => {
5496
7584
  setTimeout(() => {
@@ -5506,6 +7594,8 @@ function ChatScreen({ agent, conversation }) {
5506
7594
  modelPickerOpen,
5507
7595
  themePickerOpen,
5508
7596
  helpOpen,
7597
+ reviewOpen,
7598
+ reviewFocused,
5509
7599
  credPromptOpen
5510
7600
  ]);
5511
7601
  useEffect(() => {
@@ -6333,6 +8423,17 @@ function ChatScreen({ agent, conversation }) {
6333
8423
  agentHost
6334
8424
  ]);
6335
8425
  useKeyboard((key) => {
8426
+ if (reviewOpen) {
8427
+ if (key.name === "g" && key.ctrl) {
8428
+ if (reviewPlacement === "fullscreen" && !reviewFocused) setReviewFocused(true);
8429
+ else {
8430
+ setReviewOpen(false);
8431
+ setReviewFocused(false);
8432
+ }
8433
+ return;
8434
+ }
8435
+ if (reviewFocused) return;
8436
+ }
6336
8437
  if (modelPickerOpen || themePickerOpen || helpOpen) return;
6337
8438
  if (grantPrompt) {
6338
8439
  if (key.name === "y") {
@@ -6415,6 +8516,11 @@ function ChatScreen({ agent, conversation }) {
6415
8516
  key.preventDefault();
6416
8517
  return;
6417
8518
  }
8519
+ if (key.name === "g" && key.ctrl) {
8520
+ setReviewOpen(true);
8521
+ setReviewFocused(true);
8522
+ return;
8523
+ }
6418
8524
  if (key.name === "p" && key.ctrl) {
6419
8525
  setModelPickerOpen(true);
6420
8526
  return;
@@ -6521,12 +8627,89 @@ function ChatScreen({ agent, conversation }) {
6521
8627
  });
6522
8628
  if (themePickerOpen) return /* @__PURE__ */ jsx(ThemePicker, { onClose: () => setThemePickerOpen(false) });
6523
8629
  if (helpOpen) return /* @__PURE__ */ jsx(HelpModal, { onClose: () => setHelpOpen(false) });
6524
- return /* @__PURE__ */ jsxs("box", {
8630
+ const reviewView = reviewOpen ? /* @__PURE__ */ jsx(ReviewPane, {
8631
+ agentId: agent.id,
8632
+ conversationId,
8633
+ items,
8634
+ viewportWidth: reviewContentWidth,
8635
+ viewportHeight: reviewContentHeight,
8636
+ active: reviewFocused,
8637
+ shellLayout: reviewPlacement === "below" ? "stacked" : "side-by-side",
8638
+ tab: reviewTab,
8639
+ onTabChange: setReviewTab,
8640
+ selectedChangeFile: reviewChangeFile,
8641
+ onSelectedChangeFileChange: setReviewChangeFile,
8642
+ selectedWorkspaceFile: reviewWorkspaceFile,
8643
+ onSelectedWorkspaceFileChange: setReviewWorkspaceFile,
8644
+ navigatorSize: reviewPlacement === "below" ? reviewNavSizePref.stacked : reviewNavSizePref.side,
8645
+ onNavigatorResize: (size) => setReviewNavSizePref((prev) => reviewPlacement === "below" ? {
8646
+ ...prev,
8647
+ stacked: size
8648
+ } : {
8649
+ ...prev,
8650
+ side: size
8651
+ }),
8652
+ onInsertToComposer: (review) => {
8653
+ const next = [inputRef.current.trimEnd(), review].filter((part) => part.length > 0).join("\n\n");
8654
+ setInput(next);
8655
+ inputRef.current = next;
8656
+ },
8657
+ onRequestChatFocus: () => setReviewFocused(false),
8658
+ onClose: () => {
8659
+ setReviewOpen(false);
8660
+ setReviewFocused(false);
8661
+ }
8662
+ }) : null;
8663
+ const chatView = /* @__PURE__ */ jsxs("box", {
8664
+ onMouseDown: (event) => {
8665
+ const paneBox = reviewBelowBoxRef.current;
8666
+ if (reviewOpen && reviewPlacement === "below" && paneBox && event.y === paneBox.screenY + paneBox.height - 1) {
8667
+ paneDragRef.current = {
8668
+ start: event.y,
8669
+ size: reviewHeight
8670
+ };
8671
+ setReviewFocused(true);
8672
+ } else setReviewFocused(false);
8673
+ },
8674
+ onMouseDrag: (event) => {
8675
+ const drag = paneDragRef.current;
8676
+ if (!drag || reviewPlacement !== "below") return;
8677
+ setReviewSizePref((prev) => ({
8678
+ ...prev,
8679
+ below: drag.size + (event.y - drag.start)
8680
+ }));
8681
+ },
8682
+ onMouseDragEnd: () => {
8683
+ paneDragRef.current = null;
8684
+ },
8685
+ onMouseUp: () => {
8686
+ paneDragRef.current = null;
8687
+ },
6525
8688
  style: {
6526
8689
  flexDirection: "column",
6527
- flexGrow: 1
8690
+ flexGrow: 1,
8691
+ height: chatHeight
6528
8692
  },
6529
8693
  children: [
8694
+ reviewView && reviewPlacement === "below" ? /* @__PURE__ */ jsx("box", {
8695
+ ref: reviewBelowBoxRef,
8696
+ onMouseDown: (event) => {
8697
+ const paneBox = reviewBelowBoxRef.current;
8698
+ if (paneBox && event.y === paneBox.screenY + paneBox.height - 1) return;
8699
+ setReviewFocused(true);
8700
+ event.stopPropagation();
8701
+ },
8702
+ style: {
8703
+ width: bodyWidth,
8704
+ height: reviewHeight,
8705
+ border: true,
8706
+ borderColor: reviewFocused ? theme.accent : theme.faint,
8707
+ paddingLeft: 1,
8708
+ paddingRight: 1,
8709
+ flexShrink: 0
8710
+ },
8711
+ children: reviewView
8712
+ }, "review-above") : null,
6530
8713
  /* @__PURE__ */ jsx("scrollbox", {
6531
8714
  ref: scrollRef,
6532
8715
  style: {
@@ -6689,6 +8872,58 @@ function ChatScreen({ agent, conversation }) {
6689
8872
  })
6690
8873
  ]
6691
8874
  });
8875
+ if (!reviewView || reviewPlacement === "below") return chatView;
8876
+ const fullscreen = reviewPlacement === "fullscreen";
8877
+ return /* @__PURE__ */ jsxs("box", {
8878
+ onMouseDown: (event) => {
8879
+ const paneBox = reviewSideBoxRef.current;
8880
+ if (!fullscreen && paneBox && event.x >= paneBox.screenX - 1 && event.x <= paneBox.screenX + 2) {
8881
+ paneDragRef.current = {
8882
+ start: event.x,
8883
+ size: reviewWidth
8884
+ };
8885
+ setReviewFocused(true);
8886
+ }
8887
+ },
8888
+ onMouseDrag: (event) => {
8889
+ const drag = paneDragRef.current;
8890
+ if (!drag) return;
8891
+ setReviewSizePref((prev) => ({
8892
+ ...prev,
8893
+ side: drag.size + (drag.start - event.x)
8894
+ }));
8895
+ },
8896
+ onMouseDragEnd: () => {
8897
+ paneDragRef.current = null;
8898
+ },
8899
+ onMouseUp: () => {
8900
+ paneDragRef.current = null;
8901
+ },
8902
+ style: {
8903
+ flexDirection: reviewPlacement === "side" ? "row" : "column",
8904
+ flexGrow: 1
8905
+ },
8906
+ children: [!fullscreen || !reviewFocused ? /* @__PURE__ */ jsx("box", {
8907
+ onMouseDown: () => setReviewFocused(false),
8908
+ style: {
8909
+ flexGrow: 1,
8910
+ paddingRight: reviewPlacement === "side" ? 1 : 0
8911
+ },
8912
+ children: chatView
8913
+ }, "chat") : null, !fullscreen || reviewFocused ? /* @__PURE__ */ jsx("box", {
8914
+ ref: reviewSideBoxRef,
8915
+ onMouseDown: () => setReviewFocused(true),
8916
+ style: {
8917
+ width: reviewPlacement === "side" ? reviewWidth : bodyWidth,
8918
+ height: bodyHeight,
8919
+ border: !fullscreen,
8920
+ borderColor: reviewFocused ? theme.accent : theme.faint,
8921
+ paddingLeft: fullscreen ? 0 : 1,
8922
+ paddingRight: fullscreen ? 0 : 1
8923
+ },
8924
+ children: reviewView
8925
+ }, "review") : null]
8926
+ });
6692
8927
  }
6693
8928
  /**
6694
8929
  * The composer-replacement prompt for a connect card's credential fields.