nitpiq 0.1.0

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.
@@ -0,0 +1,1089 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import Fuse from "fuse.js";
4
+ import pc from "picocolors";
5
+ import {
6
+ changes,
7
+ diff,
8
+ files,
9
+ kindSymbol,
10
+ readFile,
11
+ stage,
12
+ unstage,
13
+ type FileChange,
14
+ type Repo,
15
+ } from "../git/repo";
16
+ import { error } from "../log/log";
17
+ import { extractContext, extractRangeAnchor, relocateThreads } from "../review/anchor";
18
+ import { AuthorHuman, ThreadOpen, ThreadResolved, type Comment, type ReviewSession, type Thread } from "../review/types";
19
+ import { Store } from "../store/store";
20
+ import type { DemoState } from "./demo";
21
+ import { fullFileRows, parseDiffRows, threadMap, visibleWindow, type DiffRow } from "./diff";
22
+ import { clearHighlightCache, highlightLine, renderMarkdown } from "./highlight";
23
+ import type { Theme } from "./theme";
24
+ import { bg, fg, getTheme } from "./theme";
25
+
26
+ type FocusPane = "files" | "diff";
27
+ type InputMode = "normal" | "comment" | "reply" | "filter" | "search" | "goto" | "visual" | "confirmDelete";
28
+ type FileListMode = "changes" | "all";
29
+ type ViewRow =
30
+ | { kind: "diff"; row: DiffRow; diffIdx: number }
31
+ | { kind: "spacer" }
32
+ | { kind: "thread-border"; position: "top" | "bottom"; resolved: boolean }
33
+ | { kind: "comment-separator"; resolved: boolean }
34
+ | { kind: "inline-comment"; author: string; body: string; resolved: boolean; showAuthor: boolean }
35
+ | { kind: "input-border"; position: "top" | "bottom" }
36
+ | { kind: "input" };
37
+
38
+ // ── Child Components ─────────────────────────────────────────────
39
+
40
+ interface FileSidebarProps {
41
+ listedPaths: string[];
42
+ fileCursor: number;
43
+ focused: boolean;
44
+ fileChangeMap: Map<string, FileChange>;
45
+ threadCounts: Record<string, number>;
46
+ theme: Theme;
47
+ width: number;
48
+ height: number;
49
+ }
50
+
51
+ function FileSidebar({ listedPaths, fileCursor, focused, fileChangeMap, threadCounts, theme: t, width, height }: FileSidebarProps) {
52
+ const win = visibleWindow(listedPaths, fileCursor, height);
53
+ const blank = " ".repeat(width);
54
+ const rows: string[] = [];
55
+ for (let i = 0; i < height; i++) {
56
+ if (i >= win.items.length) { rows.push(blank); continue; }
57
+ const absIdx = win.start + i;
58
+ const filePath = win.items[i]!;
59
+ const change = fileChangeMap.get(filePath);
60
+ const sel = absIdx === fileCursor;
61
+ const sym = change ? kindSymbol(change.kind) : " ";
62
+ const csym = colorSymbol(sym, change?.kind, t);
63
+ const tc = threadCounts[filePath] ?? 0;
64
+ const badge = tc > 0 ? fg(t.thread, ` ${tc}`) : "";
65
+ const stg = change?.staged && !change.unstaged ? fg(t.staged, " ✓") : "";
66
+ const pre = sel && focused ? fg(t.accent, "›") : " ";
67
+ const name = sel ? pc.white(filePath) : pc.dim(filePath);
68
+ const line = ` ${pre} ${csym} ${name}${badge}${stg}`;
69
+ rows.push(sel && focused ? bg(t.selection, padAnsi(line, width)) : padAnsi(line, width));
70
+ }
71
+ return (
72
+ <Box width={width} flexDirection="column">
73
+ {rows.map((row, i) => <Text key={i} wrap="truncate-end">{row}</Text>)}
74
+ </Box>
75
+ );
76
+ }
77
+
78
+ interface DiffPaneProps {
79
+ viewRows: ViewRow[];
80
+ visualCursor: number;
81
+ diffCursor: number;
82
+ focused: boolean;
83
+ markers: ReturnType<typeof threadMap>;
84
+ inputMode: InputMode;
85
+ draft: string;
86
+ theme: Theme;
87
+ width: number;
88
+ height: number;
89
+ visualAnchor: number | null;
90
+ scrollOffset: number | null;
91
+ }
92
+
93
+ function DiffPane({ viewRows, visualCursor, diffCursor, focused, markers, inputMode, draft, theme: t, width, height, visualAnchor, scrollOffset }: DiffPaneProps) {
94
+ const win = scrollOffset !== null
95
+ ? scrolledWindow(viewRows, scrollOffset, height)
96
+ : visibleWindow(viewRows, visualCursor, height);
97
+ const blank = " ".repeat(width);
98
+ const rows: string[] = [];
99
+ for (let i = 0; i < height; i++) {
100
+ if (i >= win.items.length) { rows.push(blank); continue; }
101
+ const vr = win.items[i]!;
102
+
103
+ if (vr.kind === "spacer") {
104
+ rows.push(blank);
105
+ continue;
106
+ }
107
+
108
+ if (vr.kind === "thread-border") {
109
+ const indent = " ";
110
+ const dashW = Math.max(1, width - 11);
111
+ const clr = vr.resolved ? t.staged : t.thread;
112
+ const corner = vr.position === "top" ? "╭" : "╰";
113
+ const cap = vr.position === "top" ? "╮" : "╯";
114
+ rows.push(padAnsi(`${indent}${fg(clr, `${corner}${"─".repeat(dashW)}${cap}`)}`, width));
115
+ continue;
116
+ }
117
+
118
+ if (vr.kind === "comment-separator") {
119
+ const indent = " ";
120
+ const dashW = Math.max(1, width - 11);
121
+ const clr = vr.resolved ? t.staged : t.thread;
122
+ const sep = `├${"─".repeat(dashW)}┤`;
123
+ rows.push(vr.resolved
124
+ ? padAnsi(`${indent}${pc.dim(sep)}`, width)
125
+ : bg(t.threadBg, padAnsi(`${indent}${fg(clr, sep)}`, width)));
126
+ continue;
127
+ }
128
+
129
+ if (vr.kind === "inline-comment") {
130
+ const clr = vr.resolved ? t.staged : t.thread;
131
+ const pipe = vr.resolved ? pc.dim("│") : fg(clr, "│");
132
+ const indent = " ";
133
+ const contentW = Math.max(1, width - 12);
134
+ let line: string;
135
+ if (vr.showAuthor) {
136
+ const authorClr = vr.author === "model" ? t.accent : t.thread;
137
+ const authorTag = vr.resolved ? pc.dim(pc.bold(vr.author)) : fg(authorClr, pc.bold(vr.author));
138
+ line = `${indent}${pipe} ${padAnsi(authorTag, contentW)}${pipe}`;
139
+ } else {
140
+ const body = vr.resolved ? pc.dim(vr.body) : renderMarkdown(vr.body, t);
141
+ line = `${indent}${pipe} ${padAnsi(body, contentW)}${pipe}`;
142
+ }
143
+ rows.push(vr.resolved ? padAnsi(line, width) : bg(t.threadBg, padAnsi(line, width)));
144
+ continue;
145
+ }
146
+
147
+ if (vr.kind === "input-border") {
148
+ const indent = " ";
149
+ const dashW = Math.max(1, width - 11);
150
+ const corner = vr.position === "top" ? "╭" : "╰";
151
+ const cap = vr.position === "top" ? "╮" : "╯";
152
+ rows.push(padAnsi(`${indent}${fg(t.accent, `${corner}${"─".repeat(dashW)}${cap}`)}`, width));
153
+ continue;
154
+ }
155
+
156
+ if (vr.kind === "input") {
157
+ const contentW = Math.max(1, width - 12);
158
+ const pipe = fg(t.accent, "│");
159
+ const label = inputMode === "reply" ? "reply" : "comment";
160
+ const content = ` ${pipe} ${padAnsi(`${fg(t.accent, label)}${pc.dim(":")} ${draft}█`, contentW)}${pipe}`;
161
+ rows.push(bg(t.cursor, padAnsi(content, width)));
162
+ continue;
163
+ }
164
+
165
+ const row = vr.row;
166
+ const sel = vr.diffIdx === diffCursor && focused;
167
+ const inVisualRange = visualAnchor !== null && focused &&
168
+ vr.diffIdx >= Math.min(visualAnchor, diffCursor) &&
169
+ vr.diffIdx <= Math.max(visualAnchor, diffCursor);
170
+ const lineNum = row.newLine ?? row.oldLine;
171
+ const lbl = lineNum ? String(lineNum).padStart(3) : " ";
172
+ const mark = lineNum && markers.has(lineNum) ? fg(t.thread, "●") : " ";
173
+
174
+ const sign = row.kind === "add" ? fg(t.add, "+")
175
+ : row.kind === "delete" ? fg(t.del, "-")
176
+ : " ";
177
+
178
+ const text = row.kind === "header" || row.kind === "meta" ? pc.dim(row.text)
179
+ : row.kind === "hunk" ? fg(t.hunk, row.text)
180
+ : highlightLine(row.text, t);
181
+
182
+ const content = ` ${mark}${lbl} ${sign} ${text}`;
183
+
184
+ if (sel) { rows.push(bg(t.cursor, padAnsi(content, width))); continue; }
185
+ if (inVisualRange) { rows.push(bg(t.selection, padAnsi(content, width))); continue; }
186
+ if (row.kind === "add") { rows.push(bg(t.addBg, padAnsi(content, width))); continue; }
187
+ if (row.kind === "delete") { rows.push(bg(t.delBg, padAnsi(content, width))); continue; }
188
+ rows.push(padAnsi(content, width));
189
+ }
190
+ return (
191
+ <Box flexGrow={1} flexDirection="column">
192
+ {rows.map((row, i) => <Text key={i} wrap="truncate-end">{row}</Text>)}
193
+ </Box>
194
+ );
195
+ }
196
+
197
+ // ── Main Component ───────────────────────────────────────────────
198
+
199
+ interface AppProps {
200
+ repo: Repo;
201
+ store: Store | null;
202
+ demoState?: DemoState;
203
+ snapshot?: boolean;
204
+ theme?: string;
205
+ }
206
+
207
+ export function NitpiqApp({ repo, store, demoState, snapshot = false, theme }: AppProps) {
208
+ const { exit } = useApp();
209
+ const { stdout } = useStdout();
210
+ const t = getTheme(theme);
211
+ const initialDemoFile = demoState?.files[demoState.fileCursor] ?? demoState?.files[0] ?? null;
212
+ const [session, setSession] = useState<ReviewSession | null>(demoState?.session ?? null);
213
+ const [fileChanges, setFileChanges] = useState<FileChange[]>(demoState?.files.map((file) => file.change) ?? []);
214
+ const [repoFiles, setRepoFiles] = useState<string[]>(demoState?.repoFiles ?? []);
215
+ const [focus, setFocus] = useState<FocusPane>(demoState?.focus ?? "files");
216
+ const [inputMode, setInputMode] = useState<InputMode>("normal");
217
+ const [filterQuery, setFilterQuery] = useState("");
218
+ const [searchQuery, setSearchQuery] = useState("");
219
+ const [draft, setDraft] = useState("");
220
+ const [status, setStatus] = useState(demoState?.status ?? "Loading...");
221
+ const [showFullFile, setShowFullFile] = useState(demoState?.showFullFile ?? false);
222
+ const [fileCursor, setFileCursor] = useState(demoState?.fileCursor ?? 0);
223
+ const [diffCursor, setDiffCursor] = useState(demoState?.diffCursor ?? 0);
224
+ const [currentPath, setCurrentPath] = useState(initialDemoFile?.change.path ?? "");
225
+ const [currentDiff, setCurrentDiff] = useState(initialDemoFile?.diff ?? "");
226
+ const [currentContent, setCurrentContent] = useState(initialDemoFile?.content ?? "");
227
+ const [threads, setThreads] = useState<Thread[]>(initialDemoFile?.threads ?? []);
228
+ const [commentsByThread, setCommentsByThread] = useState<Record<string, Comment[]>>(initialDemoFile?.commentsByThread ?? {});
229
+ const [threadCounts, setThreadCounts] = useState<Record<string, number>>(demoState?.threadCounts ?? {});
230
+ const [fileListMode, setFileListMode] = useState<FileListMode>("changes");
231
+ const [expandedContext, setExpandedContext] = useState(3);
232
+ const [visualAnchor, setVisualAnchor] = useState<number | null>(null);
233
+ const [deleteTarget, setDeleteTarget] = useState<Thread | null>(null);
234
+ const [allThreads, setAllThreads] = useState<Thread[]>([]);
235
+ const [pendingLine, setPendingLine] = useState<number | null>(null);
236
+ const [countPrefix, setCountPrefix] = useState("");
237
+ const [pendingG, setPendingG] = useState(false);
238
+ const [pendingZ, setPendingZ] = useState(false);
239
+ const [scrollOffset, setScrollOffset] = useState<number | null>(null);
240
+ const isDemo = Boolean(demoState);
241
+
242
+ // ── Derived data (compiler auto-memoizes) ──────────────────────
243
+
244
+ const allPaths = fileListMode === "changes"
245
+ ? fileChanges.map((change) => change.path)
246
+ : repoFiles;
247
+
248
+ let listedPaths: string[];
249
+ if (!filterQuery) {
250
+ listedPaths = allPaths;
251
+ } else {
252
+ const fuse = new Fuse<{ path: string }>(allPaths.map((p) => ({ path: p })), { keys: ["path"], threshold: 0.4 });
253
+ listedPaths = fuse.search(filterQuery).map((match) => match.item.path);
254
+ }
255
+
256
+ const fileChangeMap = new Map<string, FileChange>();
257
+ for (const change of fileChanges) fileChangeMap.set(change.path, change);
258
+
259
+ const selectedPath = listedPaths[fileCursor] ?? "";
260
+ const selectedChange = fileChangeMap.get(selectedPath) ?? null;
261
+
262
+ const diffRows = showFullFile ? fullFileRows(currentContent) : parseDiffRows(currentDiff);
263
+
264
+ let searchMatches: number[];
265
+ if (!searchQuery) {
266
+ searchMatches = [];
267
+ } else {
268
+ const needle = searchQuery.toLowerCase();
269
+ searchMatches = diffRows
270
+ .map((row, index) => ({ row, index }))
271
+ .filter(({ row }) => row.text.toLowerCase().includes(needle))
272
+ .map(({ index }) => index);
273
+ }
274
+
275
+ const markers = threadMap(threads);
276
+ const selectedLine = diffRows[diffCursor]?.newLine ?? diffRows[diffCursor]?.oldLine ?? null;
277
+ const threadsAtCursor = selectedLine ? markers.get(selectedLine) ?? [] : [];
278
+ const threadAtLine = threadsAtCursor[0]?.thread ?? null;
279
+
280
+ const tw = Math.max(stdout.columns || 120, 80);
281
+ const sidebarW = Math.max(18, Math.floor(tw * 0.22));
282
+ const diffPaneW = Math.max(10, (tw - 2) - sidebarW - 3);
283
+ // Comment box: indent(7) + pipe(1) + space(1) + ... + space(1) + pipe(1) + space(1) = 12 total overhead
284
+ const commentInnerW = Math.max(20, diffPaneW - 12);
285
+
286
+ const baseViewRows: ViewRow[] = [];
287
+ const diffToView = new Map<number, number>();
288
+ for (let i = 0; i < diffRows.length; i++) {
289
+ diffToView.set(i, baseViewRows.length);
290
+ baseViewRows.push({ kind: "diff", row: diffRows[i]!, diffIdx: i });
291
+
292
+ const lineNum = diffRows[i]!.newLine ?? diffRows[i]!.oldLine;
293
+ if (lineNum) {
294
+ const entries = markers.get(lineNum);
295
+ if (entries) {
296
+ for (const { thread } of entries) {
297
+ const cs = commentsByThread[thread.id] ?? [];
298
+ if (cs.length === 0) continue;
299
+ const resolved = thread.status === ThreadResolved;
300
+ baseViewRows.push({ kind: "spacer" });
301
+ baseViewRows.push({ kind: "thread-border", position: "top", resolved });
302
+ for (let ci = 0; ci < cs.length; ci++) {
303
+ if (ci > 0) {
304
+ baseViewRows.push({ kind: "comment-separator", resolved });
305
+ }
306
+ baseViewRows.push({
307
+ kind: "inline-comment",
308
+ author: cs[ci]!.author,
309
+ body: "",
310
+ resolved,
311
+ showAuthor: true,
312
+ });
313
+ const wrappedLines = wrapText(cs[ci]!.body, commentInnerW, commentInnerW);
314
+ for (const wl of wrappedLines) {
315
+ baseViewRows.push({
316
+ kind: "inline-comment",
317
+ author: cs[ci]!.author,
318
+ body: wl,
319
+ resolved,
320
+ showAuthor: false,
321
+ });
322
+ }
323
+ }
324
+ baseViewRows.push({ kind: "thread-border", position: "bottom", resolved });
325
+ baseViewRows.push({ kind: "spacer" });
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ const isInlineInput = inputMode === "comment" || inputMode === "reply";
332
+ let viewRows: ViewRow[];
333
+ if (!isInlineInput) {
334
+ viewRows = baseViewRows;
335
+ } else {
336
+ const baseIdx = diffToView.get(diffCursor) ?? 0;
337
+ let insertAt = baseIdx + 1;
338
+ if (inputMode === "reply") {
339
+ for (let si = baseIdx + 1; si < baseViewRows.length; si++) {
340
+ const vr = baseViewRows[si]!;
341
+ if (vr.kind === "thread-border" && vr.position === "bottom") { insertAt = si + 1; break; }
342
+ if (vr.kind === "diff") break;
343
+ }
344
+ }
345
+ viewRows = baseViewRows.slice();
346
+ viewRows.splice(insertAt, 0,
347
+ { kind: "spacer" },
348
+ { kind: "input-border", position: "top" },
349
+ { kind: "input" },
350
+ { kind: "input-border", position: "bottom" },
351
+ { kind: "spacer" },
352
+ );
353
+ }
354
+
355
+ const visualCursor = diffToView.get(diffCursor) ?? 0;
356
+
357
+ // ── Handlers ───────────────────────────────────────────────────
358
+
359
+ const refreshAll = (activeSession: ReviewSession, soft = false): void => {
360
+ if (!store) return;
361
+
362
+ try {
363
+ const nextChanges = changes(repo);
364
+ const nextRepoFiles = files(repo);
365
+ setFileChanges(nextChanges);
366
+ setRepoFiles(nextRepoFiles);
367
+ setThreadCounts(store.threadCountsByFile(activeSession.id));
368
+ setAllThreads(store.listThreads(activeSession.id));
369
+
370
+ if (!soft) {
371
+ setStatus(`Loaded ${nextChanges.length} change(s)`);
372
+ }
373
+
374
+ if (currentPath) {
375
+ loadThreads(activeSession, currentPath);
376
+ }
377
+ } catch (cause) {
378
+ const message = cause instanceof Error ? cause.message : String(cause);
379
+ setStatus(message);
380
+ error(message);
381
+ }
382
+ };
383
+
384
+ const openPath = async (nextPath: string): Promise<void> => {
385
+ clearHighlightCache();
386
+ setCurrentPath(nextPath);
387
+ setDiffCursor(0);
388
+ if (demoState) {
389
+ const selected = demoState.files.find((file) => file.change.path === nextPath);
390
+ if (selected) {
391
+ setCurrentContent(selected.content);
392
+ setCurrentDiff(selected.diff);
393
+ setThreads(selected.threads);
394
+ setCommentsByThread(selected.commentsByThread);
395
+ setStatus(demoState.status);
396
+ }
397
+ return;
398
+ }
399
+
400
+ let content: string;
401
+ try {
402
+ content = readFile(repo, nextPath);
403
+ } catch (cause) {
404
+ const message = cause instanceof Error ? cause.message : String(cause);
405
+ setStatus(message);
406
+ error(message);
407
+ return;
408
+ }
409
+ const change = fileChangeMap.get(nextPath);
410
+ const fileDiff = change ? diff(repo, change, expandedContext) : "";
411
+ setCurrentContent(content);
412
+ setCurrentDiff(fileDiff);
413
+ if (!fileDiff) setShowFullFile(true);
414
+ if (session) {
415
+ loadThreads(session, nextPath, content);
416
+ }
417
+ };
418
+
419
+ const loadThreads = (activeSession: ReviewSession, filePath: string, content = currentContent): void => {
420
+ if (!store) return;
421
+
422
+ const relocated = relocateThreads(store.listThreads(activeSession.id, filePath), content.split("\n"));
423
+ for (const thread of relocated) {
424
+ store.updateThreadLine(thread.id, thread.currentLine, thread.isOutdated);
425
+ }
426
+ setThreads(relocated);
427
+ setCommentsByThread(store.listCommentsForThreads(relocated.map((thread) => thread.id)));
428
+ setAllThreads(store.listThreads(activeSession.id));
429
+ };
430
+
431
+ const jumpToThread = (thread: Thread): void => {
432
+ if (thread.filePath === currentPath) {
433
+ const idx = diffRows.findIndex((r) => r.newLine === thread.currentLine || r.oldLine === thread.currentLine);
434
+ if (idx >= 0) setDiffCursor(idx);
435
+ setStatus(`Thread at ${thread.filePath}:${thread.currentLine}`);
436
+ } else {
437
+ const fileIdx = listedPaths.indexOf(thread.filePath);
438
+ if (fileIdx >= 0) {
439
+ setFileCursor(fileIdx);
440
+ }
441
+ setPendingLine(thread.currentLine);
442
+ setFocus("diff");
443
+ void openPath(thread.filePath);
444
+ setStatus(`Thread at ${thread.filePath}:${thread.currentLine}`);
445
+ }
446
+ };
447
+
448
+ const handleFileInput = (input: string, key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }): void => {
449
+ if (input === "q") { exit(); return; }
450
+ if (input === "j" || key.downArrow) { setFileCursor((c: number) => Math.min(c + 1, Math.max(0, listedPaths.length - 1))); return; }
451
+ if (input === "k" || key.upArrow) { setFileCursor((c: number) => Math.max(c - 1, 0)); return; }
452
+ if (input === "l" || key.return) { setFocus("diff"); return; }
453
+ if (input === "/") { setInputMode("filter"); setDraft(filterQuery); setStatus("Filter files"); return; }
454
+ if (input === "f") {
455
+ setFileListMode((c) => c === "changes" ? "all" : "changes");
456
+ setFileCursor(0);
457
+ setFilterQuery("");
458
+ setStatus(fileListMode === "changes" ? "All files" : "Git changes");
459
+ return;
460
+ }
461
+ if (input === "r") {
462
+ if (isDemo) { setStatus("Demo mode - refresh is disabled"); return; }
463
+ if (session) { refreshAll(session); setStatus("Refreshed"); }
464
+ return;
465
+ }
466
+ if (input === "s" && selectedChange) {
467
+ if (isDemo) { setStatus("Demo mode - staging is disabled"); return; }
468
+ const shouldUnstage = selectedChange.staged && !selectedChange.unstaged;
469
+ try {
470
+ if (shouldUnstage) { unstage(repo, selectedChange.path); }
471
+ else { stage(repo, selectedChange.path); }
472
+ } catch (cause) {
473
+ setStatus(cause instanceof Error ? cause.message : String(cause));
474
+ return;
475
+ }
476
+ setStatus(shouldUnstage ? `Unstaged ${selectedChange.path}` : `Staged ${selectedChange.path}`);
477
+ if (session) { refreshAll(session); }
478
+ return;
479
+ }
480
+ };
481
+
482
+ const handleDiffInput = (input: string, key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean; ctrl?: boolean; shift?: boolean }): void => {
483
+ const lastRow = Math.max(0, diffRows.length - 1);
484
+ const visibleH = Math.max(((stdout.rows || 30) - 6), 4);
485
+ const halfPage = Math.max(Math.floor(visibleH / 2), 4);
486
+
487
+ // Consume count prefix digits (only in normal diff mode, not during pending-g/z)
488
+ if (/^[0-9]$/.test(input) && !key.ctrl && !pendingG && !pendingZ) {
489
+ setCountPrefix((c) => c + input);
490
+ return;
491
+ }
492
+
493
+ const count = countPrefix ? parseInt(countPrefix, 10) : 1;
494
+ const clearCount = () => { setCountPrefix(""); setPendingG(false); setPendingZ(false); setScrollOffset(null); };
495
+
496
+ // ── Pending z commands (zz, zt, zb) ──
497
+ if (pendingZ) {
498
+ if (input === "z") {
499
+ setScrollOffset(Math.max(0, visualCursor - Math.floor(visibleH / 2)));
500
+ setStatus("Centered");
501
+ } else if (input === "t") {
502
+ setScrollOffset(Math.max(0, visualCursor));
503
+ setStatus("Scrolled to top");
504
+ } else if (input === "b") {
505
+ setScrollOffset(Math.max(0, visualCursor - visibleH + 1));
506
+ setStatus("Scrolled to bottom");
507
+ }
508
+ setPendingZ(false);
509
+ setCountPrefix("");
510
+ return;
511
+ }
512
+
513
+ // ── Pending g commands (gg) ──
514
+ if (pendingG) {
515
+ if (input === "g") {
516
+ setScrollOffset(null);
517
+ setDiffCursor(count > 1 ? Math.min(count - 1, lastRow) : 0);
518
+ }
519
+ clearCount();
520
+ return;
521
+ }
522
+
523
+ // ── Navigation ──
524
+ if (input === "q" || key.escape || input === "h") { clearCount(); setFocus("files"); return; }
525
+
526
+ if (input === "j" || key.downArrow) { setScrollOffset(null); setDiffCursor((c: number) => Math.min(c + count, lastRow)); clearCount(); return; }
527
+ if (input === "k" || key.upArrow) { setScrollOffset(null); setDiffCursor((c: number) => Math.max(c - count, 0)); clearCount(); return; }
528
+
529
+ if (key.ctrl && input === "d") { setScrollOffset(null); setDiffCursor((c: number) => Math.min(c + halfPage * count, lastRow)); clearCount(); return; }
530
+ if (key.ctrl && input === "u") { setScrollOffset(null); setDiffCursor((c: number) => Math.max(c - halfPage * count, 0)); clearCount(); return; }
531
+ if (key.ctrl && input === "f") { setScrollOffset(null); setDiffCursor((c: number) => Math.min(c + visibleH * count, lastRow)); clearCount(); return; }
532
+ if (key.ctrl && input === "b") { setScrollOffset(null); setDiffCursor((c: number) => Math.max(c - visibleH * count, 0)); clearCount(); return; }
533
+
534
+ if (input === "g") { setPendingG(true); return; }
535
+ if (input === "G") {
536
+ setScrollOffset(null);
537
+ setDiffCursor(count > 1 ? Math.min(count - 1, lastRow) : lastRow);
538
+ clearCount();
539
+ return;
540
+ }
541
+
542
+ if (input === "H") { setDiffCursor((_: number) => { const win = scrollOffset !== null ? scrolledWindow(viewRows, scrollOffset, visibleH) : visibleWindow(viewRows, visualCursor, visibleH); return Math.max(0, win.start); }); clearCount(); return; }
543
+ if (input === "M") { setDiffCursor((_: number) => { const win = scrollOffset !== null ? scrolledWindow(viewRows, scrollOffset, visibleH) : visibleWindow(viewRows, visualCursor, visibleH); return Math.min(lastRow, win.start + Math.floor(visibleH / 2)); }); clearCount(); return; }
544
+ if (input === "L") { setDiffCursor((_: number) => { const win = scrollOffset !== null ? scrolledWindow(viewRows, scrollOffset, visibleH) : visibleWindow(viewRows, visualCursor, visibleH); return Math.min(lastRow, win.start + visibleH - 1); }); clearCount(); return; }
545
+
546
+ if (input === "z") { setPendingZ(true); return; }
547
+
548
+ // w/b: next/prev changed line (add/delete)
549
+ if (input === "w") {
550
+ for (let n = 0, i = diffCursor + 1; i <= lastRow; i++) {
551
+ const k = diffRows[i]?.kind;
552
+ if (k === "add" || k === "delete") { n++; if (n >= count) { setDiffCursor(i); break; } }
553
+ }
554
+ clearCount();
555
+ return;
556
+ }
557
+ if (input === "b") {
558
+ for (let n = 0, i = diffCursor - 1; i >= 0; i--) {
559
+ const k = diffRows[i]?.kind;
560
+ if (k === "add" || k === "delete") { n++; if (n >= count) { setDiffCursor(i); break; } }
561
+ }
562
+ clearCount();
563
+ return;
564
+ }
565
+
566
+ // ── Block navigation (vim-like { and }) ──
567
+ // Block boundary = hunk header, meta, or blank line.
568
+ // Kitty keyboard protocol may send { as [ with shift, and } as ] with shift.
569
+ if (input === "}" || (input === "]" && key.shift)) {
570
+ let cur = diffCursor;
571
+ for (let n = 0; n < count; n++) {
572
+ while (cur + 1 <= lastRow && isBlockBoundary(diffRows, cur)) cur++;
573
+ while (cur + 1 <= lastRow && !isBlockBoundary(diffRows, cur + 1)) cur++;
574
+ if (cur + 1 <= lastRow) cur++;
575
+ }
576
+ setDiffCursor(cur);
577
+ clearCount();
578
+ return;
579
+ }
580
+ if (input === "{" || (input === "[" && key.shift)) {
581
+ let cur = diffCursor;
582
+ for (let n = 0; n < count; n++) {
583
+ while (cur - 1 >= 0 && isBlockBoundary(diffRows, cur)) cur--;
584
+ while (cur - 1 >= 0 && !isBlockBoundary(diffRows, cur - 1)) cur--;
585
+ if (cur - 1 >= 0) cur--;
586
+ }
587
+ setDiffCursor(cur);
588
+ clearCount();
589
+ return;
590
+ }
591
+
592
+ if (input === "]" && !key.shift) {
593
+ const threadLines = [...markers.keys()].sort((a, b) => a - b);
594
+ const curLine = selectedLine ?? 0;
595
+ const nextLine = threadLines.find((l) => l > curLine);
596
+ if (nextLine) {
597
+ const idx = diffRows.findIndex((r) => r.newLine === nextLine || r.oldLine === nextLine);
598
+ if (idx >= 0) setDiffCursor(idx);
599
+ } else {
600
+ const nextThread = allThreads.find((th) => th.filePath > currentPath);
601
+ if (nextThread) { jumpToThread(nextThread); }
602
+ else if (allThreads.length > 0) { jumpToThread(allThreads[0]!); }
603
+ }
604
+ clearCount();
605
+ return;
606
+ }
607
+ if (input === "[" && !key.shift) {
608
+ const threadLines = [...markers.keys()].sort((a, b) => b - a);
609
+ const curLine = selectedLine ?? Infinity;
610
+ const prevLine = threadLines.find((l) => l < curLine);
611
+ if (prevLine) {
612
+ const idx = diffRows.findIndex((r) => r.newLine === prevLine || r.oldLine === prevLine);
613
+ if (idx >= 0) setDiffCursor(idx);
614
+ } else {
615
+ const prevThread = [...allThreads].reverse().find((th) => th.filePath < currentPath);
616
+ if (prevThread) {
617
+ const lastInFile = [...allThreads].reverse().find((th) => th.filePath === prevThread.filePath);
618
+ jumpToThread(lastInFile ?? prevThread);
619
+ } else if (allThreads.length > 0) {
620
+ const lastThread = allThreads[allThreads.length - 1]!;
621
+ const lastInFile = [...allThreads].reverse().find((th) => th.filePath === lastThread.filePath);
622
+ jumpToThread(lastInFile ?? lastThread);
623
+ }
624
+ }
625
+ clearCount();
626
+ return;
627
+ }
628
+
629
+ if (input === ":") { clearCount(); setInputMode("goto"); setDraft(""); setStatus("Go to line"); return; }
630
+ if (input === "f") {
631
+ clearCount();
632
+ if (showFullFile && !currentDiff) { setStatus("No diff available"); return; }
633
+ setShowFullFile((c: boolean) => !c);
634
+ return;
635
+ }
636
+ if (input === "e" && !showFullFile) {
637
+ const next = expandedContext === 3 ? 10 : expandedContext === 10 ? 999 : 3;
638
+ setExpandedContext(next);
639
+ if (selectedChange) {
640
+ setCurrentDiff(diff(repo, selectedChange, next));
641
+ }
642
+ setStatus(next >= 999 ? "Full context" : `Context: ${next} lines`);
643
+ return;
644
+ }
645
+ if (input === "v") {
646
+ clearCount();
647
+ setInputMode("visual");
648
+ setVisualAnchor(diffCursor);
649
+ setStatus("Visual mode — j/k extend, c comment, Esc cancel");
650
+ return;
651
+ }
652
+ if (input === "d" && threadAtLine) {
653
+ clearCount();
654
+ if (!store) { setStatus("Demo mode - delete is disabled"); return; }
655
+ setDeleteTarget(threadAtLine);
656
+ setInputMode("confirmDelete");
657
+ setStatus(`Delete thread ${threadAtLine.id.slice(0, 8)}? (y/n)`);
658
+ return;
659
+ }
660
+ if (input === "/") { clearCount(); setInputMode("search"); setDraft(searchQuery); setStatus("Search diff"); return; }
661
+ if (input === "n" && searchMatches.length > 0) {
662
+ setDiffCursor(searchMatches.find((index) => index > diffCursor) ?? searchMatches[0] ?? 0);
663
+ clearCount();
664
+ return;
665
+ }
666
+ if (input === "N" && searchMatches.length > 0) {
667
+ setDiffCursor([...searchMatches].reverse().find((index) => index < diffCursor) ?? searchMatches[searchMatches.length - 1] ?? 0);
668
+ clearCount();
669
+ return;
670
+ }
671
+
672
+ if (input === "r" && threadAtLine) {
673
+ clearCount();
674
+ if (!store) { setStatus("Demo mode - thread status is read-only"); return; }
675
+ store.updateThreadStatus(threadAtLine.id, threadAtLine.status === ThreadResolved ? ThreadOpen : ThreadResolved);
676
+ if (session) { loadThreads(session, currentPath); }
677
+ setStatus(`${threadAtLine.status === ThreadResolved ? "Reopened" : "Resolved"} thread ${threadAtLine.id.slice(0, 8)}`);
678
+ return;
679
+ }
680
+
681
+ if (input === "c") {
682
+ clearCount();
683
+ if (isDemo) { setStatus("Demo mode - comments are disabled"); return; }
684
+ setInputMode(threadAtLine ? "reply" : "comment");
685
+ setDraft("");
686
+ setStatus(threadAtLine ? "Reply to thread" : "Add comment on current line");
687
+ return;
688
+ }
689
+
690
+ clearCount();
691
+ };
692
+
693
+ const commitPrompt = (): void => {
694
+ const value = draft.trim();
695
+ if (inputMode === "goto") {
696
+ const lineNum = parseInt(draft, 10);
697
+ setInputMode("normal");
698
+ setDraft("");
699
+ if (!isNaN(lineNum) && lineNum > 0) {
700
+ const rowIdx = diffRows.findIndex((r) => r.newLine === lineNum || r.oldLine === lineNum);
701
+ if (rowIdx >= 0) { setDiffCursor(rowIdx); setStatus(`Line ${lineNum}`); }
702
+ else { setStatus(`Line ${lineNum} not found`); }
703
+ }
704
+ return;
705
+ }
706
+
707
+ if (inputMode === "filter") {
708
+ setFilterQuery(draft);
709
+ setFileCursor(0);
710
+ setInputMode("normal");
711
+ setFocus("diff");
712
+ setStatus(`Filtered ${listedPaths.length} file(s)`);
713
+ return;
714
+ }
715
+
716
+ if (inputMode === "search") {
717
+ setSearchQuery(draft);
718
+ setInputMode("normal");
719
+ const first = diffRows.findIndex((row) => row.text.toLowerCase().includes(value.toLowerCase()));
720
+ if (first >= 0) { setDiffCursor(first); }
721
+ setStatus(value ? `Found ${searchMatches.length} match(es)` : "Cleared search");
722
+ return;
723
+ }
724
+
725
+ if (!value || !session || !store) { setInputMode("normal"); return; }
726
+
727
+ if (inputMode === "comment") {
728
+ const startIdx = visualAnchor !== null ? Math.min(visualAnchor, diffCursor) : diffCursor;
729
+ const endIdx = visualAnchor !== null ? Math.max(visualAnchor, diffCursor) : diffCursor;
730
+ const startLine = diffRows[startIdx]?.newLine ?? diffRows[startIdx]?.oldLine ?? null;
731
+ const endLine = diffRows[endIdx]?.newLine ?? diffRows[endIdx]?.oldLine ?? null;
732
+ if (!startLine) { setStatus("Current row is not commentable"); setInputMode("normal"); setVisualAnchor(null); return; }
733
+
734
+ const { anchor, before, after } = extractContext(currentContent, startLine);
735
+ const thread = store.createThread({
736
+ sessionId: session.id,
737
+ filePath: currentPath,
738
+ side: "new",
739
+ originalLine: startLine,
740
+ lineEnd: endLine && endLine > startLine ? endLine : 0,
741
+ currentLine: startLine,
742
+ anchorContent: anchor || extractRangeAnchor(currentContent, startLine, endLine ?? startLine),
743
+ contextBefore: before,
744
+ contextAfter: after,
745
+ });
746
+ store.addComment({ threadId: thread.id, author: AuthorHuman, body: value });
747
+ loadThreads(session, currentPath);
748
+ const label = endLine && endLine > startLine ? `${currentPath}:${startLine}-${endLine}` : `${currentPath}:${startLine}`;
749
+ setStatus(`Commented on ${label}`);
750
+ setVisualAnchor(null);
751
+ }
752
+
753
+ if (inputMode === "reply") {
754
+ const thread = threadAtLine;
755
+ if (!thread) { setStatus("No active thread selected"); }
756
+ else {
757
+ store.addComment({ threadId: thread.id, author: AuthorHuman, body: value });
758
+ loadThreads(session, currentPath);
759
+ setStatus(`Replied to thread ${thread.id.slice(0, 8)}`);
760
+ }
761
+ }
762
+
763
+ setDraft("");
764
+ setInputMode("normal");
765
+ };
766
+
767
+ const handlePromptInput = (input: string, key: { escape?: boolean; return?: boolean; backspace?: boolean; delete?: boolean }): void => {
768
+ if (key.escape) { setInputMode("normal"); setDraft(""); setStatus("Cancelled"); return; }
769
+ if (key.backspace || key.delete || input === "\b" || input === "\x7f") { setDraft((c: string) => c.slice(0, -1)); return; }
770
+ if (key.return) { commitPrompt(); return; }
771
+ if (input) {
772
+ if (inputMode === "goto" && !/^[0-9]$/.test(input)) return;
773
+ setDraft((c: string) => c + input);
774
+ }
775
+ };
776
+
777
+ // ── Effects ────────────────────────────────────────────────────
778
+
779
+ useEffect(() => {
780
+ if (demoState) {
781
+ const selected = demoState.files[demoState.fileCursor] ?? demoState.files[0];
782
+ setSession(demoState.session);
783
+ setFileChanges(demoState.files.map((file) => file.change));
784
+ setRepoFiles(demoState.repoFiles);
785
+ setFocus(demoState.focus);
786
+ setStatus(demoState.status);
787
+ setShowFullFile(demoState.showFullFile);
788
+ setFileCursor(demoState.fileCursor);
789
+ setDiffCursor(demoState.diffCursor);
790
+ setThreadCounts(demoState.threadCounts);
791
+ if (selected) {
792
+ setCurrentPath(selected.change.path);
793
+ setCurrentDiff(selected.diff);
794
+ setCurrentContent(selected.content);
795
+ setThreads(selected.threads);
796
+ setCommentsByThread(selected.commentsByThread);
797
+ }
798
+ return;
799
+ }
800
+
801
+ if (!store) return;
802
+
803
+ const active = store.activeSession() ?? store.createSession(repo.root);
804
+ setSession(active);
805
+ refreshAll(active);
806
+ const timer = setInterval(async () => {
807
+ await new Promise((r) => setTimeout(r, 0));
808
+ refreshAll(active, true);
809
+ }, 5000);
810
+ return () => clearInterval(timer);
811
+ }, [demoState, repo.root, store]);
812
+
813
+ useEffect(() => {
814
+ if (!snapshot) return;
815
+ const timer = setTimeout(() => exit(), 150);
816
+ return () => clearTimeout(timer);
817
+ }, [exit, snapshot]);
818
+
819
+ useEffect(() => {
820
+ if (pendingLine === null) return;
821
+ const idx = diffRows.findIndex((r) => r.newLine === pendingLine || r.oldLine === pendingLine);
822
+ if (idx >= 0) {
823
+ setDiffCursor(idx);
824
+ }
825
+ setPendingLine(null);
826
+ }, [pendingLine, diffRows]);
827
+
828
+ useEffect(() => {
829
+ if (listedPaths.length === 0) {
830
+ setCurrentPath("");
831
+ setCurrentDiff("");
832
+ setCurrentContent("");
833
+ setThreads([]);
834
+ return;
835
+ }
836
+
837
+ const nextPath = listedPaths[Math.min(fileCursor, listedPaths.length - 1)] ?? listedPaths[0] ?? "";
838
+ if (nextPath && nextPath !== currentPath) {
839
+ void openPath(nextPath);
840
+ }
841
+ }, [currentPath, fileCursor, listedPaths, showFullFile, expandedContext]);
842
+
843
+ useInput((input, key) => {
844
+ if (key.ctrl && input === "c") { exit(); return; }
845
+
846
+ if (inputMode === "confirmDelete") {
847
+ if (input === "y" && deleteTarget && store && session) {
848
+ store.deleteThread(deleteTarget.id);
849
+ loadThreads(session, currentPath);
850
+ setStatus(`Deleted thread ${deleteTarget.id.slice(0, 8)}`);
851
+ } else {
852
+ setStatus("Cancelled");
853
+ }
854
+ setInputMode("normal");
855
+ setDeleteTarget(null);
856
+ return;
857
+ }
858
+
859
+ if (inputMode === "visual") {
860
+ const lastRow = Math.max(0, diffRows.length - 1);
861
+ if (/^[0-9]$/.test(input) && !key.ctrl) { setCountPrefix((c) => c + input); return; }
862
+ const vCount = countPrefix ? parseInt(countPrefix, 10) : 1;
863
+ if (input === "j" || key.downArrow) { setDiffCursor((c: number) => Math.min(c + vCount, lastRow)); setCountPrefix(""); return; }
864
+ if (input === "k" || key.upArrow) { setDiffCursor((c: number) => Math.max(c - vCount, 0)); setCountPrefix(""); return; }
865
+ if (input === "c") {
866
+ if (isDemo) { setStatus("Demo mode - comments are disabled"); setInputMode("normal"); setVisualAnchor(null); return; }
867
+ setInputMode("comment");
868
+ setDraft("");
869
+ const startLine = Math.min(visualAnchor ?? diffCursor, diffCursor);
870
+ const endLine = Math.max(visualAnchor ?? diffCursor, diffCursor);
871
+ const s = diffRows[startLine]?.newLine ?? diffRows[startLine]?.oldLine ?? 0;
872
+ const e = diffRows[endLine]?.newLine ?? diffRows[endLine]?.oldLine ?? 0;
873
+ setStatus(`Comment on lines ${s}-${e}`);
874
+ return;
875
+ }
876
+ if (key.escape) { setInputMode("normal"); setVisualAnchor(null); setStatus(""); return; }
877
+ return;
878
+ }
879
+
880
+ if (inputMode === "filter") {
881
+ if (key.upArrow) { setFileCursor((c: number) => Math.max(c - 1, 0)); return; }
882
+ if (key.downArrow) { setFileCursor((c: number) => Math.min(c + 1, Math.max(0, listedPaths.length - 1))); return; }
883
+ handlePromptInput(input, key);
884
+ return;
885
+ }
886
+
887
+ if (inputMode !== "normal") { handlePromptInput(input, key); return; }
888
+
889
+ if (input === "\t") { setFocus((c: FocusPane) => c === "files" ? "diff" : "files"); return; }
890
+ if (focus === "files") { handleFileInput(input, key); return; }
891
+ handleDiffInput(input, key);
892
+ });
893
+
894
+ // ── Layout ─────────────────────────────────────────────────────
895
+
896
+ const th = Math.max(stdout.rows || 30, 18);
897
+ const innerW = tw - 2;
898
+ const innerH = th - 2;
899
+ const sepW = 3;
900
+ const contentH = Math.max(innerH - 4, 4);
901
+ const sepChar = pc.dim("│");
902
+
903
+ const titleLeft = ` ${fg(t.accent, pc.bold(repo.name))}`;
904
+ const modeLabel = showFullFile ? "full" : expandedContext > 3 ? `diff +${expandedContext}` : "diff";
905
+ const countLabel = fileListMode === "changes"
906
+ ? `${fileChanges.length} change${fileChanges.length !== 1 ? "s" : ""}`
907
+ : `${repoFiles.length} file${repoFiles.length !== 1 ? "s" : ""}`;
908
+ const titleRight = `${pc.dim(`${modeLabel} · ${countLabel}`)} `;
909
+
910
+ const fHeader = fileListMode === "changes" ? "changed files" : "all files";
911
+ const fLabel = focus === "files" ? fg(t.accent, pc.bold(fHeader)) : pc.dim(fHeader);
912
+ const pLabel = currentPath
913
+ ? (focus === "diff" ? fg(t.accent, pc.bold(currentPath)) : pc.dim(currentPath))
914
+ : pc.dim("—");
915
+
916
+ const footerInputModes: InputMode[] = ["comment", "reply", "visual", "confirmDelete"];
917
+ const showInputInFooter = inputMode !== "normal" && !footerInputModes.includes(inputMode);
918
+ const vimPending = countPrefix + (pendingG ? "g" : "") + (pendingZ ? "z" : "");
919
+ const statusLine = showInputInFooter
920
+ ? ` ${fg(t.accent, inputMode)}${pc.dim(":")} ${draft}`
921
+ : vimPending
922
+ ? ` ${fg(t.accent, vimPending)}`
923
+ : ` ${pc.dim(status)}`;
924
+
925
+ let keybinds: string;
926
+ if (inputMode === "visual") {
927
+ keybinds = "j/k extend c comment Esc cancel";
928
+ } else if (inputMode === "filter") {
929
+ keybinds = "type to filter ↑/↓ navigate ⏎ select Esc clear";
930
+ } else if (inputMode === "confirmDelete") {
931
+ keybinds = "y confirm any other key cancel";
932
+ } else if (inputMode === "search" || inputMode === "goto") {
933
+ keybinds = "type to " + inputMode + " ⏎ confirm Esc cancel";
934
+ } else if (focus === "files") {
935
+ keybinds = `j/k ↕ l/⏎ open f ${fileListMode === "changes" ? "all" : "changes"} / filter s stage r refresh q quit`;
936
+ } else {
937
+ const threadHints = threadAtLine ? " r resolve d delete" : "";
938
+ keybinds = `j/k ↕ gg/G top/end w/b change {/} hunk [/] thread zz center / search c comment v visual${threadHints} q back`;
939
+ }
940
+
941
+ const separators: string[] = [];
942
+ for (let i = 0; i < contentH; i++) separators.push(` ${sepChar} `);
943
+
944
+ return (
945
+ <Box width={tw} height={th} borderStyle="round" borderColor={t.border} flexDirection="column">
946
+ <Text wrap="truncate-end">{padBetween(titleLeft, titleRight, innerW)}</Text>
947
+ <Text wrap="truncate-end">{padAnsi(` ${fLabel}`, sidebarW)} {sepChar} {padAnsi(` ${pLabel}`, diffPaneW)}</Text>
948
+ <Box flexDirection="row">
949
+ <FileSidebar
950
+ listedPaths={listedPaths}
951
+ fileCursor={fileCursor}
952
+ focused={focus === "files"}
953
+ fileChangeMap={fileChangeMap}
954
+ threadCounts={threadCounts}
955
+ theme={t}
956
+ width={sidebarW}
957
+ height={contentH}
958
+ />
959
+ <Box width={sepW} flexDirection="column">
960
+ {separators.map((s, i) => <Text key={i}>{s}</Text>)}
961
+ </Box>
962
+ <DiffPane
963
+ viewRows={viewRows}
964
+ visualCursor={visualCursor}
965
+ diffCursor={diffCursor}
966
+ focused={focus === "diff"}
967
+ markers={markers}
968
+ inputMode={inputMode}
969
+ draft={draft}
970
+ theme={t}
971
+ width={diffPaneW}
972
+ height={contentH}
973
+ visualAnchor={inputMode === "visual" ? visualAnchor : null}
974
+ scrollOffset={scrollOffset}
975
+ />
976
+ </Box>
977
+ <Text wrap="truncate-end">{padAnsi(statusLine, innerW)}</Text>
978
+ <Text wrap="truncate-end">{padAnsi(` ${pc.dim(keybinds)}`, innerW)}</Text>
979
+ </Box>
980
+ );
981
+ }
982
+
983
+ // ── Module-level utilities ───────────────────────────────────────
984
+
985
+ function isBlockBoundary(rows: DiffRow[], idx: number): boolean {
986
+ if (idx < 0 || idx >= rows.length) return true;
987
+ const row = rows[idx]!;
988
+ if (row.kind === "hunk" || row.kind === "header" || row.kind === "meta") return true;
989
+ return row.text.trim() === "";
990
+ }
991
+
992
+ function scrolledWindow<T>(items: T[], start: number, height: number): { start: number; end: number; items: T[] } {
993
+ const s = Math.max(0, Math.min(start, items.length - height));
994
+ const e = Math.min(items.length, s + height);
995
+ return { start: s, end: e, items: items.slice(s, e) };
996
+ }
997
+
998
+ function colorSymbol(sym: string, kind: FileChange["kind"] | undefined, t: Theme): string {
999
+ switch (kind) {
1000
+ case "added": return fg(t.add, sym);
1001
+ case "deleted": return fg(t.del, sym);
1002
+ case "renamed":
1003
+ case "copied": return fg(t.warning, sym);
1004
+ case "untracked": return fg(t.hunk, sym);
1005
+ default: return pc.white(sym);
1006
+ }
1007
+ }
1008
+
1009
+ function visibleLength(text: string): number {
1010
+ let count = 0;
1011
+ for (let i = 0; i < text.length; i++) {
1012
+ if (text.charCodeAt(i) === 0x1b && text.charCodeAt(i + 1) === 0x5b) {
1013
+ i += 2;
1014
+ while (i < text.length && text.charCodeAt(i) !== 0x6d) i++;
1015
+ continue;
1016
+ }
1017
+ count++;
1018
+ }
1019
+ return count;
1020
+ }
1021
+
1022
+ function padAnsi(text: string, width: number): string {
1023
+ let visible = 0;
1024
+ let truncIdx = -1;
1025
+ const limit = width - 1;
1026
+
1027
+ for (let i = 0; i < text.length; i++) {
1028
+ if (text.charCodeAt(i) === 0x1b && text.charCodeAt(i + 1) === 0x5b) {
1029
+ i += 2;
1030
+ while (i < text.length && text.charCodeAt(i) !== 0x6d) i++;
1031
+ continue;
1032
+ }
1033
+ visible++;
1034
+ if (truncIdx === -1 && visible > limit && visible > width) {
1035
+ truncIdx = i;
1036
+ }
1037
+ }
1038
+
1039
+ if (visible <= width) {
1040
+ return visible === width ? text : `${text}${" ".repeat(width - visible)}`;
1041
+ }
1042
+
1043
+ let result = "";
1044
+ let seen = 0;
1045
+ for (let i = 0; i < text.length; i++) {
1046
+ if (text.charCodeAt(i) === 0x1b && text.charCodeAt(i + 1) === 0x5b) {
1047
+ const start = i;
1048
+ i += 2;
1049
+ while (i < text.length && text.charCodeAt(i) !== 0x6d) i++;
1050
+ result += text.slice(start, i + 1);
1051
+ continue;
1052
+ }
1053
+ if (seen >= limit) {
1054
+ return `${result}…\u001b[0m`;
1055
+ }
1056
+ result += text[i];
1057
+ seen++;
1058
+ }
1059
+
1060
+ return result;
1061
+ }
1062
+
1063
+ function padBetween(left: string, right: string, width: number): string {
1064
+ const leftLen = visibleLength(left);
1065
+ const rightLen = visibleLength(right);
1066
+ const gap = Math.max(1, width - leftLen - rightLen);
1067
+ return `${left}${" ".repeat(gap)}${right}`;
1068
+ }
1069
+
1070
+ function wrapText(text: string, firstLineW: number, contLineW: number): string[] {
1071
+ const result: string[] = [];
1072
+ for (const paragraph of text.split("\n")) {
1073
+ if (paragraph.length === 0) { result.push(""); continue; }
1074
+ const maxW = result.length === 0 ? firstLineW : contLineW;
1075
+ if (paragraph.length <= maxW) { result.push(paragraph); continue; }
1076
+ let pos = 0;
1077
+ while (pos < paragraph.length) {
1078
+ const w = result.length === 0 ? firstLineW : contLineW;
1079
+ if (pos + w >= paragraph.length) { result.push(paragraph.slice(pos)); break; }
1080
+ let breakAt = paragraph.lastIndexOf(" ", pos + w);
1081
+ if (breakAt <= pos) breakAt = pos + w;
1082
+ result.push(paragraph.slice(pos, breakAt));
1083
+ pos = breakAt;
1084
+ if (paragraph[pos] === " ") pos++;
1085
+ }
1086
+ }
1087
+ if (result.length === 0) result.push("");
1088
+ return result;
1089
+ }