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