@ohzw/worktree-command-tui 0.1.0 → 0.1.1

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/app.d.ts CHANGED
@@ -10,8 +10,9 @@ export interface AppWindowSize {
10
10
  columns: number;
11
11
  rows: number;
12
12
  }
13
+ export declare function getMouseWheelDelta(input: string): number;
13
14
  export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
14
- export declare function shouldUseCompactLayout(columns: number, rows: number, worktreeCount?: number): boolean;
15
+ export declare function shouldUseCompactLayout(_columns: number, _rows: number, _worktreeCount?: number): boolean;
15
16
  export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
16
17
  export declare function shouldStackPanes(columns: number, rows: number, worktreeCount?: number): boolean;
17
18
  export declare function App({ initialModel, actions, windowSizeOverride, }: {
package/dist/app.js CHANGED
@@ -1,10 +1,36 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Spinner } from '@inkjs/ui';
2
3
  import { useEffect, useMemo, useRef, useState } from 'react';
3
- import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
4
+ import { Box, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from 'ink';
4
5
  import { ActionPanel } from './components/ActionPanel.js';
5
6
  import { ContextBar } from './components/ContextBar.js';
6
7
  import { Header } from './components/Header.js';
8
+ import { FloatingLogWindow } from './components/FloatingLogWindow.js';
9
+ import { LogPanel, buildLogLines } from './components/LogPanel.js';
7
10
  import { WorktreeList } from './components/WorktreeList.js';
11
+ const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
12
+ const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
13
+ function parseMouseWheelEvents(input) {
14
+ const events = [];
15
+ const sgrMousePattern = /\u001B\[<(\d+);(\d+);(\d+)[mM]/g;
16
+ for (const match of input.matchAll(sgrMousePattern)) {
17
+ const button = Number(match[1]);
18
+ if (button !== 64 && button !== 65) {
19
+ continue;
20
+ }
21
+ const x = Number(match[2]);
22
+ const y = Number(match[3]);
23
+ events.push({
24
+ delta: button === 65 ? 1 : -1,
25
+ x: Number.isFinite(x) ? x : undefined,
26
+ y: Number.isFinite(y) ? y : undefined,
27
+ });
28
+ }
29
+ return events;
30
+ }
31
+ export function getMouseWheelDelta(input) {
32
+ return parseMouseWheelEvents(input).reduce((sum, event) => sum + event.delta, 0);
33
+ }
8
34
  function getNextSelectedPath(rows, currentPath) {
9
35
  if (rows.length === 0) {
10
36
  return null;
@@ -24,27 +50,102 @@ export function getShellDimensions(columns, rows) {
24
50
  const actionWidth = Math.max(1, bodyWidth - listWidth - 1);
25
51
  return { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth };
26
52
  }
27
- export function shouldUseCompactLayout(columns, rows, worktreeCount = 0) {
28
- const contentAwareRowFloor = Math.max(20, worktreeCount + 12);
29
- return columns < 72 || rows <= contentAwareRowFloor || (columns < 96 && rows < 24);
53
+ export function shouldUseCompactLayout(_columns, _rows, _worktreeCount = 0) {
54
+ return false;
30
55
  }
31
56
  export function shouldUseMinimalLayout(columns, rows) {
32
- return columns < 20 || rows < 6;
57
+ return columns < 20 || rows < 10;
33
58
  }
34
59
  export function shouldStackPanes(columns, rows, worktreeCount = 0) {
35
- const minimumRows = Math.max(26, worktreeCount + 18);
60
+ // Stacked panes are taller than split panes. Only use them when the full frame can fit the viewport.
61
+ const minimumRows = Math.max(36, worktreeCount + 34);
36
62
  return columns < 96 && rows >= minimumRows;
37
63
  }
64
+ function getLogPaneHeight(_rootHeight) {
65
+ // Outer pane height. With border + title, 9 rows gives ~6 visible log lines.
66
+ return 9;
67
+ }
38
68
  export function App({ initialModel, actions, windowSizeOverride, }) {
39
69
  const { exit } = useApp();
70
+ const { stdin } = useStdin();
71
+ const { stdout } = useStdout();
40
72
  const liveWindowSize = useWindowSize();
41
73
  const { columns, rows } = windowSizeOverride ?? liveWindowSize;
42
74
  const [model, setModel] = useState(initialModel);
43
75
  const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
44
- const inFlightRef = useRef(false);
76
+ const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
77
+ const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
78
+ const [logScrollOffset, setLogScrollOffset] = useState(0);
79
+ const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);
80
+ const [completedAlert, setCompletedAlert] = useState(null);
81
+ const userActionInFlightRef = useRef(false);
82
+ const backgroundRefreshInFlightRef = useRef(false);
83
+ const actionGenerationRef = useRef(0);
84
+ const logRefreshInFlightRef = useRef(false);
85
+ const previousStatusRef = useRef(initialModel.status.kind);
86
+ const alertTimeoutRef = useRef(null);
45
87
  useEffect(() => {
46
88
  setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
47
89
  }, [model.rows]);
90
+ useEffect(() => {
91
+ setSelectionScrollOffset(0);
92
+ }, [selectedPath]);
93
+ useEffect(() => {
94
+ const becameRunning = previousStatusRef.current === 'starting' && model.status.kind === 'running';
95
+ if (becameRunning) {
96
+ setCompletedAlert(model.activeBranch ? `Switched to ${model.activeBranch}` : 'Worktree switch complete.');
97
+ if (alertTimeoutRef.current !== null) {
98
+ clearTimeout(alertTimeoutRef.current);
99
+ }
100
+ alertTimeoutRef.current = setTimeout(() => {
101
+ setCompletedAlert(null);
102
+ }, 2500);
103
+ }
104
+ previousStatusRef.current = model.status.kind;
105
+ }, [model.status.kind, model.activeBranch]);
106
+ useEffect(() => {
107
+ return () => {
108
+ if (alertTimeoutRef.current !== null) {
109
+ clearTimeout(alertTimeoutRef.current);
110
+ }
111
+ };
112
+ }, []);
113
+ useEffect(() => {
114
+ return () => {
115
+ if (alertTimeoutRef.current !== null) {
116
+ clearTimeout(alertTimeoutRef.current);
117
+ }
118
+ };
119
+ }, []);
120
+ useEffect(() => {
121
+ if (model.status.kind !== 'running') {
122
+ return;
123
+ }
124
+ const fullRefreshInterval = setInterval(() => {
125
+ if (userActionInFlightRef.current || backgroundRefreshInFlightRef.current) {
126
+ return;
127
+ }
128
+ void apply(() => actions.refresh(), { blocksInput: false });
129
+ }, 1500);
130
+ const logRefreshInterval = setInterval(() => {
131
+ if (userActionInFlightRef.current || backgroundRefreshInFlightRef.current || logRefreshInFlightRef.current) {
132
+ return;
133
+ }
134
+ logRefreshInFlightRef.current = true;
135
+ void actions.refreshLogs()
136
+ .then(logs => {
137
+ setModel(current => ({ ...current, logs }));
138
+ })
139
+ .catch(() => { })
140
+ .finally(() => {
141
+ logRefreshInFlightRef.current = false;
142
+ });
143
+ }, 400);
144
+ return () => {
145
+ clearInterval(fullRefreshInterval);
146
+ clearInterval(logRefreshInterval);
147
+ };
148
+ }, [actions, model.status.kind]);
48
149
  const selectedIndex = useMemo(() => {
49
150
  if (selectedPath === null) {
50
151
  return 0;
@@ -57,33 +158,103 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
57
158
  const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
58
159
  const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
59
160
  const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
60
- const compactDetailPane = !stackedLayout && rootHeight <= 26;
161
+ const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
162
+ const showLogPanel = !stackedLayout && rootHeight >= 34;
163
+ const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
164
+ const paneHeight = stackedLayout
165
+ ? undefined
166
+ : Math.max(3, rootHeight - 11 - logPaneHeight);
167
+ const selectionScrollPageSize = Math.max(1, Math.floor((paneHeight ?? rootHeight) / 2));
168
+ const logLineCount = useMemo(() => buildLogLines(model.logs).length, [model.logs]);
169
+ const logViewportHeight = isLogOverlayOpen
170
+ ? Math.max(1, rootHeight - 3)
171
+ : showLogPanel ? Math.max(1, logPaneHeight - 3) : 0;
172
+ const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
173
+ const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
61
174
  function moveSelection(nextIndex) {
62
175
  if (model.rows.length === 0) {
63
176
  return;
64
177
  }
65
178
  setSelectedPath(model.rows[Math.min(Math.max(nextIndex, 0), model.rows.length - 1)].path);
66
179
  }
67
- async function apply(action) {
68
- inFlightRef.current = true;
180
+ function clearTransientAlert() {
181
+ if (alertTimeoutRef.current !== null) {
182
+ clearTimeout(alertTimeoutRef.current);
183
+ alertTimeoutRef.current = null;
184
+ }
185
+ setCompletedAlert(null);
186
+ }
187
+ function invalidateBackgroundRefreshes() {
188
+ actionGenerationRef.current += 1;
189
+ }
190
+ async function apply(action, options = {}) {
191
+ const blocksInput = options.blocksInput ?? true;
192
+ const generation = blocksInput ? actionGenerationRef.current + 1 : actionGenerationRef.current;
193
+ if (blocksInput) {
194
+ actionGenerationRef.current = generation;
195
+ userActionInFlightRef.current = true;
196
+ }
197
+ else {
198
+ backgroundRefreshInFlightRef.current = true;
199
+ }
69
200
  try {
70
201
  const next = await action();
71
- setModel(next);
202
+ if (blocksInput || (generation === actionGenerationRef.current && !userActionInFlightRef.current)) {
203
+ setModel(next);
204
+ }
72
205
  }
73
206
  catch (error) {
74
- setModel(current => ({
75
- ...current,
76
- status: {
77
- kind: 'error',
78
- message: error instanceof Error ? error.message : String(error),
79
- },
80
- }));
207
+ if (blocksInput || (generation === actionGenerationRef.current && !userActionInFlightRef.current)) {
208
+ setModel(current => ({
209
+ ...current,
210
+ status: {
211
+ kind: 'error',
212
+ message: error instanceof Error ? error.message : String(error),
213
+ },
214
+ }));
215
+ }
81
216
  }
82
217
  finally {
83
- inFlightRef.current = false;
218
+ if (blocksInput) {
219
+ userActionInFlightRef.current = false;
220
+ }
221
+ else {
222
+ backgroundRefreshInFlightRef.current = false;
223
+ }
84
224
  }
85
225
  }
86
226
  useInput((input, key) => {
227
+ if (isLogOverlayOpen) {
228
+ if (key.escape || input === 'q' || input === 'L') {
229
+ setIsLogOverlayOpen(false);
230
+ return;
231
+ }
232
+ if (key.upArrow || input === 'k') {
233
+ setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + 1));
234
+ return;
235
+ }
236
+ if (key.downArrow || input === 'j') {
237
+ setLogScrollOffset(current => Math.max(0, current - 1));
238
+ return;
239
+ }
240
+ if (input === 'g') {
241
+ setLogScrollOffset(maxLogScrollOffset);
242
+ return;
243
+ }
244
+ if (input === 'G') {
245
+ setLogScrollOffset(0);
246
+ return;
247
+ }
248
+ if (input === '[' || key.pageUp) {
249
+ setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
250
+ return;
251
+ }
252
+ if (input === ']' || key.pageDown) {
253
+ setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
254
+ return;
255
+ }
256
+ return;
257
+ }
87
258
  if (key.escape || input === 'q') {
88
259
  exit();
89
260
  return;
@@ -104,36 +275,159 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
104
275
  moveSelection(model.rows.length - 1);
105
276
  return;
106
277
  }
107
- if (inFlightRef.current) {
278
+ if (input === 'L') {
279
+ setIsLogOverlayOpen(true);
280
+ return;
281
+ }
282
+ if (input === ']') {
283
+ setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
284
+ return;
285
+ }
286
+ if (input === '[') {
287
+ setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
288
+ return;
289
+ }
290
+ if (key.pageDown) {
291
+ setSelectionScrollOffset(current => current + selectionScrollPageSize);
292
+ return;
293
+ }
294
+ if (key.pageUp) {
295
+ setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
296
+ return;
297
+ }
298
+ if (userActionInFlightRef.current) {
108
299
  return;
109
300
  }
110
301
  if (key.return && selected) {
111
302
  if (selected.invalidReason) {
303
+ invalidateBackgroundRefreshes();
112
304
  setModel(current => ({ ...current, status: { kind: 'error', message: selected.invalidReason } }));
305
+ clearTransientAlert();
113
306
  return;
114
307
  }
115
308
  if (selected.path === model.activePath) {
309
+ invalidateBackgroundRefreshes();
116
310
  setModel(current => ({ ...current, status: { kind: 'idle', message: 'already active' } }));
311
+ clearTransientAlert();
117
312
  return;
118
313
  }
119
314
  setModel(current => ({ ...current, status: { kind: 'starting', message: `Starting ${selected.branch}...` } }));
315
+ clearTransientAlert();
120
316
  void apply(() => actions.start(selected.path));
121
317
  return;
122
318
  }
123
319
  if (input === 's') {
124
320
  setModel(current => ({ ...current, status: { kind: 'stopping', message: 'Stopping active session...' } }));
321
+ clearTransientAlert();
125
322
  void apply(() => actions.stop());
126
323
  return;
127
324
  }
128
325
  if (input === 'r') {
326
+ clearTransientAlert();
129
327
  void apply(() => actions.refresh());
130
328
  }
131
329
  });
330
+ const listPaneViewportHeight = paneHeight === undefined ? undefined : Math.max(1, paneHeight - 3);
331
+ const mouseWheelLineStep = 3;
332
+ const paneAreaLeft = 3;
333
+ const worktreePaneRight = !stackedLayout ? paneAreaLeft + listWidth - 1 : undefined;
334
+ const selectionPaneLeft = !stackedLayout && worktreePaneRight !== undefined ? worktreePaneRight + 2 : undefined;
335
+ const bodyPaneTop = !stackedLayout && paneHeight !== undefined ? 7 : undefined;
336
+ const bodyPaneBottom = !stackedLayout && bodyPaneTop !== undefined && paneHeight !== undefined ? bodyPaneTop + paneHeight - 1 : undefined;
337
+ const logPaneTop = showLogPanel ? rootHeight - 5 - logPaneHeight + 1 : undefined;
338
+ const logPaneBottom = showLogPanel ? rootHeight - 5 : undefined;
339
+ useEffect(() => {
340
+ const onData = (data) => {
341
+ const events = parseMouseWheelEvents(typeof data === 'string' ? data : data.toString('utf8'));
342
+ for (const event of events) {
343
+ if (isLogOverlayOpen) {
344
+ setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
345
+ continue;
346
+ }
347
+ const isLogPaneEvent = !isLogOverlayOpen
348
+ && showLogPanel
349
+ && event.y !== undefined
350
+ && logPaneTop !== undefined
351
+ && logPaneBottom !== undefined
352
+ && event.y >= logPaneTop
353
+ && event.y <= logPaneBottom;
354
+ if (isLogPaneEvent) {
355
+ setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
356
+ continue;
357
+ }
358
+ const isBodyPaneEvent = bodyPaneTop !== undefined
359
+ && bodyPaneBottom !== undefined
360
+ && event.y !== undefined
361
+ && event.y >= bodyPaneTop
362
+ && event.y <= bodyPaneBottom;
363
+ const isSelectionPaneEvent = isBodyPaneEvent
364
+ && selectionPaneLeft !== undefined
365
+ && event.x !== undefined
366
+ && event.x >= selectionPaneLeft;
367
+ if (isSelectionPaneEvent) {
368
+ setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
369
+ continue;
370
+ }
371
+ const shouldScrollWorktrees = !stackedLayout
372
+ && (event.x === undefined || worktreePaneRight === undefined || event.x <= worktreePaneRight);
373
+ if (shouldScrollWorktrees) {
374
+ setWorktreeScrollOffset(current => {
375
+ if (listPaneViewportHeight === undefined) {
376
+ return 0;
377
+ }
378
+ const max = Math.max(0, model.rows.length - listPaneViewportHeight);
379
+ return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
380
+ });
381
+ }
382
+ else {
383
+ setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
384
+ }
385
+ }
386
+ };
387
+ if (stdout.isTTY) {
388
+ stdout.write(ENABLE_MOUSE_TRACKING);
389
+ }
390
+ stdin.on('data', onData);
391
+ return () => {
392
+ stdin.off('data', onData);
393
+ if (stdout.isTTY) {
394
+ stdout.write(DISABLE_MOUSE_TRACKING);
395
+ }
396
+ };
397
+ }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, isLogOverlayOpen]);
398
+ useEffect(() => {
399
+ if (listPaneViewportHeight === undefined) {
400
+ setWorktreeScrollOffset(0);
401
+ return;
402
+ }
403
+ setWorktreeScrollOffset(current => {
404
+ const max = Math.max(0, model.rows.length - listPaneViewportHeight);
405
+ if (selectedIndex < current) {
406
+ return Math.max(0, selectedIndex);
407
+ }
408
+ if (selectedIndex >= current + listPaneViewportHeight) {
409
+ return Math.max(0, Math.min(max, selectedIndex - listPaneViewportHeight + 1));
410
+ }
411
+ return Math.min(current, max);
412
+ });
413
+ }, [selectedIndex, listPaneViewportHeight, model.rows.length]);
414
+ useEffect(() => {
415
+ if (!showLogPanel && !isLogOverlayOpen) {
416
+ setLogScrollOffset(0);
417
+ return;
418
+ }
419
+ setLogScrollOffset(current => Math.min(current, maxLogScrollOffset));
420
+ }, [showLogPanel, isLogOverlayOpen, maxLogScrollOffset]);
421
+ if (isLogOverlayOpen) {
422
+ return (_jsx(FloatingLogWindow, { logs: model.logs, width: Math.max(1, rootWidth - 1), height: rootHeight, scrollOffset: logScrollOffset }));
423
+ }
132
424
  if (minimalLayout) {
133
- return (_jsxs(Box, { width: rootWidth, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsxs(Text, { wrap: "truncate-end", children: ["T:", model.status.kind] }) : null, rootHeight >= 4 ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u2191\u2193jk\u21B5q" }) : null] }));
425
+ return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsxs(Text, { wrap: "truncate-end", children: ["T:", model.status.kind] }) : null, rootHeight >= 4 ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u2191\u2193jk\u21B5Lq" }) : null] }));
134
426
  }
135
427
  if (compactLayout) {
136
- return (_jsxs(Box, { width: rootWidth, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Status: ", model.status.kind, " \u2014 ", model.status.message] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk g/G \u21B5 s r q \u00B7 Resize terminal for split view" })] }));
428
+ return (_jsxs(Box, { width: rootWidth, height: rootHeight, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), completedAlert
429
+ ? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", completedAlert] })
430
+ : model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${model.status.message}` })) : (_jsxs(Text, { wrap: "truncate-end", children: ["Status: ", model.status.kind, " \u2014 ", model.status.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk g/G \u21B5 L s r q \u00B7 Resize terminal for split view" })] }));
137
431
  }
138
- return (_jsxs(Box, { width: rootWidth, height: stackedLayout ? undefined : rootHeight, borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, stacked: stackedLayout }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, compactDetails: compactDetailPane })] }), _jsx(ContextBar, { status: model.status })] }));
432
+ return (_jsxs(Box, { width: rootWidth, height: rootHeight, borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: model.status }), completedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: completedAlert }) })) : null] }));
139
433
  }
@@ -1,10 +1,12 @@
1
1
  import type { AppRow } from '../core/runtime.js';
2
+ export declare function getActionVariant(selectedRow: AppRow, activePath: string | null): 'success' | 'error' | 'info';
2
3
  export declare function getPullRequestColor(selectedRow: AppRow): 'green' | 'yellow' | 'red' | undefined;
3
- export declare function getActionColor(selectedRow: AppRow): 'yellow' | 'red' | undefined;
4
- export declare function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }: {
4
+ export declare function ActionPanel({ selectedRow, activePath, stacked, width, height, compactDetails, scrollOffset, }: {
5
5
  selectedRow: AppRow | undefined;
6
6
  activePath: string | null;
7
7
  stacked: boolean;
8
8
  width?: number;
9
+ height?: number;
9
10
  compactDetails?: boolean;
11
+ scrollOffset?: number;
10
12
  }): import("react").JSX.Element;
@@ -1,7 +1,50 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- function formatTags(tags) {
4
- return tags.length === 0 ? '-' : tags.join(' · ');
3
+ function getTagColor(tag) {
4
+ if (tag === 'active') {
5
+ return 'green';
6
+ }
7
+ if (tag === 'external') {
8
+ return 'yellow';
9
+ }
10
+ if (tag === 'main') {
11
+ return 'blue';
12
+ }
13
+ if (tag === 'invalid') {
14
+ return 'red';
15
+ }
16
+ return 'magenta';
17
+ }
18
+ export function getActionVariant(selectedRow, activePath) {
19
+ if (selectedRow.invalidReason) {
20
+ return 'error';
21
+ }
22
+ if (selectedRow.path === activePath) {
23
+ return 'success';
24
+ }
25
+ if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
26
+ return 'error';
27
+ }
28
+ if ((selectedRow.workingTree?.staged ?? 0) > 0
29
+ || (selectedRow.workingTree?.unstaged ?? 0) > 0
30
+ || (selectedRow.workingTree?.untracked ?? 0) > 0) {
31
+ return 'info';
32
+ }
33
+ return 'info';
34
+ }
35
+ function getNoteVariant(selectedRow) {
36
+ if (selectedRow.invalidReason || selectedRow.tags.includes('external')) {
37
+ return selectedRow.invalidReason ? 'error' : 'info';
38
+ }
39
+ if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
40
+ return 'error';
41
+ }
42
+ if ((selectedRow.workingTree?.staged ?? 0) > 0
43
+ || (selectedRow.workingTree?.unstaged ?? 0) > 0
44
+ || (selectedRow.workingTree?.untracked ?? 0) > 0) {
45
+ return 'info';
46
+ }
47
+ return 'info';
5
48
  }
6
49
  function sanitizeInlineText(value) {
7
50
  return value
@@ -86,20 +129,6 @@ function getActionMessage(selectedRow, activePath) {
86
129
  }
87
130
  return 'Press Enter to start here and switch the active session.';
88
131
  }
89
- export function getActionColor(selectedRow) {
90
- if (selectedRow.invalidReason) {
91
- return 'red';
92
- }
93
- if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
94
- return 'red';
95
- }
96
- if ((selectedRow.workingTree?.staged ?? 0) > 0
97
- || (selectedRow.workingTree?.unstaged ?? 0) > 0
98
- || (selectedRow.workingTree?.untracked ?? 0) > 0) {
99
- return 'yellow';
100
- }
101
- return undefined;
102
- }
103
132
  function getNotes(selectedRow) {
104
133
  if (selectedRow.invalidReason) {
105
134
  return selectedRow.invalidReason;
@@ -107,23 +136,101 @@ function getNotes(selectedRow) {
107
136
  if (selectedRow.tags.includes('external')) {
108
137
  return 'External worktree managed outside the main checkout path.';
109
138
  }
110
- if (selectedRow.tags.includes('active')) {
111
- return 'This worktree currently owns the running command session.';
112
- }
113
139
  return 'Ready to launch with the configured command in this worktree.';
114
140
  }
115
- function SectionHeader({ label }) {
116
- return (_jsxs(Text, { bold: true, color: "cyan", children: ["[", label, "]"] }));
141
+ function getOrderedTags(tags) {
142
+ const tagPriority = {
143
+ active: 0,
144
+ main: 1,
145
+ external: 2,
146
+ invalid: 3,
147
+ };
148
+ return [...tags].sort((a, b) => {
149
+ const aPriority = tagPriority[a] ?? 10;
150
+ const bPriority = tagPriority[b] ?? 10;
151
+ if (aPriority === bPriority) {
152
+ return a.localeCompare(b);
153
+ }
154
+ return aPriority - bPriority;
155
+ });
156
+ }
157
+ function getVariantColor(variant) {
158
+ if (variant === 'success') {
159
+ return 'green';
160
+ }
161
+ if (variant === 'error') {
162
+ return 'red';
163
+ }
164
+ return 'blue';
165
+ }
166
+ function getVariantIcon(variant) {
167
+ if (variant === 'success') {
168
+ return '✓';
169
+ }
170
+ if (variant === 'error') {
171
+ return '✘';
172
+ }
173
+ return 'ℹ';
174
+ }
175
+ function section(label) {
176
+ return { text: `[${label}]`, color: 'cyan', bold: true };
117
177
  }
118
- export function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }) {
178
+ function divider() {
179
+ return { text: ' ', dimColor: true };
180
+ }
181
+ function getPanelLines(selectedRow, activePath, compactDetails) {
119
182
  if (!selectedRow) {
120
- return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Selection / Action" }), _jsx(Text, { dimColor: true, children: "No worktrees found." })] }));
183
+ return [{ text: 'No worktrees found.', dimColor: true }];
121
184
  }
122
- const actionMessage = getActionMessage(selectedRow, activePath);
185
+ const lines = [section('Identity')];
123
186
  const showFullPath = !compactDetails && selectedRow.shortPath !== selectedRow.path;
124
187
  const showTags = !compactDetails;
125
188
  const pullRequestTitle = selectedRow.pullRequest?.kind === 'found' && !compactDetails
126
189
  ? sanitizeInlineText(selectedRow.pullRequest.title)
127
190
  : null;
128
- return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Selection / Action" }), _jsx(SectionHeader, { label: "Identity" }), _jsxs(Text, { bold: true, color: selectedRow.tags.includes('active') ? 'green' : undefined, wrap: "truncate-end", children: ["Branch: ", sanitizeInlineText(selectedRow.branch)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Path: ", sanitizeInlineText(selectedRow.shortPath)] }), showFullPath ? _jsxs(Text, { wrap: "truncate-end", children: ["Full Path: ", sanitizeInlineText(selectedRow.path)] }) : undefined, _jsxs(Text, { wrap: "truncate-end", children: ["HEAD: ", selectedRow.headSha || '-'] }), showTags ? _jsxs(Text, { wrap: "truncate-end", children: ["Tags: ", formatTags(selectedRow.tags)] }) : undefined, _jsx(SectionHeader, { label: "Git / PR" }), _jsxs(Text, { wrap: "truncate-end", children: ["Upstream: ", formatUpstream(selectedRow)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Status: ", formatWorkingTree(selectedRow)] }), _jsxs(Text, { color: getPullRequestColor(selectedRow), dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN', wrap: "truncate-end", children: [getPullRequestLabel(selectedRow), ": ", formatPullRequest(selectedRow)] }), pullRequestTitle ? _jsxs(Text, { dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN', wrap: "truncate-end", children: [getPullRequestTitleLabel(selectedRow), ": ", pullRequestTitle] }) : undefined, _jsx(SectionHeader, { label: "Action" }), _jsx(Text, { color: getActionColor(selectedRow), wrap: "truncate-end", children: actionMessage }), _jsx(SectionHeader, { label: "Notes" }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: getNotes(selectedRow) })] }));
191
+ lines.push({ text: `Branch: ${sanitizeInlineText(selectedRow.branch)}`, bold: true }, { text: `Path: ${sanitizeInlineText(selectedRow.shortPath)}` });
192
+ if (showFullPath) {
193
+ lines.push({ text: `Full Path: ${sanitizeInlineText(selectedRow.path)}` });
194
+ }
195
+ lines.push({ text: `HEAD: ${selectedRow.headSha || '-'}` });
196
+ if (showTags) {
197
+ for (const tag of getOrderedTags(selectedRow.tags.filter(tag => tag !== 'active'))) {
198
+ lines.push({ text: tag.toUpperCase(), color: getTagColor(tag) });
199
+ }
200
+ }
201
+ lines.push(divider(), section('Git / PR'), { text: `Upstream: ${formatUpstream(selectedRow)}` }, { text: `Status: ${formatWorkingTree(selectedRow)}` }, {
202
+ text: `${getPullRequestLabel(selectedRow)}: ${formatPullRequest(selectedRow)}`,
203
+ color: getPullRequestColor(selectedRow),
204
+ dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN',
205
+ });
206
+ if (pullRequestTitle) {
207
+ lines.push({ text: `${getPullRequestTitleLabel(selectedRow)}: ${pullRequestTitle}`, dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN' });
208
+ }
209
+ const actionVariant = getActionVariant(selectedRow, activePath);
210
+ const noteVariant = getNoteVariant(selectedRow);
211
+ lines.push(divider(), section('Action'), { text: `${getVariantIcon(actionVariant)} ${getActionMessage(selectedRow, activePath)}`, color: getVariantColor(actionVariant) }, section('Notes'), { text: `${getVariantIcon(noteVariant)} ${getNotes(selectedRow)}`, color: getVariantColor(noteVariant) });
212
+ return lines;
213
+ }
214
+ function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
215
+ if (totalLines <= viewportHeight) {
216
+ return new Set();
217
+ }
218
+ const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
219
+ const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
220
+ const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
221
+ return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
222
+ }
223
+ export function ActionPanel({ selectedRow, activePath, stacked, width, height, compactDetails, scrollOffset = 0, }) {
224
+ const lines = getPanelLines(selectedRow, activePath, compactDetails ?? false);
225
+ const contentViewportHeight = height === undefined ? undefined : Math.max(1, height - 3);
226
+ const maxScrollOffset = contentViewportHeight === undefined ? 0 : Math.max(0, lines.length - contentViewportHeight);
227
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
228
+ const visibleLines = contentViewportHeight === undefined
229
+ ? lines
230
+ : lines.slice(effectiveScrollOffset, effectiveScrollOffset + contentViewportHeight);
231
+ const showScrollbar = contentViewportHeight !== undefined && lines.length > contentViewportHeight;
232
+ const scrollbarThumbRows = showScrollbar
233
+ ? getScrollbarThumbRows(lines.length, contentViewportHeight, effectiveScrollOffset)
234
+ : new Set();
235
+ return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: "magenta", wrap: "truncate-end", children: "Selection / Action" }), _jsx(Box, { height: contentViewportHeight, flexDirection: "column", overflow: "hidden", children: visibleLines.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: line.color, dimColor: line.dimColor, bold: line.bold, wrap: "truncate-end", children: line.text }) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'magenta' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, `${effectiveScrollOffset + index}-${line.text}`))) })] }));
129
236
  }
@@ -1,6 +1,14 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- const COLOR_BY_KIND = {
3
+ import { Spinner } from '@inkjs/ui';
4
+ const KIND_TO_ICON = {
5
+ idle: 'ℹ',
6
+ starting: '⚠',
7
+ running: '✓',
8
+ stopping: '⚠',
9
+ error: '✘',
10
+ };
11
+ const KIND_TO_COLOR = {
4
12
  idle: 'blue',
5
13
  starting: 'yellow',
6
14
  running: 'green',
@@ -8,5 +16,6 @@ const COLOR_BY_KIND = {
8
16
  error: 'red',
9
17
  };
10
18
  export function ContextBar({ status }) {
11
- return (_jsxs(Box, { borderStyle: "round", borderColor: COLOR_BY_KIND[status.kind], flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: COLOR_BY_KIND[status.kind], wrap: "truncate-end", children: ["Status: ", status.kind, " \u2014 ", status.message] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk move g/G first/last Enter start/switch s stop r refresh q quit" })] }));
19
+ const isBusy = status.kind === 'starting' || status.kind === 'stopping';
20
+ return (_jsxs(Box, { borderStyle: "round", borderColor: KIND_TO_COLOR[status.kind], flexDirection: "column", paddingX: 1, children: [isBusy ? (_jsx(Spinner, { label: `Status: ${status.kind} — ${status.message}` })) : (_jsxs(Text, { color: KIND_TO_COLOR[status.kind], wrap: "truncate-end", children: [KIND_TO_ICON[status.kind], " Status: ", status.kind, " \u2014 ", status.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk move g/G first/last Wheel/PgUp/PgDn list & selection scroll [/] log scroll L full-screen logs Enter start/switch s stop r refresh q quit" })] }));
12
21
  }
@@ -0,0 +1,7 @@
1
+ import type { AppLogEntry } from '../core/runtime.js';
2
+ export declare function FloatingLogWindow({ logs, width, height, scrollOffset, }: {
3
+ logs: AppLogEntry[];
4
+ width: number;
5
+ height: number;
6
+ scrollOffset: number;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { LogPanel } from './LogPanel.js';
3
+ export function FloatingLogWindow({ logs, width, height, scrollOffset, }) {
4
+ return (_jsx(LogPanel, { logs: logs, width: width, height: height, scrollOffset: scrollOffset, title: "Logs (*.log \u00B7 tail 120 \u00B7 full screen)" }));
5
+ }
@@ -0,0 +1,15 @@
1
+ import type { AppLogEntry } from '../core/runtime.js';
2
+ type LineSpec = {
3
+ text: string;
4
+ color?: 'cyan';
5
+ dimColor?: boolean;
6
+ };
7
+ export declare function buildLogLines(logs: AppLogEntry[]): LineSpec[];
8
+ export declare function LogPanel({ logs, width, height, scrollOffset, title, }: {
9
+ logs: AppLogEntry[];
10
+ width?: number;
11
+ height?: number;
12
+ scrollOffset?: number;
13
+ title?: string;
14
+ }): import("react").JSX.Element;
15
+ export {};
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ const ANSI_ESCAPE_PATTERN = /(?:\u001B|\u009B)\[[0-?]*[ -/]*[@-~]/gu;
4
+ function sanitizeLogLine(value) {
5
+ return value
6
+ .replace(ANSI_ESCAPE_PATTERN, '')
7
+ .replace(/[\r\t\u2028\u2029]+/g, ' ')
8
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
9
+ .replace(/\p{Cf}/gu, '')
10
+ .trimEnd();
11
+ }
12
+ export function buildLogLines(logs) {
13
+ if (logs.length === 0) {
14
+ return [{ text: 'No *.log files yet.', dimColor: true }];
15
+ }
16
+ const lines = [];
17
+ for (const [index, log] of logs.entries()) {
18
+ if (index > 0) {
19
+ lines.push({ text: ' ', dimColor: true });
20
+ }
21
+ lines.push({ text: `[${log.name}]`, color: 'cyan' });
22
+ const contentLines = log.content.length > 0 ? log.content.split('\n') : ['(empty)'];
23
+ for (const line of contentLines) {
24
+ lines.push({ text: sanitizeLogLine(line) || ' ' });
25
+ }
26
+ }
27
+ return lines;
28
+ }
29
+ function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
30
+ if (totalLines <= viewportHeight) {
31
+ return new Set();
32
+ }
33
+ const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
34
+ const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
35
+ const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
36
+ return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
37
+ }
38
+ export function LogPanel({ logs, width, height, scrollOffset = 0, title = 'Logs (*.log · tail 120)', }) {
39
+ const lines = buildLogLines(logs);
40
+ const contentViewportHeight = height === undefined ? lines.length : Math.max(1, height - 3);
41
+ const maxScrollOffset = contentViewportHeight === undefined ? 0 : Math.max(0, lines.length - contentViewportHeight);
42
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
43
+ const startIndex = contentViewportHeight === undefined
44
+ ? 0
45
+ : Math.max(0, lines.length - contentViewportHeight - effectiveScrollOffset);
46
+ const visibleLines = contentViewportHeight === undefined
47
+ ? lines
48
+ : lines.slice(startIndex, startIndex + contentViewportHeight);
49
+ const showScrollbar = contentViewportHeight !== undefined && lines.length > contentViewportHeight;
50
+ const scrollbarThumbRows = showScrollbar
51
+ ? getScrollbarThumbRows(lines.length, contentViewportHeight, maxScrollOffset - effectiveScrollOffset)
52
+ : new Set();
53
+ return (_jsxs(Box, { width: width, height: height, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: "yellow", wrap: "truncate-end", children: title }), _jsx(Box, { height: contentViewportHeight, flexDirection: "column", overflow: "hidden", children: visibleLines.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: line.color, dimColor: line.dimColor, wrap: "truncate-end", children: line.text }) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'yellow' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, `${startIndex + index}-${line.text}`))) })] }));
54
+ }
@@ -1,7 +1,9 @@
1
1
  import type { AppRow } from '../core/runtime.js';
2
- export declare function WorktreeList({ rows, selectedIndex, width, stacked, }: {
2
+ export declare function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset, }: {
3
3
  rows: AppRow[];
4
4
  selectedIndex: number;
5
5
  width?: number;
6
+ height?: number;
6
7
  stacked: boolean;
8
+ scrollOffset?: number;
7
9
  }): import("react").JSX.Element;
@@ -48,11 +48,26 @@ function truncateLabel(value, width) {
48
48
  }
49
49
  return `${value.slice(0, Math.max(width - 1, 0))}…`;
50
50
  }
51
- export function WorktreeList({ rows, selectedIndex, width, stacked, }) {
51
+ function getScrollbarThumbRows(totalLines, viewportHeight, scrollOffset) {
52
+ if (totalLines <= viewportHeight) {
53
+ return new Set();
54
+ }
55
+ const thumbSize = Math.max(1, Math.floor((viewportHeight / totalLines) * viewportHeight));
56
+ const maxScrollOffset = Math.max(1, totalLines - viewportHeight);
57
+ const thumbStart = Math.round((scrollOffset / maxScrollOffset) * (viewportHeight - thumbSize));
58
+ return new Set(Array.from({ length: thumbSize }, (_, index) => thumbStart + index));
59
+ }
60
+ export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, }) {
52
61
  const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
53
- return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), rows.map((row, index) => {
54
- const isSelected = index === selectedIndex;
62
+ const contentViewportHeight = height === undefined ? rows.length : Math.max(1, height - 3);
63
+ const maxScrollOffset = Math.max(0, rows.length - contentViewportHeight);
64
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
65
+ const visibleRows = rows.slice(effectiveScrollOffset, effectiveScrollOffset + contentViewportHeight);
66
+ const showScrollbar = height !== undefined && rows.length > contentViewportHeight;
67
+ const scrollbarThumbRows = showScrollbar ? getScrollbarThumbRows(rows.length, contentViewportHeight, effectiveScrollOffset) : new Set();
68
+ return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), visibleRows.map((row, index) => {
69
+ const isSelected = index + effectiveScrollOffset === selectedIndex;
55
70
  const line = `${isSelected ? '>' : ' '} ${getIndicator(row)} ${truncateLabel(sanitizeInlineText(row.branch), branchWidth)}`;
56
- return (_jsx(Text, { color: getRowColor(row, isSelected), dimColor: !isSelected && getRowColor(row, isSelected) === undefined, wrap: "truncate-end", children: line }, row.path));
71
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: getRowColor(row, isSelected), dimColor: !isSelected && getRowColor(row, isSelected) === undefined, wrap: "truncate-end", children: line }, row.path) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'cyan' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, row.path));
57
72
  })] }));
58
73
  }
@@ -40,6 +40,11 @@ export interface AppStatus {
40
40
  kind: 'idle' | 'starting' | 'running' | 'stopping' | 'error';
41
41
  message: string;
42
42
  }
43
+ export interface AppLogEntry {
44
+ name: string;
45
+ path: string;
46
+ content: string;
47
+ }
43
48
  export interface AppModel {
44
49
  repoName: string;
45
50
  namespace: string;
@@ -47,11 +52,13 @@ export interface AppModel {
47
52
  activePath: string | null;
48
53
  activeBranch: string | null;
49
54
  status: AppStatus;
55
+ logs: AppLogEntry[];
50
56
  }
51
57
  export interface AppActions {
52
58
  start: (worktreePath: string) => Promise<AppModel>;
53
59
  stop: () => Promise<AppModel>;
54
60
  refresh: () => Promise<AppModel>;
61
+ refreshLogs: () => Promise<AppLogEntry[]>;
55
62
  }
56
63
  interface GitStatusSummary {
57
64
  upstream?: UpstreamInfo;
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { execFile } from 'node:child_process';
3
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
4
  import { promisify } from 'node:util';
4
5
  import { loadToolConfig } from './config.js';
5
6
  import { readWorktrees, sortWorktrees, toShortPath } from './git-worktrees.js';
@@ -11,6 +12,8 @@ import { isProcessGroupAlive, killProcessGroup, killPortOwner, killOrphans } fro
11
12
  const execFileAsync = promisify(execFile);
12
13
  const SHORT_SHA_LENGTH = 8;
13
14
  const GH_TIMEOUT_MS = 2500;
15
+ const MAX_LOG_BYTES = 16 * 1024;
16
+ const MAX_LOG_LINES = 120;
14
17
  function shortenSha(headSha) {
15
18
  return headSha.slice(0, SHORT_SHA_LENGTH);
16
19
  }
@@ -177,6 +180,45 @@ async function stopRecordedSession(pgid, port, orphanMatchers) {
177
180
  throw new Error(`Failed to stop existing session pgid=${pgid}`);
178
181
  }
179
182
  }
183
+ function tailLogContent(content) {
184
+ const byteTrimmed = content.length > MAX_LOG_BYTES ? content.slice(-MAX_LOG_BYTES) : content;
185
+ const lines = byteTrimmed.replace(/\r\n/g, '\n').split('\n');
186
+ const tailLines = lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES) : lines;
187
+ return tailLines.join('\n').trimEnd();
188
+ }
189
+ async function readLogs(logsDir, activeLogPath) {
190
+ try {
191
+ const entries = (await readdir(logsDir, { withFileTypes: true }))
192
+ .filter(entry => entry.isFile() && entry.name.endsWith('.log'))
193
+ .map(entry => ({ name: entry.name, path: path.join(logsDir, entry.name) }));
194
+ if (entries.length === 0) {
195
+ return [];
196
+ }
197
+ let selectedEntries = entries;
198
+ if (activeLogPath !== null) {
199
+ const activeEntry = entries.find(entry => entry.path === activeLogPath);
200
+ if (activeEntry) {
201
+ selectedEntries = [activeEntry];
202
+ }
203
+ }
204
+ else {
205
+ const withStats = await Promise.all(entries.map(async (entry) => ({
206
+ ...entry,
207
+ mtimeMs: (await stat(entry.path)).mtimeMs,
208
+ })));
209
+ withStats.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
210
+ selectedEntries = [withStats[0]];
211
+ }
212
+ return await Promise.all(selectedEntries.map(async (entry) => ({
213
+ name: entry.name,
214
+ path: entry.path,
215
+ content: tailLogContent(await readFile(entry.path, 'utf8')),
216
+ })));
217
+ }
218
+ catch {
219
+ return [];
220
+ }
221
+ }
180
222
  export async function buildInitialModel(cwd) {
181
223
  const { workspaceRoot, mainWorktreePath, gitCommonDir } = await resolveRepoContext(cwd);
182
224
  const config = await loadToolConfig({ repoRoot: workspaceRoot });
@@ -189,6 +231,7 @@ export async function buildInitialModel(cwd) {
189
231
  activePath: active?.worktreePath ?? null,
190
232
  activeBranch: active?.branch ?? null,
191
233
  status: active ? { kind: 'running', message: `Active: ${active.branch}` } : { kind: 'idle', message: 'ready' },
234
+ logs: await readLogs(paths.logsDir, active?.logPath ?? null),
192
235
  };
193
236
  }
194
237
  export async function buildActions(cwd) {
@@ -197,6 +240,10 @@ export async function buildActions(cwd) {
197
240
  const paths = getSessionPaths(gitCommonDir, config.namespace);
198
241
  const mainWorktreePath = path.dirname(gitCommonDir);
199
242
  const refresh = async () => buildInitialModel(cwd);
243
+ const refreshLogs = async () => {
244
+ const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
245
+ return readLogs(paths.logsDir, active?.logPath ?? null);
246
+ };
200
247
  const stop = async () => {
201
248
  const active = await readSessionRecord(paths, { isSessionAlive: isProcessGroupAlive });
202
249
  if (active) {
@@ -259,5 +306,5 @@ export async function buildActions(cwd) {
259
306
  status: { kind: 'running', message: `started ${selected.branch}` },
260
307
  };
261
308
  };
262
- return { start, stop, refresh };
309
+ return { start, stop, refresh, refreshLogs };
263
310
  }
package/dist/main.js CHANGED
@@ -2,10 +2,12 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createConfigForRepo, parseInitArgs } from './core/init.js';
4
4
  import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES } from './core/config.js';
5
+ import { ThemeProvider } from '@inkjs/ui';
5
6
  import { render } from 'ink';
7
+ import { APP_RENDER_OPTIONS } from './render-options.js';
6
8
  import { App } from './app.js';
7
9
  import { buildActions, buildInitialModel } from './core/runtime.js';
8
- import { APP_RENDER_OPTIONS } from './render-options.js';
10
+ import { appTheme } from './ui-theme.js';
9
11
  const cwd = process.cwd();
10
12
  const args = process.argv.slice(2);
11
13
  const [, , subcommand] = process.argv;
@@ -65,7 +67,27 @@ if (subcommand !== undefined) {
65
67
  }
66
68
  try {
67
69
  const [initialModel, actions] = await Promise.all([buildInitialModel(cwd), buildActions(cwd)]);
68
- render(_jsx(App, { initialModel: initialModel, actions: actions }), APP_RENDER_OPTIONS);
70
+ const createApp = () => (_jsx(ThemeProvider, { theme: appTheme, children: _jsx(App, { initialModel: initialModel, actions: actions }) }));
71
+ const instance = render(createApp(), APP_RENDER_OPTIONS);
72
+ let repaintTimer;
73
+ const repaintAfterResize = () => {
74
+ // Give Ink/useWindowSize one tick to observe the new size, then force a fresh root render.
75
+ if (repaintTimer) {
76
+ clearTimeout(repaintTimer);
77
+ }
78
+ repaintTimer = setTimeout(() => {
79
+ instance.rerender(createApp());
80
+ }, 25);
81
+ };
82
+ if (process.stdout.isTTY) {
83
+ process.stdout.on('resize', repaintAfterResize);
84
+ void instance.waitUntilExit().finally(() => {
85
+ if (repaintTimer) {
86
+ clearTimeout(repaintTimer);
87
+ }
88
+ process.stdout.off('resize', repaintAfterResize);
89
+ });
90
+ }
69
91
  }
70
92
  catch (error) {
71
93
  console.error(describeError(error));
@@ -1,4 +1,5 @@
1
1
  export declare const APP_RENDER_OPTIONS: {
2
2
  readonly alternateScreen: true;
3
3
  readonly exitOnCtrlC: true;
4
+ readonly incrementalRendering: true;
4
5
  };
@@ -1,4 +1,5 @@
1
1
  export const APP_RENDER_OPTIONS = {
2
2
  alternateScreen: true,
3
3
  exitOnCtrlC: true,
4
+ incrementalRendering: true,
4
5
  };
@@ -0,0 +1 @@
1
+ export {};
package/dist/repro.js ADDED
@@ -0,0 +1,13 @@
1
+ const model = {
2
+ repoName: "reclaim-the-forest",
3
+ namespace: "rojo-serve",
4
+ rows: Array.from({ length: 10 }, (_, index) => ({
5
+ path: `/repo/.worktree/feat-${index}`,
6
+ shortPath: `.worktree/feat-${index}`,
7
+ branch: `feat/${index}`,
8
+ tags: (index === 0 ? [active] : []),
9
+ pullRequest: index === 0 ? { kind: found, number: 2125, title: Selection }
10
+ :
11
+ }))
12
+ };
13
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare const appTheme: {
2
+ components: Record<string, import("@inkjs/ui").ComponentTheme>;
3
+ };
@@ -0,0 +1,38 @@
1
+ import { defaultTheme, extendTheme } from '@inkjs/ui';
2
+ const variantColor = {
3
+ info: 'blue',
4
+ success: 'green',
5
+ error: 'red',
6
+ warning: 'yellow',
7
+ };
8
+ export const appTheme = extendTheme(defaultTheme, {
9
+ components: {
10
+ StatusMessage: {
11
+ styles: {
12
+ icon: ({ variant }) => ({
13
+ color: variantColor[variant],
14
+ bold: true,
15
+ }),
16
+ },
17
+ },
18
+ Alert: {
19
+ styles: {
20
+ container: ({ variant }) => ({
21
+ flexGrow: 0,
22
+ flexShrink: 0,
23
+ borderStyle: 'round',
24
+ borderColor: variantColor[variant],
25
+ gap: 1,
26
+ paddingX: 1,
27
+ }),
28
+ icon: ({ variant }) => ({
29
+ color: variantColor[variant],
30
+ bold: true,
31
+ }),
32
+ title: () => ({
33
+ bold: true,
34
+ }),
35
+ },
36
+ },
37
+ },
38
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohzw/worktree-command-tui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A TUI for managing git worktrees",
5
5
  "private": false,
6
6
  "type": "module",
@@ -54,6 +54,7 @@
54
54
  "access": "public"
55
55
  },
56
56
  "dependencies": {
57
+ "@inkjs/ui": "^2.0.0",
57
58
  "ink": "^7.0.4",
58
59
  "react": "^19.2.0"
59
60
  },