opencode-sidebar 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.
- package/LICENSE +203 -0
- package/NOTICE +4 -0
- package/README.md +134 -0
- package/bin/opencode-sidebar.js +93 -0
- package/dist/app.js +908 -0
- package/dist/index.js +30 -0
- package/dist/lib/constants.js +17 -0
- package/dist/lib/model.js +93 -0
- package/dist/lib/notifications.js +250 -0
- package/dist/lib/opencode.js +366 -0
- package/dist/lib/state.js +47 -0
- package/dist/lib/terminal.js +106 -0
- package/dist/lib/tmux.js +371 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/util.js +135 -0
- package/package.json +67 -0
- package/scripts/system-dependencies.mjs +42 -0
package/dist/app.js
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import { STATUS_MESSAGE_HOLD_MS, WINDOW_POLL_INTERVAL_MS } from "./lib/constants.js";
|
|
5
|
+
import { isPrintable, relativeTime, sanitizePastedText, truncate, wrapTextHard } from "./lib/util.js";
|
|
6
|
+
const SPINNER_FRAMES = ["-", "\\", "|", "/"];
|
|
7
|
+
const LIVE_FRAMES = ["o", "O", "0", "O"];
|
|
8
|
+
const SELECT_FRAMES = [">", "}", "]", "}"];
|
|
9
|
+
const ADD_PROJECT_KEY = "action:add-project";
|
|
10
|
+
const HAPPY_BREATHING_FACES = ["(◕ᴥ◕)", "(◕ᴗ◕)"];
|
|
11
|
+
const SUPER_HAPPY_FACE = "(◕‿◕)";
|
|
12
|
+
const SLEEPING_FACE = "(-ᴥ-)";
|
|
13
|
+
const SAD_FACE = "(◕︵◕)";
|
|
14
|
+
const UNWELL_FACE = "(@_@)";
|
|
15
|
+
function rowKey(row) {
|
|
16
|
+
return row.key;
|
|
17
|
+
}
|
|
18
|
+
function rowMatchesQuery(row, query) {
|
|
19
|
+
if (!query)
|
|
20
|
+
return true;
|
|
21
|
+
const lower = query.toLowerCase();
|
|
22
|
+
if (row.kind === "action") {
|
|
23
|
+
return row.label.toLowerCase().includes(lower) || row.detail.toLowerCase().includes(lower);
|
|
24
|
+
}
|
|
25
|
+
if (row.kind === "directory") {
|
|
26
|
+
return (row.record.label.toLowerCase().includes(lower) ||
|
|
27
|
+
row.record.directory.toLowerCase().includes(lower) ||
|
|
28
|
+
row.record.subtitle.toLowerCase().includes(lower));
|
|
29
|
+
}
|
|
30
|
+
return (row.session.title.toLowerCase().includes(lower) ||
|
|
31
|
+
row.record.label.toLowerCase().includes(lower) ||
|
|
32
|
+
row.record.directory.toLowerCase().includes(lower));
|
|
33
|
+
}
|
|
34
|
+
function buildRows(snapshot, expanded, query) {
|
|
35
|
+
const rows = [];
|
|
36
|
+
const addProjectRow = {
|
|
37
|
+
key: ADD_PROJECT_KEY,
|
|
38
|
+
kind: "action",
|
|
39
|
+
action: "add-project",
|
|
40
|
+
label: "Add project folder",
|
|
41
|
+
detail: "Enter an absolute or ~/ path",
|
|
42
|
+
};
|
|
43
|
+
if (rowMatchesQuery(addProjectRow, query)) {
|
|
44
|
+
rows.push(addProjectRow);
|
|
45
|
+
}
|
|
46
|
+
if (!snapshot)
|
|
47
|
+
return rows;
|
|
48
|
+
for (const record of snapshot.directories) {
|
|
49
|
+
const matchingSessions = query
|
|
50
|
+
? record.sessions.filter((session) => rowMatchesQuery({ key: session.id, kind: "session", record, session }, query))
|
|
51
|
+
: record.sessions;
|
|
52
|
+
const directoryMatches = rowMatchesQuery({ key: record.directory, kind: "directory", record }, query);
|
|
53
|
+
if (!directoryMatches && matchingSessions.length === 0)
|
|
54
|
+
continue;
|
|
55
|
+
rows.push({
|
|
56
|
+
key: `dir:${record.directory}`,
|
|
57
|
+
kind: "directory",
|
|
58
|
+
record,
|
|
59
|
+
});
|
|
60
|
+
const showSessions = query ? true : expanded[record.directory] ?? record.pinned;
|
|
61
|
+
if (!showSessions)
|
|
62
|
+
continue;
|
|
63
|
+
for (const session of matchingSessions) {
|
|
64
|
+
rows.push({
|
|
65
|
+
key: `session:${session.id}`,
|
|
66
|
+
kind: "session",
|
|
67
|
+
record,
|
|
68
|
+
session,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
function useNowTick() {
|
|
75
|
+
const [value, setValue] = useState(Date.now());
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const timer = setInterval(() => setValue(Date.now()), 30_000);
|
|
78
|
+
return () => clearInterval(timer);
|
|
79
|
+
}, []);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function useFrame(intervalMs) {
|
|
83
|
+
const [value, setValue] = useState(0);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const timer = setInterval(() => {
|
|
86
|
+
setValue((current) => current + 1);
|
|
87
|
+
}, intervalMs);
|
|
88
|
+
return () => clearInterval(timer);
|
|
89
|
+
}, [intervalMs]);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
function useTerminalSize() {
|
|
93
|
+
const { stdout } = useStdout();
|
|
94
|
+
const [size, setSize] = useState(() => ({
|
|
95
|
+
width: stdout?.columns ?? 100,
|
|
96
|
+
height: stdout?.rows ?? 24,
|
|
97
|
+
}));
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!stdout)
|
|
100
|
+
return;
|
|
101
|
+
const update = () => {
|
|
102
|
+
setSize({
|
|
103
|
+
width: stdout.columns ?? 100,
|
|
104
|
+
height: stdout.rows ?? 24,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
update();
|
|
108
|
+
stdout.on("resize", update);
|
|
109
|
+
return () => {
|
|
110
|
+
stdout.off("resize", update);
|
|
111
|
+
};
|
|
112
|
+
}, [stdout]);
|
|
113
|
+
return size;
|
|
114
|
+
}
|
|
115
|
+
function minimumWidth(value) {
|
|
116
|
+
return Math.max(1, value);
|
|
117
|
+
}
|
|
118
|
+
function clamp(value, min, max) {
|
|
119
|
+
return Math.max(min, Math.min(max, value));
|
|
120
|
+
}
|
|
121
|
+
function windowRows(rows, selectedIndex, limit) {
|
|
122
|
+
if (limit <= 0)
|
|
123
|
+
return [];
|
|
124
|
+
if (rows.length <= limit)
|
|
125
|
+
return rows;
|
|
126
|
+
const before = Math.floor(limit / 3);
|
|
127
|
+
const start = clamp(selectedIndex - before, 0, Math.max(0, rows.length - limit));
|
|
128
|
+
return rows.slice(start, start + limit);
|
|
129
|
+
}
|
|
130
|
+
function sectionRule(title, width) {
|
|
131
|
+
const prefix = `--[ ${title} ]`;
|
|
132
|
+
if (prefix.length >= width)
|
|
133
|
+
return truncate(prefix, width);
|
|
134
|
+
return prefix + "-".repeat(width - prefix.length);
|
|
135
|
+
}
|
|
136
|
+
function metricLine(label, value, width) {
|
|
137
|
+
return truncate(`${label.toUpperCase().padEnd(10)} ${value}`, width);
|
|
138
|
+
}
|
|
139
|
+
function sessionJustCompleted(status) {
|
|
140
|
+
return status?.type === "idle" && status.justCompleted === true;
|
|
141
|
+
}
|
|
142
|
+
function sessionIsWorking(status) {
|
|
143
|
+
return status?.type === "busy" || status?.type === "retry";
|
|
144
|
+
}
|
|
145
|
+
function snapshotHasKey(snapshot, key) {
|
|
146
|
+
if (!key)
|
|
147
|
+
return false;
|
|
148
|
+
if (key.startsWith("action:"))
|
|
149
|
+
return key === ADD_PROJECT_KEY;
|
|
150
|
+
if (!snapshot)
|
|
151
|
+
return false;
|
|
152
|
+
if (key.startsWith("dir:")) {
|
|
153
|
+
return snapshot.directories.some((record) => record.directory === key.slice(4));
|
|
154
|
+
}
|
|
155
|
+
if (key.startsWith("session:")) {
|
|
156
|
+
return snapshot.directories.some((record) => record.sessions.some((session) => session.id === key.slice(8)));
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
function findSessionInSnapshot(snapshot, sessionID) {
|
|
161
|
+
if (!snapshot || !sessionID)
|
|
162
|
+
return undefined;
|
|
163
|
+
for (const record of snapshot.directories) {
|
|
164
|
+
const session = record.sessions.find((item) => item.id === sessionID);
|
|
165
|
+
if (session) {
|
|
166
|
+
return { record, session };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
function describeOpenResult(result) {
|
|
172
|
+
if (result.backend === "tmux") {
|
|
173
|
+
return result.action === "focused" ? "Loaded selected session in preview" : "Opened tmux session";
|
|
174
|
+
}
|
|
175
|
+
if (result.backend === "current-terminal")
|
|
176
|
+
return "Switching current terminal to OpenCode...";
|
|
177
|
+
return result.action === "focused" ? `Focused existing ${result.backend} window` : `Opened new ${result.backend} window`;
|
|
178
|
+
}
|
|
179
|
+
function mascotTitle(input) {
|
|
180
|
+
const attentionNeeded = Boolean(input.error) || input.mode === "add-project";
|
|
181
|
+
const face = input.error
|
|
182
|
+
? UNWELL_FACE
|
|
183
|
+
: attentionNeeded
|
|
184
|
+
? SAD_FACE
|
|
185
|
+
: input.busy
|
|
186
|
+
? SUPER_HAPPY_FACE
|
|
187
|
+
: input.activeCount > 0
|
|
188
|
+
? HAPPY_BREATHING_FACES[input.frame % HAPPY_BREATHING_FACES.length]
|
|
189
|
+
: SLEEPING_FACE;
|
|
190
|
+
const mood = input.error
|
|
191
|
+
? "Not feeling great"
|
|
192
|
+
: attentionNeeded
|
|
193
|
+
? "Needs attention"
|
|
194
|
+
: input.busy
|
|
195
|
+
? "Super happy"
|
|
196
|
+
: input.activeCount > 0
|
|
197
|
+
? "Happy and breathing"
|
|
198
|
+
: "Sleeping";
|
|
199
|
+
const wideTitle = `:: OPENCODE SIDEBAR v0.1 :: ${face} ${mood}`;
|
|
200
|
+
const compactTitle = `:: OPENCODE SIDEBAR :: ${face}`;
|
|
201
|
+
if (input.compact)
|
|
202
|
+
return compactTitle;
|
|
203
|
+
if (wideTitle.length <= input.width)
|
|
204
|
+
return wideTitle;
|
|
205
|
+
const mediumTitle = `:: OPENCODE SIDEBAR v0.1 :: ${face}`;
|
|
206
|
+
if (mediumTitle.length <= input.width)
|
|
207
|
+
return mediumTitle;
|
|
208
|
+
return compactTitle;
|
|
209
|
+
}
|
|
210
|
+
function Panel(props) {
|
|
211
|
+
const { title, width, borderColor = "gray", titleColor = "cyanBright", children } = props;
|
|
212
|
+
return (_jsxs(Box, { width: width + 4, flexDirection: "column", borderStyle: "single", borderColor: borderColor, paddingX: 1, children: [_jsx(Text, { color: titleColor, bold: true, children: truncate(title, width) }), children] }));
|
|
213
|
+
}
|
|
214
|
+
export function App({ service, onCleanup, }) {
|
|
215
|
+
const { exit } = useApp();
|
|
216
|
+
const [snapshot, setSnapshot] = useState(null);
|
|
217
|
+
const [expanded, setExpanded] = useState({});
|
|
218
|
+
const [selectedKey, setSelectedKey] = useState();
|
|
219
|
+
const [mode, setMode] = useState("browse");
|
|
220
|
+
const [inputValue, setInputValue] = useState("");
|
|
221
|
+
const [status, setStatus] = useState("Booting opencode server...");
|
|
222
|
+
const [error, setError] = useState();
|
|
223
|
+
const [loading, setLoading] = useState(true);
|
|
224
|
+
const [busy, setBusy] = useState();
|
|
225
|
+
const [deleteTarget, setDeleteTarget] = useState();
|
|
226
|
+
const [killTarget, setKillTarget] = useState();
|
|
227
|
+
const [renameTarget, setRenameTarget] = useState();
|
|
228
|
+
const stickyStatusUntilRef = useRef(0);
|
|
229
|
+
const { width, height } = useTerminalSize();
|
|
230
|
+
const now = useNowTick();
|
|
231
|
+
const frame = useFrame(160);
|
|
232
|
+
const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
233
|
+
const liveGlyph = LIVE_FRAMES[frame % LIVE_FRAMES.length];
|
|
234
|
+
const selectGlyph = SELECT_FRAMES[frame % SELECT_FRAMES.length];
|
|
235
|
+
const inputCursor = frame % 2 === 0 ? "_" : " ";
|
|
236
|
+
const compactLayout = width < 38 || height < 28;
|
|
237
|
+
const panelGap = compactLayout ? 0 : 1;
|
|
238
|
+
const showBanner = height >= 12;
|
|
239
|
+
const panelOuterWidth = minimumWidth(width - 2);
|
|
240
|
+
const panelTextWidth = minimumWidth(panelOuterWidth - 4);
|
|
241
|
+
const sectionTextWidth = minimumWidth(width - 2);
|
|
242
|
+
const rows = useMemo(() => buildRows(snapshot, expanded, mode === "search" ? inputValue : ""), [expanded, inputValue, mode, snapshot]);
|
|
243
|
+
const selectedIndex = useMemo(() => {
|
|
244
|
+
if (!rows.length)
|
|
245
|
+
return 0;
|
|
246
|
+
if (!selectedKey)
|
|
247
|
+
return 0;
|
|
248
|
+
const match = rows.findIndex((row) => rowKey(row) === selectedKey);
|
|
249
|
+
return match >= 0 ? match : 0;
|
|
250
|
+
}, [rows, selectedKey]);
|
|
251
|
+
const selectedRow = rows[selectedIndex];
|
|
252
|
+
const previewSession = useMemo(() => findSessionInSnapshot(snapshot, snapshot?.previewSessionID), [snapshot]);
|
|
253
|
+
const closeApp = useCallback(async () => {
|
|
254
|
+
try {
|
|
255
|
+
if (onCleanup) {
|
|
256
|
+
await onCleanup();
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
await service.shutdown();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Best-effort cleanup only.
|
|
264
|
+
}
|
|
265
|
+
exit();
|
|
266
|
+
}, [exit, onCleanup, service]);
|
|
267
|
+
const setTemporaryStatus = useCallback((message) => {
|
|
268
|
+
const nextStickyStatusUntil = Date.now() + STATUS_MESSAGE_HOLD_MS;
|
|
269
|
+
stickyStatusUntilRef.current = nextStickyStatusUntil;
|
|
270
|
+
setStatus(message);
|
|
271
|
+
}, []);
|
|
272
|
+
const beginAddProject = useCallback(() => {
|
|
273
|
+
setMode("add-project");
|
|
274
|
+
setInputValue("");
|
|
275
|
+
setTemporaryStatus("Enter an absolute or ~/ path for the new project folder");
|
|
276
|
+
}, [setTemporaryStatus]);
|
|
277
|
+
const beginRenameSession = useCallback(() => {
|
|
278
|
+
if (!selectedRow || selectedRow.kind !== "session") {
|
|
279
|
+
setTemporaryStatus("Select a session to rename");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const title = selectedRow.session.title || "New session";
|
|
283
|
+
setRenameTarget({
|
|
284
|
+
sessionID: selectedRow.session.id,
|
|
285
|
+
directory: selectedRow.record.directory,
|
|
286
|
+
title,
|
|
287
|
+
});
|
|
288
|
+
setMode("rename-session");
|
|
289
|
+
setInputValue(title);
|
|
290
|
+
setTemporaryStatus("Edit the session title and press Enter");
|
|
291
|
+
}, [selectedRow, setTemporaryStatus]);
|
|
292
|
+
const refresh = useCallback(async (preferredSelectedKey) => {
|
|
293
|
+
setLoading(true);
|
|
294
|
+
setError(undefined);
|
|
295
|
+
try {
|
|
296
|
+
const next = await service.getSnapshot();
|
|
297
|
+
setSnapshot(next);
|
|
298
|
+
if (Date.now() > stickyStatusUntilRef.current) {
|
|
299
|
+
setStatus(`Connected to ${next.baseUrl} [${service.describeBackend()}]`);
|
|
300
|
+
}
|
|
301
|
+
setExpanded((current) => {
|
|
302
|
+
const updated = { ...current };
|
|
303
|
+
for (const [index, record] of next.directories.entries()) {
|
|
304
|
+
if (!(record.directory in updated)) {
|
|
305
|
+
updated[record.directory] = record.pinned || index < 6;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return updated;
|
|
309
|
+
});
|
|
310
|
+
setSelectedKey((current) => {
|
|
311
|
+
if (preferredSelectedKey && snapshotHasKey(next, preferredSelectedKey))
|
|
312
|
+
return preferredSelectedKey;
|
|
313
|
+
if (current && snapshotHasKey(next, current))
|
|
314
|
+
return current;
|
|
315
|
+
return next.directories[0] ? `dir:${next.directories[0].directory}` : ADD_PROJECT_KEY;
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
setLoading(false);
|
|
323
|
+
}
|
|
324
|
+
}, [service]);
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
const abort = new AbortController();
|
|
327
|
+
void refresh();
|
|
328
|
+
void service.subscribe(abort.signal, refresh);
|
|
329
|
+
const timer = setInterval(() => {
|
|
330
|
+
void refresh();
|
|
331
|
+
}, WINDOW_POLL_INTERVAL_MS);
|
|
332
|
+
return () => {
|
|
333
|
+
abort.abort();
|
|
334
|
+
clearInterval(timer);
|
|
335
|
+
};
|
|
336
|
+
}, [refresh]);
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (deleteTarget && !snapshotHasKey(snapshot, `session:${deleteTarget.sessionID}`)) {
|
|
339
|
+
setDeleteTarget(undefined);
|
|
340
|
+
setTemporaryStatus("Selected session is already gone");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (killTarget && !snapshotHasKey(snapshot, `session:${killTarget.sessionID}`)) {
|
|
344
|
+
setKillTarget(undefined);
|
|
345
|
+
setTemporaryStatus("Selected session is already gone");
|
|
346
|
+
}
|
|
347
|
+
if (renameTarget && !snapshotHasKey(snapshot, `session:${renameTarget.sessionID}`)) {
|
|
348
|
+
setRenameTarget(undefined);
|
|
349
|
+
if (mode === "rename-session") {
|
|
350
|
+
setMode("browse");
|
|
351
|
+
setInputValue("");
|
|
352
|
+
}
|
|
353
|
+
setTemporaryStatus("Selected session is already gone");
|
|
354
|
+
}
|
|
355
|
+
}, [deleteTarget, killTarget, mode, renameTarget, setTemporaryStatus, snapshot]);
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
if (!rows.length && snapshot?.directories.length) {
|
|
358
|
+
setSelectedKey(`dir:${snapshot.directories[0].directory}`);
|
|
359
|
+
}
|
|
360
|
+
if (!rows.length)
|
|
361
|
+
return;
|
|
362
|
+
if (!selectedRow) {
|
|
363
|
+
setSelectedKey(rowKey(rows[Math.min(selectedIndex, rows.length - 1)]));
|
|
364
|
+
}
|
|
365
|
+
}, [rows, selectedIndex, selectedRow, snapshot]);
|
|
366
|
+
const move = useCallback((direction) => {
|
|
367
|
+
if (!rows.length)
|
|
368
|
+
return;
|
|
369
|
+
const next = (selectedIndex + direction + rows.length) % rows.length;
|
|
370
|
+
setSelectedKey(rowKey(rows[next]));
|
|
371
|
+
}, [rows, selectedIndex]);
|
|
372
|
+
const toggleDirectory = useCallback((record, next) => {
|
|
373
|
+
setExpanded((current) => ({
|
|
374
|
+
...current,
|
|
375
|
+
[record.directory]: next ?? !current[record.directory],
|
|
376
|
+
}));
|
|
377
|
+
}, []);
|
|
378
|
+
const commitInput = useCallback(async () => {
|
|
379
|
+
const value = inputValue.trim();
|
|
380
|
+
if (!value) {
|
|
381
|
+
setMode("browse");
|
|
382
|
+
setInputValue("");
|
|
383
|
+
setRenameTarget(undefined);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (mode === "add-project") {
|
|
387
|
+
setBusy(`Adding ${value}...`);
|
|
388
|
+
try {
|
|
389
|
+
const directory = await service.addProjectDirectory(value);
|
|
390
|
+
setTemporaryStatus(`Added project folder ${directory}`);
|
|
391
|
+
setMode("browse");
|
|
392
|
+
setInputValue("");
|
|
393
|
+
await refresh(`dir:${directory}`);
|
|
394
|
+
}
|
|
395
|
+
catch (cause) {
|
|
396
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
setBusy(undefined);
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (mode === "rename-session" && renameTarget) {
|
|
404
|
+
setBusy(`Renaming ${renameTarget.title}...`);
|
|
405
|
+
try {
|
|
406
|
+
const title = await service.renameSession(renameTarget.directory, renameTarget.sessionID, value);
|
|
407
|
+
setTemporaryStatus(`Renamed session to ${title}`);
|
|
408
|
+
setMode("browse");
|
|
409
|
+
setInputValue("");
|
|
410
|
+
setRenameTarget(undefined);
|
|
411
|
+
await refresh(`session:${renameTarget.sessionID}`);
|
|
412
|
+
}
|
|
413
|
+
catch (cause) {
|
|
414
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
setBusy(undefined);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}, [inputValue, mode, refresh, renameTarget, service, setTemporaryStatus]);
|
|
421
|
+
const openSelection = useCallback(async () => {
|
|
422
|
+
if (!selectedRow)
|
|
423
|
+
return;
|
|
424
|
+
if (selectedRow.kind === "action") {
|
|
425
|
+
beginAddProject();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
setBusy(selectedRow.kind === "session" ? `Opening ${selectedRow.session.title || "New session"}...` : `Opening ${selectedRow.record.label}...`);
|
|
429
|
+
try {
|
|
430
|
+
const result = selectedRow.kind === "session"
|
|
431
|
+
? await service.openSession(selectedRow.record.directory, selectedRow.session)
|
|
432
|
+
: await service.openDirectory(selectedRow.record);
|
|
433
|
+
setTemporaryStatus(describeOpenResult(result));
|
|
434
|
+
setMode("browse");
|
|
435
|
+
setInputValue("");
|
|
436
|
+
await refresh(`session:${result.sessionID}`);
|
|
437
|
+
}
|
|
438
|
+
catch (cause) {
|
|
439
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
setBusy(undefined);
|
|
443
|
+
}
|
|
444
|
+
}, [beginAddProject, refresh, selectedRow, setTemporaryStatus]);
|
|
445
|
+
const openLatestOrCreate = useCallback(async () => {
|
|
446
|
+
if (!selectedRow)
|
|
447
|
+
return;
|
|
448
|
+
if (selectedRow.kind === "action") {
|
|
449
|
+
beginAddProject();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const record = selectedRow.record;
|
|
453
|
+
setBusy(`Opening ${record.label}...`);
|
|
454
|
+
try {
|
|
455
|
+
const result = record.sessions[0]
|
|
456
|
+
? await service.openSession(record.directory, record.sessions[0])
|
|
457
|
+
: await service.openNewSession(record.directory);
|
|
458
|
+
setTemporaryStatus(describeOpenResult(result));
|
|
459
|
+
await refresh(`session:${result.sessionID}`);
|
|
460
|
+
}
|
|
461
|
+
catch (cause) {
|
|
462
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
setBusy(undefined);
|
|
466
|
+
}
|
|
467
|
+
}, [beginAddProject, refresh, selectedRow, setTemporaryStatus]);
|
|
468
|
+
const createNewSession = useCallback(async () => {
|
|
469
|
+
if (!selectedRow)
|
|
470
|
+
return;
|
|
471
|
+
if (selectedRow.kind === "action") {
|
|
472
|
+
beginAddProject();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
setBusy(`Creating a new session for ${selectedRow.record.label}...`);
|
|
476
|
+
try {
|
|
477
|
+
const result = await service.openNewSession(selectedRow.record.directory);
|
|
478
|
+
setTemporaryStatus(describeOpenResult(result));
|
|
479
|
+
await refresh(`session:${result.sessionID}`);
|
|
480
|
+
}
|
|
481
|
+
catch (cause) {
|
|
482
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
setBusy(undefined);
|
|
486
|
+
}
|
|
487
|
+
}, [beginAddProject, refresh, selectedRow, setTemporaryStatus]);
|
|
488
|
+
const unpinSelection = useCallback(async () => {
|
|
489
|
+
if (!selectedRow || selectedRow.kind !== "directory" || !selectedRow.record.pinned)
|
|
490
|
+
return;
|
|
491
|
+
setBusy(`Removing ${selectedRow.record.label} from pins...`);
|
|
492
|
+
try {
|
|
493
|
+
await service.unpinDirectory(selectedRow.record.directory);
|
|
494
|
+
setTemporaryStatus(`Unpinned ${selectedRow.record.directory}`);
|
|
495
|
+
await refresh(rowKey(selectedRow));
|
|
496
|
+
}
|
|
497
|
+
catch (cause) {
|
|
498
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
setBusy(undefined);
|
|
502
|
+
}
|
|
503
|
+
}, [refresh, selectedRow, setTemporaryStatus]);
|
|
504
|
+
const requestDeleteSelection = useCallback(() => {
|
|
505
|
+
if (!selectedRow || selectedRow.kind !== "session") {
|
|
506
|
+
setTemporaryStatus("Select a session to delete");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
setDeleteTarget({
|
|
510
|
+
sessionID: selectedRow.session.id,
|
|
511
|
+
directory: selectedRow.record.directory,
|
|
512
|
+
title: selectedRow.session.title || "New session",
|
|
513
|
+
});
|
|
514
|
+
}, [selectedRow, setTemporaryStatus]);
|
|
515
|
+
const confirmDeleteSelection = useCallback(async () => {
|
|
516
|
+
if (!deleteTarget)
|
|
517
|
+
return;
|
|
518
|
+
const target = deleteTarget;
|
|
519
|
+
setDeleteTarget(undefined);
|
|
520
|
+
setSelectedKey(`dir:${target.directory}`);
|
|
521
|
+
setBusy(`Deleting ${target.title}...`);
|
|
522
|
+
try {
|
|
523
|
+
await service.deleteSession(target.directory, target.sessionID);
|
|
524
|
+
setTemporaryStatus(`Deleted session ${target.title}`);
|
|
525
|
+
await refresh(`dir:${target.directory}`);
|
|
526
|
+
}
|
|
527
|
+
catch (cause) {
|
|
528
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
setBusy(undefined);
|
|
532
|
+
}
|
|
533
|
+
}, [deleteTarget, refresh, setTemporaryStatus]);
|
|
534
|
+
const cancelDeleteSelection = useCallback(() => {
|
|
535
|
+
if (!deleteTarget)
|
|
536
|
+
return;
|
|
537
|
+
setDeleteTarget(undefined);
|
|
538
|
+
setTemporaryStatus("Delete cancelled");
|
|
539
|
+
}, [deleteTarget, setTemporaryStatus]);
|
|
540
|
+
const requestKillSelection = useCallback(() => {
|
|
541
|
+
if (!selectedRow || selectedRow.kind !== "session") {
|
|
542
|
+
setTemporaryStatus("Select a session to kill");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (!selectedRow.record.openSessionIDs.has(selectedRow.session.id)) {
|
|
546
|
+
setTemporaryStatus("Selected session is not currently running");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
setKillTarget({
|
|
550
|
+
sessionID: selectedRow.session.id,
|
|
551
|
+
directory: selectedRow.record.directory,
|
|
552
|
+
title: selectedRow.session.title || "New session",
|
|
553
|
+
});
|
|
554
|
+
}, [selectedRow, setTemporaryStatus]);
|
|
555
|
+
const confirmKillSelection = useCallback(async () => {
|
|
556
|
+
if (!killTarget)
|
|
557
|
+
return;
|
|
558
|
+
const target = killTarget;
|
|
559
|
+
setKillTarget(undefined);
|
|
560
|
+
setSelectedKey(`session:${target.sessionID}`);
|
|
561
|
+
setBusy(`Killing ${target.title}...`);
|
|
562
|
+
try {
|
|
563
|
+
const killed = await service.killSession(target.sessionID);
|
|
564
|
+
if (killed) {
|
|
565
|
+
setTemporaryStatus(`Killed running window for ${target.title}`);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
setTemporaryStatus(`${target.title} was not running`);
|
|
569
|
+
}
|
|
570
|
+
await refresh(`session:${target.sessionID}`);
|
|
571
|
+
}
|
|
572
|
+
catch (cause) {
|
|
573
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
setBusy(undefined);
|
|
577
|
+
}
|
|
578
|
+
}, [killTarget, refresh, setTemporaryStatus]);
|
|
579
|
+
const cancelKillSelection = useCallback(() => {
|
|
580
|
+
if (!killTarget)
|
|
581
|
+
return;
|
|
582
|
+
setKillTarget(undefined);
|
|
583
|
+
setTemporaryStatus("Kill cancelled");
|
|
584
|
+
}, [killTarget, setTemporaryStatus]);
|
|
585
|
+
const cancelRenameSelection = useCallback(() => {
|
|
586
|
+
if (!renameTarget && mode !== "rename-session")
|
|
587
|
+
return;
|
|
588
|
+
setRenameTarget(undefined);
|
|
589
|
+
setMode("browse");
|
|
590
|
+
setInputValue("");
|
|
591
|
+
setTemporaryStatus("Rename cancelled");
|
|
592
|
+
}, [mode, renameTarget, setTemporaryStatus]);
|
|
593
|
+
useInput((input, key) => {
|
|
594
|
+
const loweredInput = input.toLowerCase();
|
|
595
|
+
const isInterrupt = input === "\u0003" || (key.ctrl && input === "c");
|
|
596
|
+
if (isInterrupt) {
|
|
597
|
+
void closeApp();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (deleteTarget) {
|
|
601
|
+
if (key.escape || loweredInput === "n") {
|
|
602
|
+
cancelDeleteSelection();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (key.return || loweredInput === "y") {
|
|
606
|
+
void confirmDeleteSelection();
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (killTarget) {
|
|
611
|
+
if (key.escape || loweredInput === "n") {
|
|
612
|
+
cancelKillSelection();
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (key.return || loweredInput === "y") {
|
|
616
|
+
void confirmKillSelection();
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (mode === "rename-session") {
|
|
621
|
+
if (key.escape) {
|
|
622
|
+
cancelRenameSelection();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (key.return) {
|
|
626
|
+
void commitInput();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (key.backspace || key.delete) {
|
|
630
|
+
setInputValue((current) => current.slice(0, -1));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const pasted = sanitizePastedText(input);
|
|
634
|
+
if (pasted && !key.ctrl && !key.meta) {
|
|
635
|
+
setInputValue((current) => current + pasted);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (isPrintable(input)) {
|
|
639
|
+
setInputValue((current) => current + input);
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (busy) {
|
|
644
|
+
if (input === "q") {
|
|
645
|
+
void closeApp();
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (mode === "search" || mode === "add-project") {
|
|
650
|
+
if (mode === "search") {
|
|
651
|
+
if (key.upArrow) {
|
|
652
|
+
move(-1);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (key.downArrow) {
|
|
656
|
+
move(1);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (key.escape) {
|
|
661
|
+
if (mode === "search" && inputValue) {
|
|
662
|
+
setInputValue("");
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
setMode("browse");
|
|
666
|
+
setInputValue("");
|
|
667
|
+
setTemporaryStatus("Ready");
|
|
668
|
+
}
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (key.return) {
|
|
672
|
+
if (mode === "search") {
|
|
673
|
+
void openSelection();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
void commitInput();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (key.backspace || key.delete) {
|
|
680
|
+
setInputValue((current) => current.slice(0, -1));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const pasted = sanitizePastedText(input);
|
|
684
|
+
if (pasted && !key.ctrl && !key.meta) {
|
|
685
|
+
setInputValue((current) => current + pasted);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (isPrintable(input)) {
|
|
689
|
+
setInputValue((current) => current + input);
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (input === "q") {
|
|
694
|
+
void closeApp();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (input === "/") {
|
|
698
|
+
setMode("search");
|
|
699
|
+
setInputValue("");
|
|
700
|
+
setTemporaryStatus("Type to filter projects and sessions");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (input === "a") {
|
|
704
|
+
beginAddProject();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (input === "x") {
|
|
708
|
+
void unpinSelection();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (input === "d") {
|
|
712
|
+
requestDeleteSelection();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (input === "k") {
|
|
716
|
+
requestKillSelection();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (input === "e") {
|
|
720
|
+
beginRenameSession();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (input === "r") {
|
|
724
|
+
void refresh();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (input === "n") {
|
|
728
|
+
void createNewSession();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (input === "o") {
|
|
732
|
+
void openLatestOrCreate();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (input === " ") {
|
|
736
|
+
if (selectedRow?.kind === "directory")
|
|
737
|
+
toggleDirectory(selectedRow.record);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (key.return) {
|
|
741
|
+
void openSelection();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (key.upArrow) {
|
|
745
|
+
move(-1);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (key.downArrow) {
|
|
749
|
+
move(1);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (key.leftArrow && selectedRow?.kind === "directory") {
|
|
753
|
+
toggleDirectory(selectedRow.record, false);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (key.rightArrow && selectedRow?.kind === "directory") {
|
|
757
|
+
toggleDirectory(selectedRow.record, true);
|
|
758
|
+
}
|
|
759
|
+
}, { isActive: Boolean(process.stdin.isTTY) });
|
|
760
|
+
const directoryCount = snapshot?.directories.length ?? 0;
|
|
761
|
+
const sessionCount = snapshot?.directories.reduce((count, record) => count + record.sessions.length, 0) ?? 0;
|
|
762
|
+
const activeCount = snapshot?.activeSessions.length ?? 0;
|
|
763
|
+
const hasWorkingSessions = snapshot?.directories.some((record) => record.sessions.some((session) => sessionIsWorking(session.status))) ?? false;
|
|
764
|
+
const detail = useMemo(() => {
|
|
765
|
+
if (!selectedRow)
|
|
766
|
+
return "No project selected";
|
|
767
|
+
if (selectedRow.kind === "action")
|
|
768
|
+
return selectedRow.detail;
|
|
769
|
+
if (selectedRow.kind === "directory") {
|
|
770
|
+
const active = selectedRow.record.activeSessionIDs.size;
|
|
771
|
+
return `${selectedRow.record.sessions.length} sessions${active ? ` | ${active} live` : ""}`;
|
|
772
|
+
}
|
|
773
|
+
const active = selectedRow.record.activeSessionIDs.has(selectedRow.session.id);
|
|
774
|
+
return `${selectedRow.session.id}${active ? " | live" : ""}`;
|
|
775
|
+
}, [selectedRow]);
|
|
776
|
+
const previewLabel = useMemo(() => {
|
|
777
|
+
if (!previewSession)
|
|
778
|
+
return "idle";
|
|
779
|
+
const label = previewSession.session.title || previewSession.record.label;
|
|
780
|
+
return truncate(label, compactLayout ? panelTextWidth : minimumWidth(panelTextWidth - 16));
|
|
781
|
+
}, [compactLayout, panelTextWidth, previewSession]);
|
|
782
|
+
const bannerTitle = mascotTitle({
|
|
783
|
+
compact: compactLayout,
|
|
784
|
+
width: panelTextWidth,
|
|
785
|
+
frame,
|
|
786
|
+
busy: hasWorkingSessions,
|
|
787
|
+
activeCount,
|
|
788
|
+
error,
|
|
789
|
+
mode,
|
|
790
|
+
});
|
|
791
|
+
const activityGlyph = hasWorkingSessions ? liveGlyph : activeCount > 0 ? "|" : ".";
|
|
792
|
+
const statusTitle = `STATUS / MATRIX [${activityGlyph}]`;
|
|
793
|
+
const showAddProjectModal = mode === "add-project";
|
|
794
|
+
const showRenameSessionModal = mode === "rename-session";
|
|
795
|
+
const showToolsPanel = !compactLayout || (!deleteTarget && !killTarget);
|
|
796
|
+
const apiState = snapshot ? "CONNECTED" : error ? "DEGRADED" : "BOOTING";
|
|
797
|
+
const statusLines = compactLayout
|
|
798
|
+
? [
|
|
799
|
+
metricLine("api", `[${apiState}]`, panelTextWidth),
|
|
800
|
+
metricLine("preview", `[${previewLabel}]`, panelTextWidth),
|
|
801
|
+
metricLine("workspace", `[${directoryCount} D | ${sessionCount} S | ${activeCount} LIVE]`, panelTextWidth),
|
|
802
|
+
]
|
|
803
|
+
: [
|
|
804
|
+
metricLine("api", `[${apiState}]`, panelTextWidth),
|
|
805
|
+
metricLine("backend", `[${service.describeBackend().toUpperCase()}]`, panelTextWidth),
|
|
806
|
+
metricLine("preview", `[${previewLabel}]`, panelTextWidth),
|
|
807
|
+
metricLine("workspace", `[${directoryCount} D | ${sessionCount} S | ${activeCount} LIVE]`, panelTextWidth),
|
|
808
|
+
];
|
|
809
|
+
const statusMessageText = error ? `STATE ERROR :: ${error}` : busy ? `STATE WORK :: ${busy} [${spinner}]` : `STATE LINK :: ${status}`;
|
|
810
|
+
const statusMessageLines = wrapTextHard(statusMessageText, panelTextWidth);
|
|
811
|
+
const toolsLines = wrapTextHard("[Enter] Load [N] New [E] Rename [D] Delete [K] Kill [/] Find [A] Add [Space] Expand [R] Refresh [Q] Quit [Ctrl-b + Arrow] Move panes", panelTextWidth);
|
|
812
|
+
const addProjectLines = showAddProjectModal
|
|
813
|
+
? [
|
|
814
|
+
...wrapTextHard(`Path :: ${inputValue}${inputCursor}`, panelTextWidth),
|
|
815
|
+
...wrapTextHard("Paste an absolute path or use ~/ to add a folder to the sidebar.", panelTextWidth),
|
|
816
|
+
...wrapTextHard("[Enter] Add folder [Esc] Cancel", panelTextWidth),
|
|
817
|
+
]
|
|
818
|
+
: [];
|
|
819
|
+
const renameSessionLines = showRenameSessionModal
|
|
820
|
+
? [
|
|
821
|
+
...wrapTextHard(`Title :: ${inputValue}${inputCursor}`, panelTextWidth),
|
|
822
|
+
...wrapTextHard("Give the selected session a new title.", panelTextWidth),
|
|
823
|
+
...wrapTextHard("[Enter] Rename [Esc] Cancel", panelTextWidth),
|
|
824
|
+
]
|
|
825
|
+
: [];
|
|
826
|
+
const promptPrimary = mode === "search"
|
|
827
|
+
? `/ ${inputValue}${inputCursor}`
|
|
828
|
+
: selectedRow?.kind === "action" ? selectedRow.detail : selectedRow?.record.directory ?? "No project selected";
|
|
829
|
+
const promptPrimaryLines = wrapTextHard(promptPrimary, panelTextWidth);
|
|
830
|
+
const promptDetail = mode === "search"
|
|
831
|
+
? ["[Enter] submit [Esc] cancel"]
|
|
832
|
+
: wrapTextHard(rows.length > 0 ? `${selectedIndex + 1}/${rows.length} ${detail}` : `FOCUS ${detail}`, panelTextWidth);
|
|
833
|
+
const deleteLines = deleteTarget
|
|
834
|
+
? [
|
|
835
|
+
`Delete session \"${truncate(deleteTarget.title, minimumWidth(panelTextWidth - 18))}\"?`,
|
|
836
|
+
truncate(deleteTarget.directory, panelTextWidth),
|
|
837
|
+
"[Enter/Y] confirm [Esc/N] cancel",
|
|
838
|
+
]
|
|
839
|
+
: [];
|
|
840
|
+
const killLines = killTarget
|
|
841
|
+
? [
|
|
842
|
+
`Kill running window for \"${truncate(killTarget.title, minimumWidth(panelTextWidth - 22))}\"?`,
|
|
843
|
+
truncate(killTarget.directory, panelTextWidth),
|
|
844
|
+
"[Enter/Y] confirm [Esc/N] cancel",
|
|
845
|
+
]
|
|
846
|
+
: [];
|
|
847
|
+
const projectHeader = sectionRule(rows.length ? `PROJECT MATRIX ${selectedIndex + 1}/${rows.length}` : "PROJECT MATRIX", sectionTextWidth);
|
|
848
|
+
const projectFooter = rows.length > 0 ? truncate(`FOCUS :: ${selectedIndex + 1}/${rows.length} :: ${detail}`, sectionTextWidth) : truncate(`FOCUS :: ${detail}`, sectionTextWidth);
|
|
849
|
+
const projectPanelStaticHeight = 2 + (loading && !snapshot ? 1 : 0) + (!rows.length && !loading ? 1 : 0);
|
|
850
|
+
const fixedHeight = (showBanner ? 3 + panelGap : 0) +
|
|
851
|
+
(3 + statusLines.length + statusMessageLines.length + panelGap) +
|
|
852
|
+
(deleteTarget ? 3 + deleteLines.length + panelGap : 0) +
|
|
853
|
+
(killTarget ? 3 + killLines.length + panelGap : 0) +
|
|
854
|
+
(showAddProjectModal ? 3 + addProjectLines.length + panelGap : 0) +
|
|
855
|
+
(showRenameSessionModal ? 3 + renameSessionLines.length + panelGap : 0) +
|
|
856
|
+
(showToolsPanel ? 3 + toolsLines.length + panelGap : 0) +
|
|
857
|
+
(mode !== "add-project" && mode !== "rename-session" ? 3 + promptPrimaryLines.length + promptDetail.length + panelGap : 0) +
|
|
858
|
+
projectPanelStaticHeight;
|
|
859
|
+
const visibleRowCount = Math.max(1, height - fixedHeight);
|
|
860
|
+
const visibleRows = useMemo(() => windowRows(rows, selectedIndex, visibleRowCount), [rows, selectedIndex, visibleRowCount]);
|
|
861
|
+
const firstVisibleIndex = useMemo(() => {
|
|
862
|
+
if (!visibleRows.length)
|
|
863
|
+
return 0;
|
|
864
|
+
return rows.findIndex((row) => row.key === visibleRows[0]?.key);
|
|
865
|
+
}, [rows, visibleRows]);
|
|
866
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, paddingTop: 1, children: [showBanner ? _jsx(Panel, { title: bannerTitle, width: panelTextWidth, borderColor: "cyan", titleColor: "cyanBright" }) : null, _jsx(Box, { marginTop: panelGap, children: _jsxs(Panel, { title: statusTitle, width: panelTextWidth, children: [statusLines.map((line, index) => (_jsx(Text, { color: "white", children: truncate(line, panelTextWidth) }, `status-line-${index}`))), statusMessageLines.map((line, index) => (_jsx(Text, { color: error ? "redBright" : busy ? "yellowBright" : "gray", children: truncate(line, panelTextWidth) }, `status-message-${index}`)))] }) }), deleteTarget ? (_jsx(Box, { marginTop: panelGap, children: _jsx(Panel, { title: "DELETE / ARM / CONFIRM", width: panelTextWidth, borderColor: "redBright", titleColor: "redBright", children: deleteLines.map((line, index) => (_jsx(Text, { color: index === 2 ? "yellowBright" : "white", children: truncate(line, panelTextWidth) }, `delete-${index}`))) }) })) : null, killTarget ? (_jsx(Box, { marginTop: panelGap, children: _jsx(Panel, { title: "KILL / WINDOW / CONFIRM", width: panelTextWidth, borderColor: "yellowBright", titleColor: "yellowBright", children: killLines.map((line, index) => (_jsx(Text, { color: index === 2 ? "yellowBright" : "white", children: truncate(line, panelTextWidth) }, `kill-${index}`))) }) })) : null, showAddProjectModal ? (_jsx(Box, { marginTop: panelGap, children: _jsx(Panel, { title: "ADD / PROJECT / FOLDER", width: panelTextWidth, borderColor: "greenBright", titleColor: "greenBright", children: addProjectLines.map((line, index) => (_jsx(Text, { color: index === 2 ? "yellowBright" : index === 0 ? "greenBright" : "white", children: line }, `add-project-${index}`))) }) })) : null, showRenameSessionModal ? (_jsx(Box, { marginTop: panelGap, children: _jsx(Panel, { title: "RENAME / SESSION / TITLE", width: panelTextWidth, borderColor: "magentaBright", titleColor: "magentaBright", children: renameSessionLines.map((line, index) => (_jsx(Text, { color: index === 2 ? "yellowBright" : index === 0 ? "magentaBright" : "white", children: line }, `rename-session-${index}`))) }) })) : null, _jsxs(Box, { marginTop: panelGap, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: truncate(projectHeader, sectionTextWidth) }), loading && !snapshot ? _jsx(Text, { color: "yellowBright", children: "SYNC :: workspace..." }) : null, !loading && snapshot?.directories.length === 0 ? _jsx(Text, { color: "gray", children: "No projects yet. Add a project folder to get started." }) : null, visibleRows.map((row, visibleIndex) => {
|
|
867
|
+
const index = firstVisibleIndex + visibleIndex;
|
|
868
|
+
const selected = index === selectedIndex;
|
|
869
|
+
const selectedForeground = selected ? "black" : undefined;
|
|
870
|
+
const selectedBackground = selected ? "cyan" : undefined;
|
|
871
|
+
const rowWidth = sectionTextWidth;
|
|
872
|
+
if (row.kind === "action") {
|
|
873
|
+
const suffix = "[ADD]";
|
|
874
|
+
const label = `${selected ? `[${selectGlyph}]` : "[ ]"} + ${row.label}`;
|
|
875
|
+
const availableWidth = minimumWidth(rowWidth - suffix.length - 1);
|
|
876
|
+
return (_jsxs(Box, { width: rowWidth, justifyContent: "space-between", children: [_jsx(Text, { color: selected ? selectedForeground : "greenBright", backgroundColor: selectedBackground, bold: true, children: truncate(label, availableWidth) }), _jsx(Text, { color: selected ? selectedForeground : "gray", backgroundColor: selectedBackground, children: suffix })] }, row.key));
|
|
877
|
+
}
|
|
878
|
+
if (row.kind === "directory") {
|
|
879
|
+
const expandedNow = mode === "search" ? true : expanded[row.record.directory] ?? row.record.pinned;
|
|
880
|
+
const activeDirectoryCount = row.record.activeSessionIDs.size;
|
|
881
|
+
const hasWorkingSession = row.record.sessions.some((session) => sessionIsWorking(session.status));
|
|
882
|
+
const hasCompleted = row.record.sessions.some((session) => sessionJustCompleted(session.status));
|
|
883
|
+
const marker = hasWorkingSession ? liveGlyph : activeDirectoryCount > 0 ? "|" : hasCompleted ? "*" : " ";
|
|
884
|
+
const suffix = activeDirectoryCount > 0 ? `${activeDirectoryCount}/${row.record.sessions.length}` : `${row.record.sessions.length}`;
|
|
885
|
+
const label = `${expandedNow ? "v" : ">"} ${row.record.label}`;
|
|
886
|
+
const availableWidth = minimumWidth(rowWidth - suffix.length - 5);
|
|
887
|
+
return (_jsxs(Box, { width: rowWidth, justifyContent: "space-between", children: [_jsx(Text, { color: selected ? selectedForeground : "cyanBright", backgroundColor: selectedBackground, bold: true, children: truncate(`${marker} ${label}`, availableWidth) }), _jsx(Text, { color: selected ? selectedForeground : hasCompleted ? "redBright" : "gray", backgroundColor: selectedBackground, children: suffix })] }, row.key));
|
|
888
|
+
}
|
|
889
|
+
const isActive = row.record.activeSessionIDs.has(row.session.id);
|
|
890
|
+
const isPreview = snapshot?.previewSessionID === row.session.id;
|
|
891
|
+
const isWorking = sessionIsWorking(row.session.status);
|
|
892
|
+
const completion = sessionJustCompleted(row.session.status);
|
|
893
|
+
const marker = isWorking ? liveGlyph : completion ? "*" : isPreview ? ">" : isActive ? "|" : " ";
|
|
894
|
+
const label = `|-- ${marker} ${row.session.title || "New session"}`;
|
|
895
|
+
const suffix = relativeTime(row.session.time.updated, now);
|
|
896
|
+
const availableWidth = minimumWidth(rowWidth - suffix.length - 1);
|
|
897
|
+
const color = selected
|
|
898
|
+
? selectedForeground
|
|
899
|
+
: completion
|
|
900
|
+
? "redBright"
|
|
901
|
+
: isWorking || isActive
|
|
902
|
+
? "greenBright"
|
|
903
|
+
: isPreview
|
|
904
|
+
? "magentaBright"
|
|
905
|
+
: "white";
|
|
906
|
+
return (_jsxs(Box, { width: rowWidth, justifyContent: "space-between", children: [_jsx(Text, { color: color, backgroundColor: selectedBackground, children: truncate(label, availableWidth) }), _jsx(Text, { color: selected ? selectedForeground : completion ? "redBright" : "gray", backgroundColor: selectedBackground, children: suffix })] }, row.key));
|
|
907
|
+
}), _jsx(Text, { color: "gray", children: truncate(projectFooter, sectionTextWidth) })] }), showToolsPanel ? (_jsx(Box, { marginTop: panelGap, children: _jsx(Panel, { title: "TOOLS / MODES", width: panelTextWidth, children: toolsLines.map((line, index) => (_jsx(Text, { color: "gray", children: line }, `tool-${index}`))) }) })) : null, mode !== "add-project" && mode !== "rename-session" ? (_jsxs(Box, { width: panelOuterWidth, marginTop: panelGap, borderStyle: "single", borderColor: mode === "browse" ? "gray" : "greenBright", flexDirection: "column", paddingX: 1, children: [promptPrimaryLines.map((line, index) => (_jsx(Text, { color: mode === "browse" ? "white" : "greenBright", children: line }, `prompt-primary-${index}`))), promptDetail.map((line, index) => (_jsx(Text, { color: "gray", children: line }, `prompt-${index}`)))] })) : null] }));
|
|
908
|
+
}
|