ccdock 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdock",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A TUI sidebar to orchestrate VS Code windows and track Claude Code agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,5 +44,8 @@
44
44
  },
45
45
  "peerDependencies": {
46
46
  "typescript": "^5"
47
+ },
48
+ "dependencies": {
49
+ "ccdock": "^0.1.1"
47
50
  }
48
51
  }
package/src/main.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { handleHook } from "./agent/hook.ts";
3
3
  import { runSidebar } from "./sidebar.ts";
4
+ import pkg from "../package.json" with { type: "json" };
5
+
6
+ function printVersion(): void {
7
+ console.log(`ccdock ${pkg.version}`);
8
+ }
4
9
 
5
10
  function printHelp(): void {
6
11
  const help = `
@@ -12,6 +17,7 @@ USAGE:
12
17
  COMMANDS:
13
18
  start Start the sidebar TUI (default)
14
19
  hook Handle agent hook events (called by Claude Code hooks)
20
+ version Show version number
15
21
  help Show this help message
16
22
 
17
23
  HOOK USAGE:
@@ -50,6 +56,11 @@ async function main(): Promise<void> {
50
56
  case "hook":
51
57
  await handleHook(args[0] ?? "claude-code", args[1] ?? "unknown");
52
58
  break;
59
+ case "version":
60
+ case "--version":
61
+ case "-v":
62
+ printVersion();
63
+ break;
53
64
  case "help":
54
65
  case "--help":
55
66
  case "-h":
package/src/sidebar.ts CHANGED
@@ -357,6 +357,13 @@ async function handleDeleteConfirmInput(state: SidebarState, data: Buffer): Prom
357
357
  }
358
358
  }
359
359
 
360
+ function getManagedEditorTitles(sessions: SidebarState["sessions"]): string[] {
361
+ return sessions
362
+ .filter((s) => s.editorState !== "closed")
363
+ .map((s) => s.worktreePath.split("/").pop() ?? "")
364
+ .filter((t) => t.length > 0);
365
+ }
366
+
360
367
  export async function runSidebar(): Promise<void> {
361
368
  const config = loadConfig();
362
369
  const state = createInitialState();
@@ -374,8 +381,7 @@ export async function runSidebar(): Promise<void> {
374
381
  state.rows = process.stdout.rows ?? 24;
375
382
  state.cols = process.stdout.columns ?? 80;
376
383
  render(state);
377
- // Reposition all VS Code windows to fill remaining space
378
- await repositionAllEditors();
384
+ await repositionAllEditors(getManagedEditorTitles(state.sessions));
379
385
  });
380
386
 
381
387
  // Animation timer (200ms) — only repaint when there's something animating
@@ -516,7 +522,7 @@ export async function runSidebar(): Promise<void> {
516
522
  break;
517
523
 
518
524
  case "realign":
519
- await repositionAllEditors();
525
+ await repositionAllEditors(getManagedEditorTitles(state.sessions));
520
526
  break;
521
527
 
522
528
  case "mouse_click": {
package/src/tui/ansi.ts CHANGED
@@ -44,7 +44,8 @@ export const COLORS = {
44
44
  editorFocused: fg256(255), // bright white — focused editor
45
45
  editorOpen: fg256(117), // cyan — open but not focused
46
46
  editorClosed: fg256(240), // dark gray — closed
47
- borderFocused: fg256(255), // bright white border
47
+ borderFocused: fg256(255), // bright white border — VS Code window focused
48
+ borderSelected: fg256(75), // soft blue border — J/K cursor selection
48
49
  borderOpen: fg256(248), // light gray border
49
50
  borderClosed: fg256(235), // very dark border
50
51
  } as const;
package/src/tui/render.ts CHANGED
@@ -35,11 +35,9 @@ function padRight(str: string, len: number): string {
35
35
  return str + " ".repeat(len - visible);
36
36
  }
37
37
 
38
- function renderDeleteConfirm(deleteConfirm: DeleteConfirm, cols: number): string[] {
38
+ function renderDeleteConfirm(deleteConfirm: DeleteConfirm): string[] {
39
39
  const lines: string[] = [];
40
- const width = Math.max(cols - 6, 16);
41
40
 
42
- lines.push(` ${COLORS.border}${BOX.horizontal.repeat(width)}${RESET}`);
43
41
  lines.push(` ${BOLD}${COLORS.error} Delete session?${RESET}`);
44
42
  lines.push("");
45
43
 
@@ -58,7 +56,6 @@ function renderDeleteConfirm(deleteConfirm: DeleteConfirm, cols: number): string
58
56
 
59
57
  lines.push("");
60
58
  lines.push(` ${COLORS.muted}Enter: confirm | Esc: cancel${RESET}`);
61
- lines.push(` ${COLORS.border}${BOX.horizontal.repeat(width)}${RESET}`);
62
59
 
63
60
  return lines;
64
61
  }
@@ -74,8 +71,6 @@ function renderCard(
74
71
  ): string[] {
75
72
  const lines: string[] = [];
76
73
  const width = Math.max(cols - 2, 20);
77
- const bg = isSelected ? COLORS.bgSelected : "";
78
- const resetBg = isSelected ? RESET : "";
79
74
 
80
75
  // Colors based on editor state
81
76
  // Focused: white border, normal title
@@ -85,11 +80,14 @@ function renderCard(
85
80
  const isFocused = editorState === "focused";
86
81
  const isClosed = editorState === "closed";
87
82
  const isLaunching = editorState === "launching";
83
+ // Border color: VS Code focused > J/K selected > closed/open
88
84
  const borderColor = isFocused
89
85
  ? COLORS.borderFocused
90
- : isClosed
91
- ? COLORS.borderClosed
92
- : COLORS.border;
86
+ : isSelected
87
+ ? COLORS.borderSelected
88
+ : isClosed
89
+ ? COLORS.borderClosed
90
+ : COLORS.border;
93
91
  const titleColor = isClosed ? COLORS.editorClosed : COLORS.title;
94
92
  const detailColor = isClosed ? COLORS.editorClosed : COLORS.subtitle;
95
93
  const dimAll = isClosed ? DIM : "";
@@ -113,7 +111,7 @@ function renderCard(
113
111
  const titleText = `${titleColor}${icon} ${session.repoName}:${session.branch}${RESET}`;
114
112
  const title = `${sessionNum}${openDot}${titleText}`;
115
113
  const titleTruncated = truncate(title, width - 4);
116
- const titleLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(titleTruncated, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
114
+ const titleLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${padRight(titleTruncated, width - 4)}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
117
115
  lines.push(titleLine);
118
116
 
119
117
  if (!compact) {
@@ -121,13 +119,13 @@ function renderCard(
121
119
  const shortPath = shortenHome(session.worktreePath);
122
120
  const pathTruncated = truncate(shortPath, width - 4);
123
121
  const pathColor = editorState === "closed" ? COLORS.editorClosed : COLORS.subtitle;
124
- const pathLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${pathColor}${padRight(pathTruncated, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
122
+ const pathLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${pathColor}${padRight(pathTruncated, width - 4)}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
125
123
  lines.push(pathLine);
126
124
 
127
125
  // Agent status lines
128
126
  if (session.agents.length === 0) {
129
127
  const noAgent = `${DIM}no agents${RESET}`;
130
- const agentLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(noAgent, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
128
+ const agentLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${padRight(noAgent, width - 4)}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
131
129
  lines.push(agentLine);
132
130
  } else {
133
131
  for (const agent of session.agents) {
@@ -135,7 +133,7 @@ function renderCard(
135
133
  const sColor = statusColor(agent.status);
136
134
  const statusText = `${sColor}${sIcon} ${agent.status}${RESET}`;
137
135
  const agentInfo = `${statusText} ${detailColor}${agent.agentType}${RESET}`;
138
- const agentLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(agentInfo, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
136
+ const agentLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${padRight(agentInfo, width - 4)}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
139
137
  lines.push(agentLine);
140
138
 
141
139
  // Show latest tool activity
@@ -144,7 +142,7 @@ function renderCard(
144
142
  ? `${detailColor}${agent.toolName}${RESET} ${detailColor}${agent.toolDetail}${RESET}`
145
143
  : `${detailColor}${agent.toolName}${RESET}`;
146
144
  const detailTruncated = truncate(` ${detail}`, width - 4);
147
- const detailLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(detailTruncated, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
145
+ const detailLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${padRight(detailTruncated, width - 4)}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
148
146
  lines.push(detailLine);
149
147
  }
150
148
  }
@@ -157,15 +155,15 @@ function renderCard(
157
155
  .map((a) => `${statusColor(a.status)}${statusIcon(a.status, animFrame)}${RESET}`)
158
156
  .join(" ")
159
157
  : `${DIM}no agents${RESET}`;
160
- const compactLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(agentSummary, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
158
+ const compactLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${padRight(agentSummary, width - 4)}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
161
159
  lines.push(compactLine);
162
160
  }
163
161
 
164
162
  // Delete confirmation inline
165
163
  if (isSelected && deleteConfirm && deleteConfirm.sessionId === session.id) {
166
- const confirmLines = renderDeleteConfirm(deleteConfirm, cols);
164
+ const confirmLines = renderDeleteConfirm(deleteConfirm);
167
165
  for (const cl of confirmLines) {
168
- const confirmLine = `${borderColor}${BOX.vertical}${RESET} ${padRight(cl, width - 4)} ${borderColor}${BOX.vertical}${RESET}`;
166
+ const confirmLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${padRight(cl, width - 4)}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
169
167
  lines.push(confirmLine);
170
168
  }
171
169
  }
@@ -20,6 +20,16 @@ async function runOsascript(script: string): Promise<string> {
20
20
  return out.trim();
21
21
  }
22
22
 
23
+ async function runJXA(script: string): Promise<string> {
24
+ const proc = Bun.spawn(["osascript", "-l", "JavaScript", "-e", script], {
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ });
28
+ const out = await new Response(proc.stdout).text();
29
+ await proc.exited;
30
+ return out.trim();
31
+ }
32
+
23
33
  /**
24
34
  * Get the bounds of the sidebar terminal window (the frontmost Ghostty window).
25
35
  */
@@ -44,27 +54,63 @@ end tell
44
54
  }
45
55
 
46
56
  /**
47
- * Get the screen bounds that the sidebar is on.
48
- * Returns the full screen dimensions for the monitor containing the sidebar.
57
+ * Get the right edge (x coordinate) of the screen containing the sidebar window.
58
+ * Uses JXA + NSScreen with proper coordinate conversion for multi-monitor setups.
59
+ *
60
+ * Coordinate systems:
61
+ * NSScreen: origin at bottom-left of primary display, y increases upward.
62
+ * Screens to the left have negative x.
63
+ * AppleScript: origin at top-left of primary display, y increases downward.
64
+ * x=0 is the left edge of the leftmost monitor.
65
+ *
66
+ * Conversions (using primaryH = height of primary NSScreen):
67
+ * nsX = asX + nsMinX (nsMinX = min x across all NSScreens)
68
+ * nsY = primaryH - asCenterY (Y axis is flipped)
49
69
  */
50
- export async function getScreenBounds(): Promise<WindowBounds | null> {
70
+ async function getScreenRightEdge(sidebar: WindowBounds): Promise<number> {
71
+ // Fallback: typical MacBook Pro screen width from the sidebar's right edge
72
+ const fallback = 1512;
51
73
  try {
52
- const result = await runOsascript(`
53
- tell application "Finder"
54
- set db to bounds of window of desktop
55
- return "" & (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)
56
- end tell
74
+ // Use the window center for a more robust screen hit-test
75
+ const asCenterX = sidebar.x + sidebar.width / 2;
76
+ const asCenterY = sidebar.y + sidebar.height / 2;
77
+ const result = await runJXA(`
78
+ ObjC.import("AppKit");
79
+ var asCenterX = ${asCenterX};
80
+ var asCenterY = ${asCenterY};
81
+ var screens = $.NSScreen.screens;
82
+ var count = screens.count;
83
+ var primaryH = $.NSScreen.mainScreen.frame.size.height;
84
+ // NSScreen always places the primary display at x=0, same as AppleScript.
85
+ // So AS.x == NS.x (no x offset needed).
86
+ // Only Y is flipped: nsY = primaryH - asY.
87
+ var nsX = asCenterX;
88
+ var nsY = primaryH - asCenterY;
89
+ // Find screen whose bounds contain the converted center point
90
+ var found = false;
91
+ var result = 1512;
92
+ for (var i = 0; i < count; i++) {
93
+ var s = screens.objectAtIndex(i);
94
+ var left = s.frame.origin.x;
95
+ var bottom = s.frame.origin.y;
96
+ var width = s.frame.size.width;
97
+ var height = s.frame.size.height;
98
+ if (left <= nsX && nsX < left + width && bottom <= nsY && nsY < bottom + height) {
99
+ result = Math.round(left + width);
100
+ found = true;
101
+ break;
102
+ }
103
+ }
104
+ if (!found) {
105
+ var main = $.NSScreen.mainScreen;
106
+ result = Math.round(main.frame.origin.x + main.frame.size.width);
107
+ }
108
+ result;
57
109
  `);
58
- const parts = result.split(",").map((s) => Number.parseInt(s.trim(), 10));
59
- if (parts.length < 4) return null;
60
- return {
61
- x: parts[0]!,
62
- y: parts[1]!,
63
- width: parts[2]! - parts[0]!,
64
- height: parts[3]! - parts[1]!,
65
- };
110
+ const value = Number.parseInt(result, 10);
111
+ return Number.isNaN(value) || value <= 0 ? fallback : value;
66
112
  } catch {
67
- return null;
113
+ return fallback;
68
114
  }
69
115
  }
70
116
 
@@ -80,10 +126,8 @@ export async function positionEditorWindow(
80
126
  // Calculate editor position: right of sidebar, same height
81
127
  const editorX = sidebarBounds.x + sidebarBounds.width + 4; // 4px gap
82
128
  const editorY = sidebarBounds.y;
83
- // Editor width: fill remaining screen width (estimate with a large number)
84
- // We'll get the actual screen width for precision
85
- const screen = await getScreenBounds();
86
- const screenRight = screen ? screen.x + screen.width : 5120; // fallback
129
+ // Editor width: fill remaining space on the screen containing the sidebar
130
+ const screenRight = await getScreenRightEdge(sidebarBounds);
87
131
  const editorWidth = screenRight - editorX;
88
132
  const editorHeight = sidebarBounds.height;
89
133
 
@@ -292,27 +336,43 @@ end tell
292
336
  }
293
337
 
294
338
  /**
295
- * Reposition ALL VS Code windows to fill the area right of the sidebar.
339
+ * Reposition managed VS Code windows to fill the area right of the sidebar.
340
+ * Only windows whose title contains one of the managed titles are repositioned.
296
341
  * Called when the sidebar terminal is resized.
297
342
  */
298
- export async function repositionAllEditors(): Promise<void> {
299
- if (!(await isEditorRunning())) return;
300
- const [sidebar, screen] = await Promise.all([getSidebarBounds(), getScreenBounds()]);
301
- if (!sidebar) return;
343
+ export async function repositionAllEditors(managedTitles: string[]): Promise<void> {
344
+ if (managedTitles.length === 0) return;
345
+ const [running, sidebar] = await Promise.all([isEditorRunning(), getSidebarBounds()]);
346
+ if (!running || !sidebar) return;
302
347
 
303
348
  try {
304
349
  const editorX = sidebar.x + sidebar.width + 4;
305
350
  const editorY = sidebar.y;
306
- const screenRight = screen ? screen.x + screen.width : 5120;
351
+ const screenRight = await getScreenRightEdge(sidebar);
307
352
  const editorWidth = screenRight - editorX;
308
353
  const editorHeight = sidebar.height;
309
354
 
355
+ const titlesAppleScript = managedTitles
356
+ .map((t) => `"${t.replace(/"/g, '\\"')}"`)
357
+ .join(", ");
358
+
310
359
  await runOsascript(`
311
360
  tell application "System Events"
312
361
  tell process "Code"
362
+ set managedTitles to {${titlesAppleScript}}
313
363
  repeat with w in every window
314
- set position of w to {${editorX}, ${editorY}}
315
- set size of w to {${editorWidth}, ${editorHeight}}
364
+ set wName to name of w
365
+ set isManaged to false
366
+ repeat with t in managedTitles
367
+ if wName contains t then
368
+ set isManaged to true
369
+ exit repeat
370
+ end if
371
+ end repeat
372
+ if isManaged then
373
+ set position of w to {${editorX}, ${editorY}}
374
+ set size of w to {${editorWidth}, ${editorHeight}}
375
+ end if
316
376
  end repeat
317
377
  end tell
318
378
  end tell