ccdock 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 +21 -0
- package/README.md +195 -0
- package/package.json +48 -0
- package/src/agent/hook.ts +134 -0
- package/src/config/config.ts +58 -0
- package/src/main.ts +65 -0
- package/src/sidebar.ts +556 -0
- package/src/tui/ansi.ts +148 -0
- package/src/tui/input.ts +116 -0
- package/src/tui/render.ts +321 -0
- package/src/tui/wizard.ts +166 -0
- package/src/types.ts +86 -0
- package/src/workspace/editor.ts +32 -0
- package/src/workspace/state.ts +150 -0
- package/src/workspace/window.ts +323 -0
- package/src/worktree/manager.ts +120 -0
- package/src/worktree/scanner.ts +82 -0
package/src/sidebar.ts
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { loadConfig } from "./config/config.ts";
|
|
3
|
+
import { CURSOR_HIDE, CURSOR_SHOW } from "./tui/ansi.ts";
|
|
4
|
+
import {
|
|
5
|
+
repositionAllEditors,
|
|
6
|
+
closeAllEditors,
|
|
7
|
+
closeEditorWindow,
|
|
8
|
+
listEditorWindows,
|
|
9
|
+
getFocusedEditorWindow,
|
|
10
|
+
} from "./workspace/window.ts";
|
|
11
|
+
import { disableRawMode, enableRawMode, parseKey, parseKeyWizard } from "./tui/input.ts";
|
|
12
|
+
import { renderSidebar } from "./tui/render.ts";
|
|
13
|
+
import { renderWizard } from "./tui/wizard.ts";
|
|
14
|
+
import type { RepoInfo, SidebarState } from "./types.ts";
|
|
15
|
+
import { focusEditor, openEditor } from "./workspace/editor.ts";
|
|
16
|
+
import {
|
|
17
|
+
cleanStaleAgents,
|
|
18
|
+
deleteSession,
|
|
19
|
+
loadAgentStates,
|
|
20
|
+
loadSessions,
|
|
21
|
+
saveSession,
|
|
22
|
+
} from "./workspace/state.ts";
|
|
23
|
+
import { createWorktree, listWorktrees, removeWorktree } from "./worktree/manager.ts";
|
|
24
|
+
import { scanRepos } from "./worktree/scanner.ts";
|
|
25
|
+
|
|
26
|
+
function sessionNameFromBranch(branchName: string): string {
|
|
27
|
+
const parts = branchName.split("/");
|
|
28
|
+
return parts[parts.length - 1] ?? branchName;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createInitialState(): SidebarState {
|
|
32
|
+
const [rows, cols] = [process.stdout.rows ?? 24, process.stdout.columns ?? 80];
|
|
33
|
+
return {
|
|
34
|
+
sessions: [],
|
|
35
|
+
selectedIndex: 0,
|
|
36
|
+
rows,
|
|
37
|
+
cols,
|
|
38
|
+
animationFrame: 0,
|
|
39
|
+
compactMode: false,
|
|
40
|
+
showActivityLog: false,
|
|
41
|
+
cardRowRanges: [],
|
|
42
|
+
activityLog: [],
|
|
43
|
+
wizard: null,
|
|
44
|
+
deleteConfirm: null,
|
|
45
|
+
quitConfirm: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function refreshSessions(state: SidebarState): Promise<void> {
|
|
50
|
+
const sessions = loadSessions();
|
|
51
|
+
const agentStates = loadAgentStates();
|
|
52
|
+
|
|
53
|
+
// Match agent states to sessions by cwd prefix
|
|
54
|
+
for (const session of sessions) {
|
|
55
|
+
session.agents = agentStates.filter(
|
|
56
|
+
(a) => a.cwd.startsWith(session.worktreePath) || a.sessionId === session.id,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Detect editor window state for each session
|
|
61
|
+
const [editorWindows, focusedEditor] = await Promise.all([
|
|
62
|
+
listEditorWindows(),
|
|
63
|
+
getFocusedEditorWindow(),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
for (const session of sessions) {
|
|
67
|
+
const basename = session.worktreePath.split("/").pop() ?? "";
|
|
68
|
+
const hasWindow = editorWindows.some((w) => w.includes(basename));
|
|
69
|
+
if (hasWindow && focusedEditor.isFrontmost && focusedEditor.frontWindow.includes(basename)) {
|
|
70
|
+
session.editorState = "focused";
|
|
71
|
+
} else if (hasWindow) {
|
|
72
|
+
session.editorState = "open";
|
|
73
|
+
} else {
|
|
74
|
+
session.editorState = "closed";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
state.sessions = sessions;
|
|
79
|
+
|
|
80
|
+
// Keep selectedIndex in bounds
|
|
81
|
+
if (state.selectedIndex >= state.sessions.length) {
|
|
82
|
+
state.selectedIndex = Math.max(0, state.sessions.length - 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Update activity log from agents
|
|
86
|
+
for (const agent of agentStates) {
|
|
87
|
+
if (agent.toolName && agent.status === "running") {
|
|
88
|
+
const time = new Date(agent.updatedAt).toLocaleTimeString("en-US", {
|
|
89
|
+
hour12: false,
|
|
90
|
+
hour: "2-digit",
|
|
91
|
+
minute: "2-digit",
|
|
92
|
+
second: "2-digit",
|
|
93
|
+
});
|
|
94
|
+
const sessionIdx = sessions.findIndex(
|
|
95
|
+
(s) => agent.cwd.startsWith(s.worktreePath) || agent.sessionId === s.id,
|
|
96
|
+
);
|
|
97
|
+
// Only add if not duplicate of last entry
|
|
98
|
+
const lastEntry = state.activityLog[state.activityLog.length - 1];
|
|
99
|
+
const toolKey = `${agent.toolName}:${agent.toolDetail}`;
|
|
100
|
+
if (!lastEntry || lastEntry.time !== time || lastEntry.tool !== toolKey) {
|
|
101
|
+
state.activityLog.push({
|
|
102
|
+
time,
|
|
103
|
+
sessionId: agent.sessionId,
|
|
104
|
+
sessionIndex: sessionIdx,
|
|
105
|
+
agent: agent.agentType,
|
|
106
|
+
tool: agent.toolName,
|
|
107
|
+
toolDetail: agent.toolDetail,
|
|
108
|
+
});
|
|
109
|
+
// Keep log to last 50 entries
|
|
110
|
+
if (state.activityLog.length > 50) {
|
|
111
|
+
state.activityLog = state.activityLog.slice(-50);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let lastRendered = "";
|
|
119
|
+
|
|
120
|
+
function render(state: SidebarState): void {
|
|
121
|
+
const output = state.wizard ? renderWizard(state.wizard, state.cols) : renderSidebar(state);
|
|
122
|
+
if (output === lastRendered) return;
|
|
123
|
+
lastRendered = output;
|
|
124
|
+
process.stdout.write(output);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleWizardInput(
|
|
128
|
+
state: SidebarState,
|
|
129
|
+
data: Buffer,
|
|
130
|
+
config: { editor: string },
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const wizard = state.wizard;
|
|
133
|
+
if (!wizard) return;
|
|
134
|
+
|
|
135
|
+
const key = parseKeyWizard(data);
|
|
136
|
+
|
|
137
|
+
switch (wizard.step) {
|
|
138
|
+
case "select-repo": {
|
|
139
|
+
const filtered = wizard.repos.filter((r) =>
|
|
140
|
+
r.name.toLowerCase().includes(wizard.filter.toLowerCase()),
|
|
141
|
+
);
|
|
142
|
+
switch (key.type) {
|
|
143
|
+
case "up":
|
|
144
|
+
wizard.selectedIndex = Math.max(0, wizard.selectedIndex - 1);
|
|
145
|
+
break;
|
|
146
|
+
case "down":
|
|
147
|
+
wizard.selectedIndex = Math.min(filtered.length - 1, wizard.selectedIndex + 1);
|
|
148
|
+
break;
|
|
149
|
+
case "enter": {
|
|
150
|
+
const selected = filtered[wizard.selectedIndex];
|
|
151
|
+
if (selected) {
|
|
152
|
+
state.wizard = {
|
|
153
|
+
step: "select-mode",
|
|
154
|
+
repo: selected,
|
|
155
|
+
selectedIndex: 0,
|
|
156
|
+
repos: wizard.repos,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "escape":
|
|
162
|
+
state.wizard = null;
|
|
163
|
+
break;
|
|
164
|
+
case "backspace":
|
|
165
|
+
wizard.filter = wizard.filter.slice(0, -1);
|
|
166
|
+
wizard.selectedIndex = 0;
|
|
167
|
+
break;
|
|
168
|
+
case "char":
|
|
169
|
+
wizard.filter += key.char;
|
|
170
|
+
wizard.selectedIndex = 0;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "select-mode": {
|
|
176
|
+
switch (key.type) {
|
|
177
|
+
case "up":
|
|
178
|
+
wizard.selectedIndex = Math.max(0, wizard.selectedIndex - 1);
|
|
179
|
+
break;
|
|
180
|
+
case "down":
|
|
181
|
+
wizard.selectedIndex = Math.min(2, wizard.selectedIndex + 1);
|
|
182
|
+
break;
|
|
183
|
+
case "enter":
|
|
184
|
+
if (wizard.selectedIndex === 0) {
|
|
185
|
+
// Create new worktree (git wt)
|
|
186
|
+
state.wizard = {
|
|
187
|
+
step: "enter-branch",
|
|
188
|
+
repo: wizard.repo,
|
|
189
|
+
branchName: "",
|
|
190
|
+
repos: wizard.repos,
|
|
191
|
+
};
|
|
192
|
+
} else if (wizard.selectedIndex === 1) {
|
|
193
|
+
// Use existing worktree
|
|
194
|
+
const worktrees = await listWorktrees(wizard.repo.path);
|
|
195
|
+
state.wizard = {
|
|
196
|
+
step: "select-worktree",
|
|
197
|
+
repo: wizard.repo,
|
|
198
|
+
worktrees: worktrees,
|
|
199
|
+
selectedIndex: 0,
|
|
200
|
+
repos: wizard.repos,
|
|
201
|
+
};
|
|
202
|
+
} else if (wizard.selectedIndex === 2) {
|
|
203
|
+
// Open repository root
|
|
204
|
+
await createSessionFromPath(
|
|
205
|
+
wizard.repo,
|
|
206
|
+
wizard.repo.path,
|
|
207
|
+
wizard.repo.defaultBranch,
|
|
208
|
+
config.editor,
|
|
209
|
+
);
|
|
210
|
+
state.wizard = null;
|
|
211
|
+
refreshSessions(state);
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
case "escape":
|
|
215
|
+
state.wizard = {
|
|
216
|
+
step: "select-repo",
|
|
217
|
+
repos: wizard.repos,
|
|
218
|
+
selectedIndex: 0,
|
|
219
|
+
filter: "",
|
|
220
|
+
};
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "select-worktree": {
|
|
226
|
+
switch (key.type) {
|
|
227
|
+
case "up":
|
|
228
|
+
wizard.selectedIndex = Math.max(0, wizard.selectedIndex - 1);
|
|
229
|
+
break;
|
|
230
|
+
case "down":
|
|
231
|
+
wizard.selectedIndex = Math.min(wizard.worktrees.length - 1, wizard.selectedIndex + 1);
|
|
232
|
+
break;
|
|
233
|
+
case "enter": {
|
|
234
|
+
const selected = wizard.worktrees[wizard.selectedIndex];
|
|
235
|
+
if (selected) {
|
|
236
|
+
await createSessionFromPath(wizard.repo, selected.path, selected.branch, config.editor);
|
|
237
|
+
state.wizard = null;
|
|
238
|
+
refreshSessions(state);
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case "escape":
|
|
243
|
+
state.wizard = {
|
|
244
|
+
step: "select-mode",
|
|
245
|
+
repo: wizard.repo,
|
|
246
|
+
selectedIndex: 1,
|
|
247
|
+
repos: wizard.repos,
|
|
248
|
+
};
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case "enter-branch": {
|
|
254
|
+
switch (key.type) {
|
|
255
|
+
case "enter": {
|
|
256
|
+
if (wizard.branchName.trim()) {
|
|
257
|
+
await createSession(wizard.repo, wizard.branchName.trim(), config.editor);
|
|
258
|
+
state.wizard = null;
|
|
259
|
+
refreshSessions(state);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "escape":
|
|
264
|
+
state.wizard = {
|
|
265
|
+
step: "select-mode",
|
|
266
|
+
repo: wizard.repo,
|
|
267
|
+
selectedIndex: 0,
|
|
268
|
+
repos: wizard.repos,
|
|
269
|
+
};
|
|
270
|
+
break;
|
|
271
|
+
case "backspace":
|
|
272
|
+
wizard.branchName = wizard.branchName.slice(0, -1);
|
|
273
|
+
break;
|
|
274
|
+
case "char":
|
|
275
|
+
wizard.branchName += key.char;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function createSessionFromPath(
|
|
284
|
+
repo: RepoInfo,
|
|
285
|
+
worktreePath: string,
|
|
286
|
+
branch: string,
|
|
287
|
+
editor: string,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
const sessionName = sessionNameFromBranch(branch);
|
|
291
|
+
const session = {
|
|
292
|
+
id: randomUUID().slice(0, 8),
|
|
293
|
+
sessionName: `${repo.name}:${sessionName}`,
|
|
294
|
+
worktreePath,
|
|
295
|
+
branch,
|
|
296
|
+
repoName: repo.name,
|
|
297
|
+
agents: [],
|
|
298
|
+
editorState: "open" as const,
|
|
299
|
+
createdAt: Date.now(),
|
|
300
|
+
lastActiveAt: Date.now(),
|
|
301
|
+
};
|
|
302
|
+
saveSession(session);
|
|
303
|
+
await openEditor(worktreePath, editor);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
306
|
+
process.stderr.write(`\nError creating session: ${msg}\n`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function createSession(repo: RepoInfo, branchName: string, editor: string): Promise<void> {
|
|
311
|
+
try {
|
|
312
|
+
const worktreePath = await createWorktree(repo.path, branchName);
|
|
313
|
+
await createSessionFromPath(repo, worktreePath, branchName, editor);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
316
|
+
process.stderr.write(`\nError creating session: ${msg}\n`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function handleDeleteConfirmInput(state: SidebarState, data: Buffer): Promise<void> {
|
|
321
|
+
const confirm = state.deleteConfirm;
|
|
322
|
+
if (!confirm) return;
|
|
323
|
+
|
|
324
|
+
const key = parseKeyWizard(data);
|
|
325
|
+
|
|
326
|
+
switch (key.type) {
|
|
327
|
+
case "up":
|
|
328
|
+
confirm.selectedIndex = Math.max(0, confirm.selectedIndex - 1);
|
|
329
|
+
break;
|
|
330
|
+
case "down":
|
|
331
|
+
confirm.selectedIndex = Math.min(1, confirm.selectedIndex + 1);
|
|
332
|
+
break;
|
|
333
|
+
case "enter": {
|
|
334
|
+
// Close the VS Code window for this session
|
|
335
|
+
const basename = confirm.worktreePath.split("/").pop() ?? "";
|
|
336
|
+
await closeEditorWindow(basename);
|
|
337
|
+
|
|
338
|
+
// Delete session state
|
|
339
|
+
deleteSession(confirm.sessionId);
|
|
340
|
+
|
|
341
|
+
if (confirm.selectedIndex === 1) {
|
|
342
|
+
// Also remove worktree
|
|
343
|
+
try {
|
|
344
|
+
await removeWorktree(confirm.worktreePath);
|
|
345
|
+
} catch {
|
|
346
|
+
// Worktree might already be removed or busy
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
state.deleteConfirm = null;
|
|
351
|
+
refreshSessions(state);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case "escape":
|
|
355
|
+
state.deleteConfirm = null;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function runSidebar(): Promise<void> {
|
|
361
|
+
const config = loadConfig();
|
|
362
|
+
const state = createInitialState();
|
|
363
|
+
|
|
364
|
+
// Initial load
|
|
365
|
+
await refreshSessions(state);
|
|
366
|
+
cleanStaleAgents();
|
|
367
|
+
|
|
368
|
+
// Enable raw mode for keyboard input
|
|
369
|
+
enableRawMode();
|
|
370
|
+
process.stdout.write(CURSOR_HIDE);
|
|
371
|
+
|
|
372
|
+
// Handle terminal resize — also reposition VS Code windows
|
|
373
|
+
process.stdout.on("resize", async () => {
|
|
374
|
+
state.rows = process.stdout.rows ?? 24;
|
|
375
|
+
state.cols = process.stdout.columns ?? 80;
|
|
376
|
+
render(state);
|
|
377
|
+
// Reposition all VS Code windows to fill remaining space
|
|
378
|
+
await repositionAllEditors();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Animation timer (200ms) — only repaint when there's something animating
|
|
382
|
+
const animTimer = setInterval(() => {
|
|
383
|
+
state.animationFrame++;
|
|
384
|
+
const hasAnimated = state.sessions.some(
|
|
385
|
+
(s) =>
|
|
386
|
+
s.editorState === "launching" ||
|
|
387
|
+
s.agents.some((a) => a.status === "running" || a.status === "waiting"),
|
|
388
|
+
);
|
|
389
|
+
if (hasAnimated || state.wizard || state.deleteConfirm || state.quitConfirm) {
|
|
390
|
+
render(state);
|
|
391
|
+
}
|
|
392
|
+
}, 200);
|
|
393
|
+
|
|
394
|
+
// Refresh timer (2s) - reload state files, agent states, editor states
|
|
395
|
+
const refreshTimer = setInterval(async () => {
|
|
396
|
+
cleanStaleAgents();
|
|
397
|
+
await refreshSessions(state);
|
|
398
|
+
render(state);
|
|
399
|
+
}, 2000);
|
|
400
|
+
|
|
401
|
+
// Initial render
|
|
402
|
+
render(state);
|
|
403
|
+
|
|
404
|
+
// Handle keyboard input
|
|
405
|
+
const cleanup = () => {
|
|
406
|
+
clearInterval(animTimer);
|
|
407
|
+
clearInterval(refreshTimer);
|
|
408
|
+
process.stdout.write(CURSOR_SHOW);
|
|
409
|
+
disableRawMode();
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
process.stdin.on("data", async (data: Buffer) => {
|
|
413
|
+
// Quit confirmation mode
|
|
414
|
+
if (state.quitConfirm) {
|
|
415
|
+
const key = parseKeyWizard(data);
|
|
416
|
+
switch (key.type) {
|
|
417
|
+
case "up":
|
|
418
|
+
state.quitConfirm.selectedIndex = Math.max(0, state.quitConfirm.selectedIndex - 1);
|
|
419
|
+
break;
|
|
420
|
+
case "down":
|
|
421
|
+
state.quitConfirm.selectedIndex = Math.min(1, state.quitConfirm.selectedIndex + 1);
|
|
422
|
+
break;
|
|
423
|
+
case "enter":
|
|
424
|
+
if (state.quitConfirm.selectedIndex === 1) {
|
|
425
|
+
// Close VS Code windows for all managed sessions
|
|
426
|
+
for (const session of state.sessions) {
|
|
427
|
+
const basename = session.worktreePath.split("/").pop() ?? "";
|
|
428
|
+
await closeEditorWindow(basename);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
cleanup();
|
|
432
|
+
process.exit(0);
|
|
433
|
+
break;
|
|
434
|
+
case "escape":
|
|
435
|
+
state.quitConfirm = null;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
render(state);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Delete confirmation mode
|
|
443
|
+
if (state.deleteConfirm) {
|
|
444
|
+
await handleDeleteConfirmInput(state, data);
|
|
445
|
+
render(state);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Wizard mode
|
|
450
|
+
if (state.wizard) {
|
|
451
|
+
await handleWizardInput(state, data, { editor: config.editor });
|
|
452
|
+
render(state);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const key = parseKey(data);
|
|
457
|
+
|
|
458
|
+
switch (key.type) {
|
|
459
|
+
case "quit":
|
|
460
|
+
state.quitConfirm = { selectedIndex: 0 };
|
|
461
|
+
break;
|
|
462
|
+
|
|
463
|
+
case "up":
|
|
464
|
+
state.selectedIndex = Math.max(0, state.selectedIndex - 1);
|
|
465
|
+
break;
|
|
466
|
+
|
|
467
|
+
case "down":
|
|
468
|
+
state.selectedIndex = Math.min(state.sessions.length - 1, state.selectedIndex + 1);
|
|
469
|
+
break;
|
|
470
|
+
|
|
471
|
+
case "enter":
|
|
472
|
+
case "tab": {
|
|
473
|
+
const session = state.sessions[state.selectedIndex];
|
|
474
|
+
if (session) {
|
|
475
|
+
const focused = await focusEditor(session.worktreePath, config.editor);
|
|
476
|
+
if (!focused) {
|
|
477
|
+
// Show launching state while VS Code opens
|
|
478
|
+
session.editorState = "launching";
|
|
479
|
+
render(state);
|
|
480
|
+
await openEditor(session.worktreePath, config.editor);
|
|
481
|
+
session.editorState = "open";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
case "new": {
|
|
488
|
+
const repos = await scanRepos(config.workspace_dirs);
|
|
489
|
+
state.wizard = {
|
|
490
|
+
step: "select-repo",
|
|
491
|
+
repos,
|
|
492
|
+
selectedIndex: 0,
|
|
493
|
+
filter: "",
|
|
494
|
+
};
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
case "delete": {
|
|
499
|
+
const session = state.sessions[state.selectedIndex];
|
|
500
|
+
if (session) {
|
|
501
|
+
state.deleteConfirm = {
|
|
502
|
+
sessionId: session.id,
|
|
503
|
+
worktreePath: session.worktreePath,
|
|
504
|
+
selectedIndex: 0,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
case "compact":
|
|
511
|
+
state.compactMode = !state.compactMode;
|
|
512
|
+
break;
|
|
513
|
+
|
|
514
|
+
case "log":
|
|
515
|
+
state.showActivityLog = !state.showActivityLog;
|
|
516
|
+
break;
|
|
517
|
+
|
|
518
|
+
case "realign":
|
|
519
|
+
await repositionAllEditors();
|
|
520
|
+
break;
|
|
521
|
+
|
|
522
|
+
case "mouse_click": {
|
|
523
|
+
const clicked = state.cardRowRanges.find(
|
|
524
|
+
(r) => key.row >= r.startRow && key.row <= r.endRow,
|
|
525
|
+
);
|
|
526
|
+
if (clicked) {
|
|
527
|
+
state.selectedIndex = clicked.sessionIndex;
|
|
528
|
+
const session = state.sessions[clicked.sessionIndex];
|
|
529
|
+
if (session) {
|
|
530
|
+
const focused = await focusEditor(session.worktreePath, config.editor);
|
|
531
|
+
if (!focused) {
|
|
532
|
+
session.editorState = "launching";
|
|
533
|
+
render(state);
|
|
534
|
+
await openEditor(session.worktreePath, config.editor);
|
|
535
|
+
session.editorState = "open";
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
render(state);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Handle process signals
|
|
547
|
+
process.on("SIGINT", () => {
|
|
548
|
+
cleanup();
|
|
549
|
+
process.exit(0);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
process.on("SIGTERM", () => {
|
|
553
|
+
cleanup();
|
|
554
|
+
process.exit(0);
|
|
555
|
+
});
|
|
556
|
+
}
|
package/src/tui/ansi.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ANSI escape code utilities for 256-color terminal rendering
|
|
2
|
+
|
|
3
|
+
export const ESC = "\x1b";
|
|
4
|
+
export const CSI = `${ESC}[`;
|
|
5
|
+
|
|
6
|
+
// Screen control
|
|
7
|
+
export const CLEAR_SCREEN = `${CSI}2J`;
|
|
8
|
+
export const CURSOR_HOME = `${CSI}H`;
|
|
9
|
+
export const CURSOR_HIDE = `${CSI}?25l`;
|
|
10
|
+
export const CURSOR_SHOW = `${CSI}?25h`;
|
|
11
|
+
// Text attributes
|
|
12
|
+
export const RESET = `${CSI}0m`;
|
|
13
|
+
export const BOLD = `${CSI}1m`;
|
|
14
|
+
export const DIM = `${CSI}2m`;
|
|
15
|
+
|
|
16
|
+
// Color helpers (256-color)
|
|
17
|
+
export function fg256(code: number): string {
|
|
18
|
+
return `${CSI}38;5;${code}m`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bg256(code: number): string {
|
|
22
|
+
return `${CSI}48;5;${code}m`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Theme colors
|
|
26
|
+
export const COLORS = {
|
|
27
|
+
running: fg256(82), // bright green
|
|
28
|
+
waiting: fg256(220), // yellow/orange
|
|
29
|
+
idle: fg256(73), // teal
|
|
30
|
+
error: fg256(196), // red
|
|
31
|
+
unknown: fg256(245), // gray
|
|
32
|
+
|
|
33
|
+
title: fg256(255), // white
|
|
34
|
+
subtitle: fg256(250), // light gray
|
|
35
|
+
muted: fg256(240), // dark gray
|
|
36
|
+
border: fg256(238), // subtle border
|
|
37
|
+
highlight: fg256(117), // light blue
|
|
38
|
+
accent: fg256(213), // pink/magenta
|
|
39
|
+
|
|
40
|
+
bgSelected: bg256(236), // dark highlight
|
|
41
|
+
bgHeader: bg256(235), // header background
|
|
42
|
+
|
|
43
|
+
// Editor state colors
|
|
44
|
+
editorFocused: fg256(255), // bright white — focused editor
|
|
45
|
+
editorOpen: fg256(117), // cyan — open but not focused
|
|
46
|
+
editorClosed: fg256(240), // dark gray — closed
|
|
47
|
+
borderFocused: fg256(255), // bright white border
|
|
48
|
+
borderOpen: fg256(248), // light gray border
|
|
49
|
+
borderClosed: fg256(235), // very dark border
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
// Status colors
|
|
53
|
+
export function statusColor(status: string): string {
|
|
54
|
+
switch (status) {
|
|
55
|
+
case "running":
|
|
56
|
+
return COLORS.running;
|
|
57
|
+
case "waiting":
|
|
58
|
+
return COLORS.waiting;
|
|
59
|
+
case "idle":
|
|
60
|
+
return COLORS.idle;
|
|
61
|
+
case "error":
|
|
62
|
+
return COLORS.error;
|
|
63
|
+
default:
|
|
64
|
+
return COLORS.unknown;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Status icons
|
|
69
|
+
export function statusIcon(status: string, frame: number): string {
|
|
70
|
+
const pulse = frame % 4 < 2;
|
|
71
|
+
switch (status) {
|
|
72
|
+
case "running":
|
|
73
|
+
return "\u25cf"; // ●
|
|
74
|
+
case "waiting":
|
|
75
|
+
return pulse ? "\u25cf" : "\u25cb";
|
|
76
|
+
case "idle":
|
|
77
|
+
return "\u25cb"; // ○
|
|
78
|
+
case "error":
|
|
79
|
+
return "\u25cf"; // ●
|
|
80
|
+
default:
|
|
81
|
+
return "\u25cb";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Utility functions
|
|
86
|
+
export function stripAnsi(str: string): string {
|
|
87
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes contain control characters
|
|
88
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function visibleLength(str: string): number {
|
|
92
|
+
return stripAnsi(str).length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function truncate(str: string, maxLen: number): string {
|
|
96
|
+
const visible = stripAnsi(str);
|
|
97
|
+
if (visible.length <= maxLen) return str;
|
|
98
|
+
|
|
99
|
+
// ANSI-aware truncation: walk through the string preserving escape sequences
|
|
100
|
+
let visCount = 0;
|
|
101
|
+
let result = "";
|
|
102
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes contain control characters
|
|
103
|
+
const re = /(\x1b\[[0-9;]*m)|(.)/g;
|
|
104
|
+
let m = re.exec(str);
|
|
105
|
+
while (m !== null) {
|
|
106
|
+
if (m[1]) {
|
|
107
|
+
// ANSI escape sequence — always include
|
|
108
|
+
result += m[1];
|
|
109
|
+
} else if (m[2]) {
|
|
110
|
+
if (visCount >= maxLen - 1) {
|
|
111
|
+
result += `${RESET}\u2026`;
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
result += m[2];
|
|
115
|
+
visCount++;
|
|
116
|
+
}
|
|
117
|
+
m = re.exec(str);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function shortenHome(path: string): string {
|
|
123
|
+
const home = process.env.HOME ?? "";
|
|
124
|
+
if (home && path.startsWith(home)) {
|
|
125
|
+
return `~${path.slice(home.length)}`;
|
|
126
|
+
}
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function moveCursor(row: number, col: number): string {
|
|
131
|
+
return `${CSI}${row};${col}H`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function clearLine(): string {
|
|
135
|
+
return `${CSI}2K`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Box-drawing characters
|
|
139
|
+
export const BOX = {
|
|
140
|
+
topLeft: "\u256d",
|
|
141
|
+
topRight: "\u256e",
|
|
142
|
+
bottomLeft: "\u2570",
|
|
143
|
+
bottomRight: "\u256f",
|
|
144
|
+
horizontal: "\u2500",
|
|
145
|
+
vertical: "\u2502",
|
|
146
|
+
teeRight: "\u251c",
|
|
147
|
+
teeLeft: "\u2524",
|
|
148
|
+
} as const;
|