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,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
+ }