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,542 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, memo, useCallback } from "react";
|
|
2
|
+
import { Box, Text, useInput, useApp, useStdin } from "ink";
|
|
3
|
+
import {
|
|
4
|
+
getTasks,
|
|
5
|
+
getActiveTask,
|
|
6
|
+
addTask,
|
|
7
|
+
updateTask,
|
|
8
|
+
removeTask,
|
|
9
|
+
activateTask,
|
|
10
|
+
completeActiveTask,
|
|
11
|
+
type Task,
|
|
12
|
+
type ActiveTask,
|
|
13
|
+
} from "../persistence/store";
|
|
14
|
+
import { sendToClaudePane, isClaudeAtPrompt } from "../terminal/tmux";
|
|
15
|
+
|
|
16
|
+
// Parse a raw keypress buffer into key info (simplified version)
|
|
17
|
+
function parseRawKey(data: Buffer): { input: string; key: any } {
|
|
18
|
+
const str = data.toString();
|
|
19
|
+
|
|
20
|
+
// In iTerm2: Enter sends \r (0x0d), Shift+Enter sends \n (0x0a)
|
|
21
|
+
const isShiftEnter = str === '\n';
|
|
22
|
+
|
|
23
|
+
const key = {
|
|
24
|
+
return: str === '\r',
|
|
25
|
+
shiftReturn: isShiftEnter,
|
|
26
|
+
escape: str === '\x1b',
|
|
27
|
+
backspace: str === '\x7f' || str === '\b',
|
|
28
|
+
delete: str === '\x1b[3~',
|
|
29
|
+
leftArrow: str === '\x1b[D' || str === '\x1bOD',
|
|
30
|
+
rightArrow: str === '\x1b[C' || str === '\x1bOC',
|
|
31
|
+
upArrow: str === '\x1b[A' || str === '\x1bOA',
|
|
32
|
+
downArrow: str === '\x1b[B' || str === '\x1bOB',
|
|
33
|
+
ctrl: str.charCodeAt(0) < 32 && str !== '\r' && str !== '\n' && str !== '\x1b',
|
|
34
|
+
meta: str.startsWith('\x1b') && str.length > 1 && !str.startsWith('\x1b[') && !str.startsWith('\x1bO'),
|
|
35
|
+
tab: str === '\t',
|
|
36
|
+
shift: isShiftEnter,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let input = str;
|
|
40
|
+
// Extract ctrl+letter
|
|
41
|
+
if (key.ctrl && str.length === 1) {
|
|
42
|
+
input = String.fromCharCode(str.charCodeAt(0) + 96); // ctrl+a = 0x01 -> 'a'
|
|
43
|
+
}
|
|
44
|
+
// Strip escape sequences
|
|
45
|
+
if (str.startsWith('\x1b[') || str.startsWith('\x1bO')) {
|
|
46
|
+
input = '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { input, key };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SidebarProps {
|
|
53
|
+
onClose?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Render text with cursor at position for input mode (memoized)
|
|
57
|
+
// Using single Text with inline styling to avoid fragment flickering
|
|
58
|
+
const TextWithCursor = memo(function TextWithCursor({ text, cursorPos }: { text: string; cursorPos: number }) {
|
|
59
|
+
const before = text.slice(0, cursorPos);
|
|
60
|
+
const at = text[cursorPos] || " ";
|
|
61
|
+
const after = text.slice(cursorPos + 1);
|
|
62
|
+
|
|
63
|
+
// Use a single Text wrapper to prevent Ink fragment flickering
|
|
64
|
+
return (
|
|
65
|
+
<Text>
|
|
66
|
+
<Text color="black">{before}</Text>
|
|
67
|
+
<Text color="white" backgroundColor="black">{at}</Text>
|
|
68
|
+
<Text color="black">{after}</Text>
|
|
69
|
+
</Text>
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Truncate text with ellipsis
|
|
74
|
+
function truncate(text: string, maxLen: number): string {
|
|
75
|
+
if (text.length <= maxLen) return text;
|
|
76
|
+
return text.slice(0, maxLen - 3) + "...";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Memoized Active Section - only re-renders when active task changes
|
|
80
|
+
const ActiveSection = memo(function ActiveSection({ active }: { active: ActiveTask | null }) {
|
|
81
|
+
return (
|
|
82
|
+
<Box flexDirection="column" marginBottom={1} flexShrink={0}>
|
|
83
|
+
<Text bold color="black">Active</Text>
|
|
84
|
+
<Box flexDirection="column">
|
|
85
|
+
{active ? (
|
|
86
|
+
<Box flexDirection="row">
|
|
87
|
+
<Box width={2} flexShrink={0}>
|
|
88
|
+
<Text color="black">→</Text>
|
|
89
|
+
</Box>
|
|
90
|
+
<Box flexGrow={1}>
|
|
91
|
+
<Text color="black" wrap="wrap">
|
|
92
|
+
{active.content}
|
|
93
|
+
</Text>
|
|
94
|
+
</Box>
|
|
95
|
+
</Box>
|
|
96
|
+
) : (
|
|
97
|
+
<Text color="gray">No active task</Text>
|
|
98
|
+
)}
|
|
99
|
+
</Box>
|
|
100
|
+
</Box>
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Memoized Queue Item - only re-renders when this specific item changes
|
|
105
|
+
const QueueItem = memo(function QueueItem({
|
|
106
|
+
task,
|
|
107
|
+
isSelected,
|
|
108
|
+
checkboxWidth,
|
|
109
|
+
}: {
|
|
110
|
+
task: Task;
|
|
111
|
+
isSelected: boolean;
|
|
112
|
+
checkboxWidth: number;
|
|
113
|
+
}) {
|
|
114
|
+
return (
|
|
115
|
+
<Box flexDirection="row" flexShrink={0}>
|
|
116
|
+
<Box width={checkboxWidth} flexShrink={0}>
|
|
117
|
+
<Text color="black">{isSelected ? "[•]" : "[ ]"}</Text>
|
|
118
|
+
</Box>
|
|
119
|
+
<Box flexGrow={1}>
|
|
120
|
+
<Text color="black" wrap="wrap">
|
|
121
|
+
{task.content}
|
|
122
|
+
</Text>
|
|
123
|
+
</Box>
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
// Static input placeholder - NEVER re-renders during typing
|
|
130
|
+
// Shows a blinking cursor prompt; actual typed text is not shown to avoid flicker
|
|
131
|
+
const InputPlaceholder = memo(function InputPlaceholder({
|
|
132
|
+
checkboxWidth,
|
|
133
|
+
}: {
|
|
134
|
+
checkboxWidth: number;
|
|
135
|
+
}) {
|
|
136
|
+
return (
|
|
137
|
+
<Box flexDirection="row" flexShrink={0}>
|
|
138
|
+
<Box width={checkboxWidth} flexShrink={0}>
|
|
139
|
+
<Text color="black">[ ]</Text>
|
|
140
|
+
</Box>
|
|
141
|
+
<Box flexGrow={1}>
|
|
142
|
+
<Text backgroundColor="black" color="white">{" "}</Text>
|
|
143
|
+
<Text color="gray">{" typing... (press Enter to submit)"}</Text>
|
|
144
|
+
</Box>
|
|
145
|
+
</Box>
|
|
146
|
+
);
|
|
147
|
+
}, () => true); // Never re-render - always return true from comparison
|
|
148
|
+
|
|
149
|
+
// Memoized Footer - only re-renders when mode changes
|
|
150
|
+
const Footer = memo(function Footer({ inputMode, active }: { inputMode: InputMode; active: boolean }) {
|
|
151
|
+
return (
|
|
152
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
153
|
+
<Text color="gray">
|
|
154
|
+
{inputMode !== "none"
|
|
155
|
+
? "Enter: submit | Shift+Enter: newline | Esc: cancel"
|
|
156
|
+
: active
|
|
157
|
+
? "Tab: section | d: clear active"
|
|
158
|
+
: "Tab: section | a: add | e: edit | d: del | Enter: send"}
|
|
159
|
+
</Text>
|
|
160
|
+
<Box marginTop={1}>
|
|
161
|
+
<Text color="black">{"• "}</Text>
|
|
162
|
+
<Text bold color="black">Claude Sidebar</Text>
|
|
163
|
+
</Box>
|
|
164
|
+
</Box>
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
type Section = "active" | "queue";
|
|
169
|
+
type InputMode = "none" | "add" | "edit";
|
|
170
|
+
|
|
171
|
+
export function Sidebar({ onClose }: SidebarProps) {
|
|
172
|
+
const { exit } = useApp();
|
|
173
|
+
const { stdin, setRawMode } = useStdin();
|
|
174
|
+
// Get terminal height once on mount (avoid re-renders from useStdout)
|
|
175
|
+
const terminalHeight = useRef(process.stdout.rows || 40).current;
|
|
176
|
+
|
|
177
|
+
// Hide terminal cursor to reduce flicker (we show our own cursor in TextWithCursor)
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
process.stdout.write('\x1B[?25l'); // Hide cursor
|
|
180
|
+
return () => {
|
|
181
|
+
process.stdout.write('\x1B[?25h'); // Show cursor on cleanup
|
|
182
|
+
};
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
// State
|
|
186
|
+
const [tasks, setTasks] = useState<Task[]>([]);
|
|
187
|
+
const [active, setActive] = useState<ActiveTask | null>(null);
|
|
188
|
+
const [activeSection, setActiveSection] = useState<Section>("queue");
|
|
189
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
190
|
+
const [inputMode, setInputMode] = useState<InputMode>("none");
|
|
191
|
+
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
|
192
|
+
|
|
193
|
+
// Input state - stored ONLY in refs to avoid ANY React re-renders during typing
|
|
194
|
+
// This eliminates flicker completely at the cost of not showing typed text
|
|
195
|
+
const inputBufferRef = useRef("");
|
|
196
|
+
const inputCursorRef = useRef(0);
|
|
197
|
+
|
|
198
|
+
// Refs for raw input handling - bypasses Ink's reconciler completely
|
|
199
|
+
const rawInputHandlerRef = useRef<((data: Buffer) => void) | null>(null);
|
|
200
|
+
|
|
201
|
+
// Update input refs only - NO React state updates during typing
|
|
202
|
+
const updateInput = useCallback((buffer: string, cursor: number) => {
|
|
203
|
+
inputBufferRef.current = buffer;
|
|
204
|
+
inputCursorRef.current = cursor;
|
|
205
|
+
// Intentionally do NOT update any React state here
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
// Completion polling
|
|
209
|
+
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
210
|
+
const lastOutputRef = useRef<string>("");
|
|
211
|
+
const stableCountRef = useRef(0);
|
|
212
|
+
|
|
213
|
+
// Load data on mount and periodically (only update if changed)
|
|
214
|
+
// PAUSE polling during input mode to prevent any re-renders
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
// Don't poll during input mode - this prevents flicker
|
|
217
|
+
if (inputMode !== "none") {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const loadData = () => {
|
|
222
|
+
const newTasks = getTasks();
|
|
223
|
+
const newActive = getActiveTask();
|
|
224
|
+
|
|
225
|
+
// Only update state if data actually changed (prevents unnecessary re-renders)
|
|
226
|
+
setTasks(prev => {
|
|
227
|
+
const prevJson = JSON.stringify(prev);
|
|
228
|
+
const newJson = JSON.stringify(newTasks);
|
|
229
|
+
return prevJson === newJson ? prev : newTasks;
|
|
230
|
+
});
|
|
231
|
+
setActive(prev => {
|
|
232
|
+
const prevJson = JSON.stringify(prev);
|
|
233
|
+
const newJson = JSON.stringify(newActive);
|
|
234
|
+
return prevJson === newJson ? prev : newActive;
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
loadData();
|
|
239
|
+
const interval = setInterval(loadData, 1000);
|
|
240
|
+
return () => clearInterval(interval);
|
|
241
|
+
}, [inputMode]);
|
|
242
|
+
|
|
243
|
+
// Start/stop completion polling when active task changes
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (active && !pollingRef.current) {
|
|
246
|
+
// Start polling for completion
|
|
247
|
+
pollingRef.current = setInterval(async () => {
|
|
248
|
+
const atPrompt = await isClaudeAtPrompt();
|
|
249
|
+
if (atPrompt) {
|
|
250
|
+
stableCountRef.current++;
|
|
251
|
+
// Wait for 2 consecutive checks (4 seconds) to confirm completion
|
|
252
|
+
if (stableCountRef.current >= 2) {
|
|
253
|
+
completeActiveTask();
|
|
254
|
+
setActive(null);
|
|
255
|
+
stableCountRef.current = 0;
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
stableCountRef.current = 0;
|
|
259
|
+
}
|
|
260
|
+
}, 2000);
|
|
261
|
+
} else if (!active && pollingRef.current) {
|
|
262
|
+
clearInterval(pollingRef.current);
|
|
263
|
+
pollingRef.current = null;
|
|
264
|
+
stableCountRef.current = 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return () => {
|
|
268
|
+
if (pollingRef.current) {
|
|
269
|
+
clearInterval(pollingRef.current);
|
|
270
|
+
pollingRef.current = null;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}, [active]);
|
|
274
|
+
|
|
275
|
+
// Helper to find word boundaries
|
|
276
|
+
const findPrevWordBoundary = (text: string, pos: number): number => {
|
|
277
|
+
if (pos <= 0) return 0;
|
|
278
|
+
let i = pos - 1;
|
|
279
|
+
while (i > 0 && text[i] === " ") i--;
|
|
280
|
+
while (i > 0 && text[i - 1] !== " ") i--;
|
|
281
|
+
return i;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const findNextWordBoundary = (text: string, pos: number): number => {
|
|
285
|
+
if (pos >= text.length) return text.length;
|
|
286
|
+
let i = pos;
|
|
287
|
+
while (i < text.length && text[i] !== " ") i++;
|
|
288
|
+
while (i < text.length && text[i] === " ") i++;
|
|
289
|
+
return i;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// RAW STDIN HANDLER - removes ALL Ink's stdin listeners during input mode
|
|
293
|
+
// This is the nuclear option to prevent any Ink-triggered re-renders
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (inputMode === "none") {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Get all 'readable' listeners and remove them temporarily
|
|
300
|
+
const readableListeners = process.stdin.listeners('readable');
|
|
301
|
+
readableListeners.forEach(listener => {
|
|
302
|
+
process.stdin.removeListener('readable', listener as any);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Add our own data handler directly
|
|
306
|
+
const handleData = (data: Buffer) => {
|
|
307
|
+
const { input, key } = parseRawKey(data);
|
|
308
|
+
const buffer = inputBufferRef.current;
|
|
309
|
+
const cursor = inputCursorRef.current;
|
|
310
|
+
|
|
311
|
+
// Shift+Enter: insert newline
|
|
312
|
+
if (key.shiftReturn) {
|
|
313
|
+
updateInput(buffer.slice(0, cursor) + '\n' + buffer.slice(cursor), cursor + 1);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (key.return) {
|
|
318
|
+
// Restore Ink's listeners BEFORE changing state
|
|
319
|
+
readableListeners.forEach(listener => {
|
|
320
|
+
process.stdin.addListener('readable', listener as any);
|
|
321
|
+
});
|
|
322
|
+
process.stdin.removeListener('data', handleData);
|
|
323
|
+
|
|
324
|
+
if (buffer.trim()) {
|
|
325
|
+
if (inputMode === "add") {
|
|
326
|
+
addTask(buffer.trim());
|
|
327
|
+
} else if (inputMode === "edit" && editingTaskId) {
|
|
328
|
+
updateTask(editingTaskId, buffer.trim());
|
|
329
|
+
}
|
|
330
|
+
setTasks(getTasks());
|
|
331
|
+
}
|
|
332
|
+
inputBufferRef.current = "";
|
|
333
|
+
inputCursorRef.current = 0;
|
|
334
|
+
setInputMode("none");
|
|
335
|
+
setEditingTaskId(null);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (key.escape) {
|
|
340
|
+
// Restore Ink's listeners BEFORE changing state
|
|
341
|
+
readableListeners.forEach(listener => {
|
|
342
|
+
process.stdin.addListener('readable', listener as any);
|
|
343
|
+
});
|
|
344
|
+
process.stdin.removeListener('data', handleData);
|
|
345
|
+
|
|
346
|
+
inputBufferRef.current = "";
|
|
347
|
+
inputCursorRef.current = 0;
|
|
348
|
+
setInputMode("none");
|
|
349
|
+
setEditingTaskId(null);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// All other input - no state changes, no re-renders
|
|
354
|
+
if (key.leftArrow) {
|
|
355
|
+
updateInput(buffer, key.meta ? findPrevWordBoundary(buffer, cursor) : Math.max(0, cursor - 1));
|
|
356
|
+
} else if (key.rightArrow) {
|
|
357
|
+
updateInput(buffer, key.meta ? findNextWordBoundary(buffer, cursor) : Math.min(buffer.length, cursor + 1));
|
|
358
|
+
} else if (key.backspace && cursor > 0) {
|
|
359
|
+
updateInput(buffer.slice(0, cursor - 1) + buffer.slice(cursor), cursor - 1);
|
|
360
|
+
} else if (key.ctrl && input === "a") {
|
|
361
|
+
updateInput(buffer, 0);
|
|
362
|
+
} else if (key.ctrl && input === "e") {
|
|
363
|
+
updateInput(buffer, buffer.length);
|
|
364
|
+
} else if (key.ctrl && input === "u") {
|
|
365
|
+
updateInput(buffer.slice(cursor), 0);
|
|
366
|
+
} else if (key.ctrl && input === "k") {
|
|
367
|
+
updateInput(buffer.slice(0, cursor), cursor);
|
|
368
|
+
} else if (key.ctrl && input === "w") {
|
|
369
|
+
const newPos = findPrevWordBoundary(buffer, cursor);
|
|
370
|
+
updateInput(buffer.slice(0, newPos) + buffer.slice(cursor), newPos);
|
|
371
|
+
} else if (input && !key.ctrl && input.length === 1) {
|
|
372
|
+
const code = input.charCodeAt(0);
|
|
373
|
+
if (code >= 32 && code <= 126) {
|
|
374
|
+
updateInput(buffer.slice(0, cursor) + input + buffer.slice(cursor), cursor + 1);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// Use 'data' event instead of 'readable' to get raw data directly
|
|
380
|
+
process.stdin.on('data', handleData);
|
|
381
|
+
|
|
382
|
+
return () => {
|
|
383
|
+
process.stdin.removeListener('data', handleData);
|
|
384
|
+
// Restore Ink's readable listeners
|
|
385
|
+
readableListeners.forEach(listener => {
|
|
386
|
+
process.stdin.addListener('readable', listener as any);
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
}, [inputMode, editingTaskId, updateInput]);
|
|
390
|
+
|
|
391
|
+
// Send task to Claude
|
|
392
|
+
const sendTaskToClaude = async (task: Task) => {
|
|
393
|
+
if (active) return; // Already have an active task
|
|
394
|
+
|
|
395
|
+
const activated = activateTask(task.id);
|
|
396
|
+
if (activated) {
|
|
397
|
+
setActive(activated);
|
|
398
|
+
setTasks(getTasks());
|
|
399
|
+
await sendToClaudePane(task.content);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Handle keyboard input - ONLY for normal mode (non-input)
|
|
404
|
+
// Input mode is handled by raw stdin handler above to avoid reconciler flicker
|
|
405
|
+
useInput((input, key) => {
|
|
406
|
+
// Normal mode only - input mode handled by raw stdin
|
|
407
|
+
if (key.escape) {
|
|
408
|
+
onClose?.();
|
|
409
|
+
exit();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Tab to switch sections
|
|
414
|
+
if (key.tab) {
|
|
415
|
+
setActiveSection((prev) => (prev === "active" ? "queue" : "active"));
|
|
416
|
+
setSelectedIndex(0);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Navigation
|
|
421
|
+
if (key.upArrow || input === "k") {
|
|
422
|
+
if (activeSection === "queue") {
|
|
423
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (key.downArrow || input === "j") {
|
|
429
|
+
if (activeSection === "queue") {
|
|
430
|
+
setSelectedIndex((prev) => Math.min(tasks.length - 1, prev + 1));
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Queue-specific actions
|
|
436
|
+
if (activeSection === "queue") {
|
|
437
|
+
// Number keys for quick selection
|
|
438
|
+
if (/^[1-9]$/.test(input)) {
|
|
439
|
+
const index = parseInt(input, 10) - 1;
|
|
440
|
+
if (index < tasks.length) {
|
|
441
|
+
setSelectedIndex(index);
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Enter to send task to Claude
|
|
447
|
+
if (key.return && tasks[selectedIndex] && !active) {
|
|
448
|
+
sendTaskToClaude(tasks[selectedIndex]);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Add task
|
|
453
|
+
if (input === "a") {
|
|
454
|
+
setInputMode("add");
|
|
455
|
+
updateInput("", 0);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Edit task
|
|
460
|
+
if (input === "e" && tasks[selectedIndex]) {
|
|
461
|
+
setInputMode("edit");
|
|
462
|
+
setEditingTaskId(tasks[selectedIndex].id);
|
|
463
|
+
updateInput(tasks[selectedIndex].content, tasks[selectedIndex].content.length);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Delete task
|
|
468
|
+
if (input === "d" && tasks[selectedIndex]) {
|
|
469
|
+
removeTask(tasks[selectedIndex].id);
|
|
470
|
+
setTasks(getTasks());
|
|
471
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Active section - allow clearing active task manually
|
|
477
|
+
if (activeSection === "active" && input === "d" && active) {
|
|
478
|
+
completeActiveTask();
|
|
479
|
+
setActive(null);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}, { isActive: inputMode === "none" }); // DISABLE useInput during input mode to prevent reconciler flicker
|
|
483
|
+
|
|
484
|
+
const CHECKBOX_WIDTH = 4; // "[ ] " is 4 chars
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<Box
|
|
488
|
+
flexDirection="column"
|
|
489
|
+
width="100%"
|
|
490
|
+
height={terminalHeight}
|
|
491
|
+
paddingX={2}
|
|
492
|
+
paddingY={1}
|
|
493
|
+
backgroundColor="#e8e8e8"
|
|
494
|
+
>
|
|
495
|
+
{/* Active Section - memoized */}
|
|
496
|
+
<ActiveSection active={active} />
|
|
497
|
+
|
|
498
|
+
{/* Queue Section */}
|
|
499
|
+
<Box flexDirection="column" marginBottom={1} flexShrink={0}>
|
|
500
|
+
<Box flexShrink={0}>
|
|
501
|
+
<Text bold color="black">Queue</Text>
|
|
502
|
+
{tasks.length > 0 && <Text color="gray"> ({tasks.length})</Text>}
|
|
503
|
+
</Box>
|
|
504
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
505
|
+
{tasks.length === 0 && inputMode !== "add" && (
|
|
506
|
+
<Text color="gray">No tasks queued</Text>
|
|
507
|
+
)}
|
|
508
|
+
{tasks.map((task, index) => {
|
|
509
|
+
const isEditing = inputMode === "edit" && editingTaskId === task.id;
|
|
510
|
+
if (isEditing) {
|
|
511
|
+
return (
|
|
512
|
+
<InputPlaceholder
|
|
513
|
+
key={task.id}
|
|
514
|
+
checkboxWidth={CHECKBOX_WIDTH}
|
|
515
|
+
/>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return (
|
|
519
|
+
<QueueItem
|
|
520
|
+
key={task.id}
|
|
521
|
+
task={task}
|
|
522
|
+
isSelected={activeSection === "queue" && index === selectedIndex}
|
|
523
|
+
checkboxWidth={CHECKBOX_WIDTH}
|
|
524
|
+
/>
|
|
525
|
+
);
|
|
526
|
+
})}
|
|
527
|
+
{inputMode === "add" && (
|
|
528
|
+
<InputPlaceholder
|
|
529
|
+
checkboxWidth={CHECKBOX_WIDTH}
|
|
530
|
+
/>
|
|
531
|
+
)}
|
|
532
|
+
</Box>
|
|
533
|
+
</Box>
|
|
534
|
+
|
|
535
|
+
{/* Spacer - fills remaining height with background */}
|
|
536
|
+
<Box flexGrow={1} />
|
|
537
|
+
|
|
538
|
+
{/* Footer - memoized */}
|
|
539
|
+
<Footer inputMode={inputMode} active={!!active} />
|
|
540
|
+
</Box>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC Client for sending messages to the sidebar
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
|
|
7
|
+
export interface IPCMessage {
|
|
8
|
+
type: string;
|
|
9
|
+
data?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Send a message to the sidebar via Unix socket
|
|
14
|
+
*/
|
|
15
|
+
export async function sendMessage(
|
|
16
|
+
socketPath: string,
|
|
17
|
+
message: IPCMessage
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
if (!existsSync(socketPath)) {
|
|
20
|
+
throw new Error(`Socket not found: ${socketPath}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const socket = Bun.connect({
|
|
25
|
+
unix: socketPath,
|
|
26
|
+
socket: {
|
|
27
|
+
open(socket) {
|
|
28
|
+
const data = JSON.stringify(message) + "\n";
|
|
29
|
+
socket.write(data);
|
|
30
|
+
socket.end();
|
|
31
|
+
},
|
|
32
|
+
close() {
|
|
33
|
+
resolve();
|
|
34
|
+
},
|
|
35
|
+
data() {
|
|
36
|
+
// We don't expect responses for now
|
|
37
|
+
},
|
|
38
|
+
error(socket, error) {
|
|
39
|
+
reject(error);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Connect to sidebar and listen for messages
|
|
48
|
+
*/
|
|
49
|
+
export function connectToSidebar(
|
|
50
|
+
socketPath: string,
|
|
51
|
+
onMessage: (message: IPCMessage) => void,
|
|
52
|
+
onError?: (error: Error) => void
|
|
53
|
+
): { close: () => void } {
|
|
54
|
+
if (!existsSync(socketPath)) {
|
|
55
|
+
throw new Error(`Socket not found: ${socketPath}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let buffer = "";
|
|
59
|
+
|
|
60
|
+
const socket = Bun.connect({
|
|
61
|
+
unix: socketPath,
|
|
62
|
+
socket: {
|
|
63
|
+
open() {
|
|
64
|
+
// Connected
|
|
65
|
+
},
|
|
66
|
+
close() {
|
|
67
|
+
// Disconnected
|
|
68
|
+
},
|
|
69
|
+
data(socket, data) {
|
|
70
|
+
buffer += Buffer.from(data).toString("utf-8");
|
|
71
|
+
const lines = buffer.split("\n");
|
|
72
|
+
buffer = lines.pop() || "";
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (line.trim()) {
|
|
76
|
+
try {
|
|
77
|
+
const message = JSON.parse(line) as IPCMessage;
|
|
78
|
+
onMessage(message);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
error(socket, error) {
|
|
86
|
+
onError?.(error);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
close() {
|
|
93
|
+
socket.then((s) => s.end());
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|