cc-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.
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Syncs Claude Code's TodoWrite output to the sidebar
4
+ * Called via PostToolUse hook whenever Claude updates its todo list
5
+ *
6
+ * Two functions:
7
+ * 1. Sync Claude's todos to sidebar display (claude-todos.json)
8
+ * 2. Auto-complete sidebar queue items when Claude marks related work done
9
+ *
10
+ * Receives JSON via stdin with structure:
11
+ * {
12
+ * "tool_name": "TodoWrite",
13
+ * "tool_input": { "todos": [...] },
14
+ * "tool_result": "..."
15
+ * }
16
+ */
17
+
18
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from "fs";
19
+ import { join } from "path";
20
+ import { homedir } from "os";
21
+ import { createHash } from "crypto";
22
+
23
+ const SIDEBAR_DIR = join(homedir(), ".claude-sidebar");
24
+
25
+ interface TodoItem {
26
+ content: string;
27
+ status: "pending" | "in_progress" | "completed";
28
+ }
29
+
30
+ interface HookPayload {
31
+ tool_name: string;
32
+ tool_input: {
33
+ todos: TodoItem[];
34
+ };
35
+ }
36
+
37
+ interface SidebarTask {
38
+ id: string;
39
+ content: string;
40
+ createdAt: string;
41
+ }
42
+
43
+ interface DoneTask {
44
+ id: string;
45
+ content: string;
46
+ completedAt: string;
47
+ }
48
+
49
+ // Get project-specific directory using same hash as sidebar
50
+ function getProjectDir(): string {
51
+ const cwd = process.cwd();
52
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
53
+ return join(SIDEBAR_DIR, "projects", hash);
54
+ }
55
+
56
+ // Normalize text for comparison: lowercase, keep only alphanumeric, collapse whitespace
57
+ function normalizeText(s: string): string {
58
+ return s.toLowerCase().replace(/[^a-z0-9]/g, ' ').replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ // Extract significant words (length > 2) from text
62
+ function extractWords(s: string): Set<string> {
63
+ return new Set(normalizeText(s).split(' ').filter(w => w.length > 2));
64
+ }
65
+
66
+ // Check if strings are similar based on word overlap (50% threshold)
67
+ function isSimilar(a: string, b: string): boolean {
68
+ const wordsA = extractWords(a);
69
+ const wordsB = extractWords(b);
70
+
71
+ if (wordsA.size === 0 || wordsB.size === 0) return false;
72
+
73
+ let overlap = 0;
74
+ for (const word of wordsA) {
75
+ if (wordsB.has(word)) overlap++;
76
+ }
77
+
78
+ const minSize = Math.min(wordsA.size, wordsB.size);
79
+ return overlap >= minSize * 0.5;
80
+ }
81
+
82
+ // Check completed Claude todos against sidebar queue and move matches to Done
83
+ function autoCompleteSidebarTasks(completedTodos: TodoItem[]): void {
84
+ const projectDir = getProjectDir();
85
+ const tasksPath = join(projectDir, "tasks.json");
86
+ const donePath = join(projectDir, "done.json");
87
+
88
+ if (!existsSync(tasksPath)) return;
89
+
90
+ try {
91
+ const tasks: SidebarTask[] = JSON.parse(readFileSync(tasksPath, "utf-8"));
92
+ const done: DoneTask[] = existsSync(donePath)
93
+ ? JSON.parse(readFileSync(donePath, "utf-8"))
94
+ : [];
95
+
96
+ const now = new Date().toISOString();
97
+ const remaining: SidebarTask[] = [];
98
+ const newDone: DoneTask[] = [];
99
+
100
+ for (const task of tasks) {
101
+ const matchesCompleted = completedTodos.some(todo => isSimilar(todo.content, task.content));
102
+ if (matchesCompleted) {
103
+ newDone.push({ id: task.id, content: task.content, completedAt: now });
104
+ } else {
105
+ remaining.push(task);
106
+ }
107
+ }
108
+
109
+ if (newDone.length > 0) {
110
+ const updatedDone = [...newDone, ...done].slice(0, 10);
111
+ writeFileSync(tasksPath, JSON.stringify(remaining, null, 2));
112
+ writeFileSync(donePath, JSON.stringify(updatedDone, null, 2));
113
+ }
114
+ } catch {
115
+ // Silently fail - don't interrupt Claude Code
116
+ }
117
+ }
118
+
119
+ async function main() {
120
+ // Read JSON from stdin
121
+ const chunks: Buffer[] = [];
122
+ for await (const chunk of process.stdin) {
123
+ chunks.push(chunk);
124
+ }
125
+ const input = Buffer.concat(chunks).toString("utf-8");
126
+
127
+ if (!input.trim()) {
128
+ process.exit(0);
129
+ }
130
+
131
+ try {
132
+ const payload: HookPayload = JSON.parse(input);
133
+
134
+ if (payload.tool_name === "TodoWrite" && payload.tool_input?.todos) {
135
+ // Ensure directory exists
136
+ mkdirSync(SIDEBAR_DIR, { recursive: true });
137
+
138
+ // Write todos to file for sidebar to poll
139
+ const todosPath = join(SIDEBAR_DIR, "claude-todos.json");
140
+ writeFileSync(todosPath, JSON.stringify({
141
+ todos: payload.tool_input.todos,
142
+ updatedAt: new Date().toISOString()
143
+ }, null, 2));
144
+
145
+ // Check for completed todos and auto-complete matching sidebar tasks
146
+ const completedTodos = payload.tool_input.todos.filter(t => t.status === "completed");
147
+ if (completedTodos.length > 0) {
148
+ autoCompleteSidebarTasks(completedTodos);
149
+ }
150
+ }
151
+ } catch (err) {
152
+ // Silently fail - don't interrupt Claude Code
153
+ console.error("sync-todos error:", err);
154
+ }
155
+ }
156
+
157
+ main();
@@ -0,0 +1,187 @@
1
+ /**
2
+ * iTerm2 integration for spawning sidebar in split pane
3
+ * Uses AppleScript to create native iTerm2 splits (no tmux needed)
4
+ */
5
+
6
+ import { $ } from "bun";
7
+
8
+ /**
9
+ * Check if we're running in iTerm2
10
+ */
11
+ export function isInITerm(): boolean {
12
+ return process.env.TERM_PROGRAM === "iTerm.app";
13
+ }
14
+
15
+ /**
16
+ * Get the current session's unique ID for reference
17
+ */
18
+ export async function getCurrentSessionId(): Promise<string | null> {
19
+ try {
20
+ const script = `
21
+ tell application "iTerm2"
22
+ tell current session of current tab of current window
23
+ return unique id
24
+ end tell
25
+ end tell
26
+ `;
27
+ const result = await $`osascript -e ${script}`.text();
28
+ return result.trim();
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Create an iTerm2 vertical split (sidebar on right) and run command
36
+ * Returns the unique ID of the new session
37
+ */
38
+ export async function spawnITermSidebarPane(command: string): Promise<string | null> {
39
+ try {
40
+ const escapedCommand = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
41
+ // Split first, then send command to the new session
42
+ const script = `
43
+ tell application "iTerm2"
44
+ tell current session of current tab of current window
45
+ set newSession to (split vertically with default profile)
46
+ end tell
47
+ tell newSession
48
+ write text "${escapedCommand}"
49
+ return unique id
50
+ end tell
51
+ end tell
52
+ `;
53
+ const result = await $`osascript -e ${script}`.text();
54
+ return result.trim();
55
+ } catch (err) {
56
+ console.error("Failed to spawn iTerm sidebar:", err);
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Send text to a specific iTerm2 session by index (1-based)
63
+ * Session 1 is typically the first/left pane, session 2 is the right pane
64
+ */
65
+ export async function sendToSession(sessionIndex: number, text: string): Promise<boolean> {
66
+ try {
67
+ const escapedText = text.replace(/"/g, '\\"');
68
+ const script = `
69
+ tell application "iTerm2"
70
+ tell session ${sessionIndex} of current tab of current window
71
+ write text "${escapedText}"
72
+ end tell
73
+ end tell
74
+ `;
75
+ await $`osascript -e ${script}`.quiet();
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Send text to the first session (Claude Code pane)
84
+ * Assumes sidebar is in session 2 and Claude is in session 1
85
+ */
86
+ export async function sendToClaudePane(text: string): Promise<boolean> {
87
+ return sendToSession(1, text);
88
+ }
89
+
90
+ /**
91
+ * Get the number of sessions in the current tab
92
+ */
93
+ export async function getSessionCount(): Promise<number> {
94
+ try {
95
+ const script = `
96
+ tell application "iTerm2"
97
+ return count of sessions of current tab of current window
98
+ end tell
99
+ `;
100
+ const result = await $`osascript -e ${script}`.text();
101
+ return parseInt(result.trim(), 10);
102
+ } catch {
103
+ return 0;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Close a session by index
109
+ */
110
+ export async function closeSession(sessionIndex: number): Promise<boolean> {
111
+ try {
112
+ const script = `
113
+ tell application "iTerm2"
114
+ tell session ${sessionIndex} of current tab of current window
115
+ close
116
+ end tell
117
+ end tell
118
+ `;
119
+ await $`osascript -e ${script}`.quiet();
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Focus a specific session by index
128
+ */
129
+ export async function focusSession(sessionIndex: number): Promise<boolean> {
130
+ try {
131
+ const script = `
132
+ tell application "iTerm2"
133
+ tell session ${sessionIndex} of current tab of current window
134
+ select
135
+ end tell
136
+ end tell
137
+ `;
138
+ await $`osascript -e ${script}`.quiet();
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Capture the last N lines from Claude's pane (session 1)
147
+ */
148
+ export async function captureClaudePane(lines: number = 5): Promise<string | null> {
149
+ try {
150
+ const script = `
151
+ tell application "iTerm2"
152
+ tell session 1 of current tab of current window
153
+ return contents
154
+ end tell
155
+ end tell
156
+ `;
157
+ const result = await $`osascript -e ${script}`.text();
158
+ // Get last N lines
159
+ const allLines = result.trim().split("\n");
160
+ return allLines.slice(-lines).join("\n");
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Check if Claude Code is at a prompt (waiting for input)
168
+ */
169
+ export async function isClaudeAtPrompt(): Promise<boolean> {
170
+ const { checkOutputForPrompt } = await import("./prompt");
171
+ const output = await captureClaudePane(10);
172
+ if (!output) return false;
173
+ return checkOutputForPrompt(output);
174
+ }
175
+
176
+ /**
177
+ * Get environment info for debugging
178
+ */
179
+ export function getITermEnvInfo(): {
180
+ inITerm: boolean;
181
+ termProgram: string;
182
+ } {
183
+ return {
184
+ inITerm: isInITerm(),
185
+ termProgram: process.env.TERM_PROGRAM || "unknown",
186
+ };
187
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared prompt detection logic for Claude Code terminals
3
+ * Used by both iTerm2 and tmux integrations
4
+ */
5
+
6
+ /**
7
+ * Patterns that indicate Claude Code is waiting for input
8
+ */
9
+ const PROMPT_PATTERNS = [
10
+ /^❯\s*$/, // Just the prompt character with optional whitespace
11
+ /^>\s*$/, // Fallback prompt character
12
+ ];
13
+
14
+ /**
15
+ * Check if terminal output indicates Claude is at a prompt
16
+ * Analyzes the last few lines of output for prompt patterns
17
+ */
18
+ export function checkOutputForPrompt(output: string): boolean {
19
+ const lines = output.trim().split("\n");
20
+ const linesToCheck = 5;
21
+
22
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - linesToCheck); i--) {
23
+ const line = lines[i] || "";
24
+ if (PROMPT_PATTERNS.some((pattern) => pattern.test(line))) {
25
+ return true;
26
+ }
27
+ }
28
+
29
+ return false;
30
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * tmux integration for spawning sidebar in split pane
3
+ * Based on patterns from claude-canvas
4
+ */
5
+
6
+ import { $ } from "bun";
7
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
8
+
9
+ const PANE_FILE = "/tmp/claude-sidebar-pane-id";
10
+
11
+ /**
12
+ * Check if we're running inside tmux
13
+ */
14
+ export function isInTmux(): boolean {
15
+ return !!process.env.TMUX;
16
+ }
17
+
18
+ /**
19
+ * Get stored pane ID from file
20
+ */
21
+ function getStoredPaneId(): string | null {
22
+ try {
23
+ if (existsSync(PANE_FILE)) {
24
+ return readFileSync(PANE_FILE, "utf-8").trim();
25
+ }
26
+ } catch {
27
+ // Ignore
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Store pane ID to file
34
+ */
35
+ function storePaneId(paneId: string): void {
36
+ writeFileSync(PANE_FILE, paneId);
37
+ }
38
+
39
+ /**
40
+ * Clear stored pane ID
41
+ */
42
+ function clearStoredPaneId(): void {
43
+ try {
44
+ if (existsSync(PANE_FILE)) {
45
+ unlinkSync(PANE_FILE);
46
+ }
47
+ } catch {
48
+ // Ignore
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Check if a pane ID is still valid
54
+ */
55
+ async function isPaneValid(paneId: string): Promise<boolean> {
56
+ try {
57
+ const result = await $`tmux display-message -t ${paneId} -p "#{pane_id}"`.text();
58
+ return result.trim() === paneId;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Create a new tmux pane and run command in it
66
+ */
67
+ async function createNewPane(command: string): Promise<string> {
68
+ // Save current history-limit so we can restore it after creating the pane
69
+ // (history-limit is a session option, not per-pane, so we must set it before creating)
70
+ let originalLimit = "2000";
71
+ try {
72
+ originalLimit = (await $`tmux show-options -gv history-limit`.text()).trim();
73
+ } catch {}
74
+
75
+ // Set minimal history-limit (1 = minimum, 0 = unlimited!) before creating sidebar pane
76
+ await $`tmux set-option -g history-limit 1`.quiet();
77
+
78
+ // Create horizontal split with fixed 50 character width for sidebar
79
+ // -h = horizontal split (side by side)
80
+ // -l 50 = new pane gets 50 columns (fixed width)
81
+ // -d = don't switch focus to new pane
82
+ // -P -F "#{pane_id}" = print the new pane ID
83
+ const result = await $`tmux split-window -h -l 50 -d -P -F "#{pane_id}"`.text();
84
+ const paneId = result.trim();
85
+
86
+ // Restore original history-limit for future panes
87
+ await $`tmux set-option -g history-limit ${originalLimit}`.quiet();
88
+
89
+ // Store the pane ID for future reuse
90
+ storePaneId(paneId);
91
+
92
+ // Send the command to the new pane
93
+ await $`tmux send-keys -t ${paneId} ${command} Enter`.quiet();
94
+
95
+ return paneId;
96
+ }
97
+
98
+ /**
99
+ * Reuse an existing pane by sending a new command to it
100
+ */
101
+ async function reuseExistingPane(paneId: string, command: string): Promise<void> {
102
+ // Send Ctrl+C to stop any running process
103
+ await $`tmux send-keys -t ${paneId} C-c`.quiet();
104
+
105
+ // Wait a moment for the process to stop
106
+ await new Promise((resolve) => setTimeout(resolve, 150));
107
+
108
+ // Clear and run the new command
109
+ await $`tmux send-keys -t ${paneId} "clear && ${command}" Enter`.quiet();
110
+ }
111
+
112
+ /**
113
+ * Spawn sidebar in a tmux pane (creates new or reuses existing)
114
+ */
115
+ export async function spawnSidebarPane(command: string): Promise<string> {
116
+ if (!isInTmux()) {
117
+ throw new Error("Sidebar requires tmux. Please run inside a tmux session.");
118
+ }
119
+
120
+ // Check for existing pane
121
+ const existingPaneId = getStoredPaneId();
122
+
123
+ if (existingPaneId && (await isPaneValid(existingPaneId))) {
124
+ console.log(`Reusing existing sidebar pane: ${existingPaneId}`);
125
+ await reuseExistingPane(existingPaneId, command);
126
+ return existingPaneId;
127
+ }
128
+
129
+ // Clear stale pane reference if any
130
+ if (existingPaneId) {
131
+ console.log("Clearing stale pane reference...");
132
+ clearStoredPaneId();
133
+ }
134
+
135
+ // Create new pane
136
+ console.log("Creating new sidebar pane...");
137
+ const paneId = await createNewPane(command);
138
+ console.log(`Created sidebar pane: ${paneId}`);
139
+
140
+ return paneId;
141
+ }
142
+
143
+ /**
144
+ * Close the sidebar pane
145
+ */
146
+ export async function closeSidebarPane(): Promise<void> {
147
+ const paneId = getStoredPaneId();
148
+ if (paneId && (await isPaneValid(paneId))) {
149
+ try {
150
+ await $`tmux kill-pane -t ${paneId}`.quiet();
151
+ } catch {
152
+ // Pane might already be gone
153
+ }
154
+ }
155
+ clearStoredPaneId();
156
+ }
157
+
158
+ /**
159
+ * Get environment info for debugging
160
+ */
161
+ export function getEnvInfo(): {
162
+ inTmux: boolean;
163
+ term: string;
164
+ shell: string;
165
+ storedPaneId: string | null;
166
+ } {
167
+ return {
168
+ inTmux: isInTmux(),
169
+ term: process.env.TERM || "unknown",
170
+ shell: process.env.SHELL || "unknown",
171
+ storedPaneId: getStoredPaneId(),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Get Claude Code's pane ID (the pane that isn't the sidebar)
177
+ */
178
+ export async function getClaudePaneId(): Promise<string | null> {
179
+ if (!isInTmux()) return null;
180
+
181
+ const sidebarPaneId = getStoredPaneId();
182
+ if (!sidebarPaneId) return null;
183
+
184
+ try {
185
+ const panesOutput = await $`tmux list-panes -F "#{pane_id}"`.text();
186
+ const panes = panesOutput.trim().split("\n");
187
+ return panes.find((p) => p !== sidebarPaneId) || null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Focus the Claude Code pane (switch tmux focus to it)
195
+ */
196
+ export async function focusClaudePane(): Promise<boolean> {
197
+ const claudePane = await getClaudePaneId();
198
+ if (!claudePane) return false;
199
+
200
+ try {
201
+ await $`tmux select-pane -t ${claudePane}`.quiet();
202
+ return true;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Send a message to the Claude Code pane (the pane that isn't the sidebar)
210
+ */
211
+ export async function sendToClaudePane(message: string): Promise<boolean> {
212
+ const claudePane = await getClaudePaneId();
213
+ if (!claudePane) return false;
214
+
215
+ try {
216
+ await $`tmux send-keys -t ${claudePane} ${message} Enter`.quiet();
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Capture the last N lines of Claude's pane output
225
+ */
226
+ export async function captureClaudePane(lines: number = 10): Promise<string | null> {
227
+ const claudePane = await getClaudePaneId();
228
+ if (!claudePane) return null;
229
+
230
+ try {
231
+ const output = await $`tmux capture-pane -t ${claudePane} -p -S -${lines}`.text();
232
+ return output;
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Check if Claude Code is at an input prompt (waiting for input)
240
+ */
241
+ export async function isClaudeAtPrompt(): Promise<boolean> {
242
+ const { checkOutputForPrompt } = await import("./prompt");
243
+ const output = await captureClaudePane(10);
244
+ if (!output) return false;
245
+ return checkOutputForPrompt(output);
246
+ }