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.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/commands/sidebar.md +62 -0
- package/package.json +40 -0
- package/skills/sidebar-awareness/SKILL.md +69 -0
- package/src/cli.ts +166 -0
- package/src/components/RawSidebar.tsx +1155 -0
- package/src/components/Sidebar.tsx +542 -0
- package/src/ipc/client.ts +96 -0
- package/src/ipc/server.ts +92 -0
- package/src/minimal-test.ts +45 -0
- package/src/node-test.mjs +50 -0
- package/src/persistence/store.ts +346 -0
- package/src/standalone-input.ts +101 -0
- package/src/sync-todos.ts +157 -0
- package/src/terminal/iterm.ts +187 -0
- package/src/terminal/prompt.ts +30 -0
- package/src/terminal/tmux.ts +246 -0
|
@@ -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
|
+
}
|