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/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
+ }
@@ -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;