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,1155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw terminal sidebar - bypasses Ink completely to avoid flicker
|
|
3
|
+
* Uses direct ANSI escape codes for all rendering
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import {
|
|
8
|
+
getTasks,
|
|
9
|
+
getStatusline,
|
|
10
|
+
getClaudeTodos,
|
|
11
|
+
addTask,
|
|
12
|
+
updateTask,
|
|
13
|
+
removeTask,
|
|
14
|
+
markTaskClarified,
|
|
15
|
+
getActiveTask,
|
|
16
|
+
setActiveTask,
|
|
17
|
+
activateTask,
|
|
18
|
+
completeActiveTask,
|
|
19
|
+
getRecentlyDone,
|
|
20
|
+
removeFromDone,
|
|
21
|
+
returnToActive,
|
|
22
|
+
type Task,
|
|
23
|
+
type ActiveTask,
|
|
24
|
+
type DoneTask,
|
|
25
|
+
type StatuslineData,
|
|
26
|
+
type ClaudeTodo,
|
|
27
|
+
} from "../persistence/store";
|
|
28
|
+
import * as tmux from "../terminal/tmux";
|
|
29
|
+
import * as iterm from "../terminal/iterm";
|
|
30
|
+
|
|
31
|
+
// Check if using iTerm2 natively (not inside tmux)
|
|
32
|
+
function useITerm(): boolean {
|
|
33
|
+
return iterm.isInITerm() && !tmux.isInTmux();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Unified functions that work with both iTerm2 and tmux
|
|
37
|
+
async function sendToClaudePane(text: string): Promise<boolean> {
|
|
38
|
+
return useITerm() ? iterm.sendToClaudePane(text) : tmux.sendToClaudePane(text);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function focusClaudePane(): Promise<boolean> {
|
|
42
|
+
return useITerm() ? iterm.focusSession(1) : tmux.focusClaudePane();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function isClaudeAtPrompt(): Promise<boolean> {
|
|
46
|
+
return useITerm() ? iterm.isClaudeAtPrompt() : tmux.isClaudeAtPrompt();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ANSI escape codes
|
|
50
|
+
const ESC = '\x1b';
|
|
51
|
+
const CSI = `${ESC}[`;
|
|
52
|
+
|
|
53
|
+
const ansi = {
|
|
54
|
+
clearScreen: `${CSI}2J`,
|
|
55
|
+
cursorHome: `${CSI}H`,
|
|
56
|
+
cursorTo: (row: number, col: number) => `${CSI}${row};${col}H`,
|
|
57
|
+
clearLine: `${CSI}2K`,
|
|
58
|
+
clearToEnd: `${CSI}K`,
|
|
59
|
+
hideCursor: `${CSI}?25l`,
|
|
60
|
+
showCursor: `${CSI}?25h`,
|
|
61
|
+
// Cursor styles
|
|
62
|
+
steadyCursor: `${CSI}2 q`,
|
|
63
|
+
blinkCursor: `${CSI}1 q`,
|
|
64
|
+
// Synchronized output (DEC mode 2026) - prevents flicker
|
|
65
|
+
beginSync: `${CSI}?2026h`,
|
|
66
|
+
endSync: `${CSI}?2026l`,
|
|
67
|
+
// Alternate screen buffer
|
|
68
|
+
enterAltScreen: `${CSI}?1049h`,
|
|
69
|
+
exitAltScreen: `${CSI}?1049l`,
|
|
70
|
+
// Colors
|
|
71
|
+
reset: `${CSI}0m`,
|
|
72
|
+
bold: `${CSI}1m`,
|
|
73
|
+
dim: `${CSI}2m`,
|
|
74
|
+
inverse: `${CSI}7m`,
|
|
75
|
+
black: `${CSI}30m`,
|
|
76
|
+
gray: `${CSI}90m`,
|
|
77
|
+
white: `${CSI}37m`,
|
|
78
|
+
bgGray: `${CSI}48;2;255;255;255m`, // #ffffff - focused
|
|
79
|
+
bgBlack: `${CSI}40m`,
|
|
80
|
+
bgWhite: `${CSI}107m`,
|
|
81
|
+
// Dimmed colors for unfocused state
|
|
82
|
+
dimBg: `${CSI}48;2;245;245;245m`, // #f5f5f5 - unfocused
|
|
83
|
+
dimText: `${CSI}30m`, // Same black text (unfocused)
|
|
84
|
+
// Context warning colors
|
|
85
|
+
yellow: `${CSI}33m`, // Warning (60-80%)
|
|
86
|
+
red: `${CSI}31m`, // Critical (>80%)
|
|
87
|
+
green: `${CSI}32m`, // Good (<60%)
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type InputMode = "none" | "add" | "edit";
|
|
91
|
+
|
|
92
|
+
// Wrap text into multiple lines (handles both newlines and width wrapping)
|
|
93
|
+
function wrapText(text: string, maxWidth: number): string[] {
|
|
94
|
+
const lines: string[] = [];
|
|
95
|
+
// First split by actual newlines
|
|
96
|
+
const paragraphs = text.split('\n');
|
|
97
|
+
for (const para of paragraphs) {
|
|
98
|
+
if (para.length <= maxWidth) {
|
|
99
|
+
lines.push(para);
|
|
100
|
+
} else {
|
|
101
|
+
// Wrap long lines
|
|
102
|
+
let remaining = para;
|
|
103
|
+
while (remaining.length > 0) {
|
|
104
|
+
lines.push(remaining.slice(0, maxWidth));
|
|
105
|
+
remaining = remaining.slice(maxWidth);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines.length > 0 ? lines : [''];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface State {
|
|
113
|
+
tasks: Task[];
|
|
114
|
+
activeTask: ActiveTask | null;
|
|
115
|
+
doneTasks: DoneTask[];
|
|
116
|
+
claudeTodos: ClaudeTodo[];
|
|
117
|
+
statusline: StatuslineData | null;
|
|
118
|
+
selectedSection: "queue" | "done";
|
|
119
|
+
selectedIndex: number;
|
|
120
|
+
doneSelectedIndex: number;
|
|
121
|
+
inputMode: InputMode;
|
|
122
|
+
editingTaskId: string | null;
|
|
123
|
+
inputBuffer: string;
|
|
124
|
+
inputCursor: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class RawSidebar {
|
|
128
|
+
private state: State = {
|
|
129
|
+
tasks: [],
|
|
130
|
+
activeTask: null,
|
|
131
|
+
doneTasks: [],
|
|
132
|
+
claudeTodos: [],
|
|
133
|
+
statusline: null,
|
|
134
|
+
selectedSection: "queue",
|
|
135
|
+
selectedIndex: 0,
|
|
136
|
+
doneSelectedIndex: 0,
|
|
137
|
+
inputMode: "none",
|
|
138
|
+
editingTaskId: null,
|
|
139
|
+
inputBuffer: "",
|
|
140
|
+
inputCursor: 0,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
private width: number;
|
|
144
|
+
private height: number;
|
|
145
|
+
private focused = true;
|
|
146
|
+
private running = false;
|
|
147
|
+
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
148
|
+
private completionInterval: ReturnType<typeof setInterval> | null = null;
|
|
149
|
+
private onClose?: () => void;
|
|
150
|
+
private isPasting = false;
|
|
151
|
+
private pasteBuffer = "";
|
|
152
|
+
|
|
153
|
+
// Get tasks sorted by priority (lower number = higher priority)
|
|
154
|
+
// Tasks without priority go last, sorted by createdAt
|
|
155
|
+
private getSortedTasks(): Task[] {
|
|
156
|
+
const { tasks } = this.state;
|
|
157
|
+
return [...tasks].sort((a, b) => {
|
|
158
|
+
// Both have priority: sort by priority (lower first)
|
|
159
|
+
if (a.priority !== undefined && b.priority !== undefined) {
|
|
160
|
+
return a.priority - b.priority;
|
|
161
|
+
}
|
|
162
|
+
// Only a has priority: a comes first
|
|
163
|
+
if (a.priority !== undefined) return -1;
|
|
164
|
+
// Only b has priority: b comes first
|
|
165
|
+
if (b.priority !== undefined) return 1;
|
|
166
|
+
// Neither has priority: sort by createdAt (oldest first)
|
|
167
|
+
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
constructor(onClose?: () => void) {
|
|
172
|
+
this.width = process.stdout.columns || 50;
|
|
173
|
+
this.height = process.stdout.rows || 40;
|
|
174
|
+
this.onClose = onClose;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
start(): void {
|
|
178
|
+
this.running = true;
|
|
179
|
+
|
|
180
|
+
// Use stty to ensure echo is off and we're in raw mode
|
|
181
|
+
try {
|
|
182
|
+
execSync('stty -echo raw', { stdio: 'ignore' });
|
|
183
|
+
} catch {}
|
|
184
|
+
|
|
185
|
+
// Setup terminal - enter alt screen buffer and enable focus reporting
|
|
186
|
+
process.stdout.write(
|
|
187
|
+
ansi.enterAltScreen + // Enter alternate screen buffer (prevents scrollback pollution)
|
|
188
|
+
'\x1b[?1004h' + // Enable focus reporting
|
|
189
|
+
'\x1b[?2004h' + // Enable bracketed paste mode
|
|
190
|
+
ansi.hideCursor + ansi.clearScreen + ansi.cursorHome
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Configure stdin for raw input
|
|
194
|
+
if (process.stdin.isTTY) {
|
|
195
|
+
process.stdin.setRawMode(true);
|
|
196
|
+
}
|
|
197
|
+
process.stdin.resume();
|
|
198
|
+
process.stdin.setEncoding('utf8');
|
|
199
|
+
|
|
200
|
+
// Load initial data
|
|
201
|
+
this.loadData();
|
|
202
|
+
|
|
203
|
+
// Start polling for data changes
|
|
204
|
+
this.pollInterval = setInterval(() => {
|
|
205
|
+
if (this.state.inputMode === "none") {
|
|
206
|
+
this.loadData();
|
|
207
|
+
}
|
|
208
|
+
}, 1000);
|
|
209
|
+
|
|
210
|
+
// Start polling for task completion (check if Claude is idle)
|
|
211
|
+
this.completionInterval = setInterval(() => {
|
|
212
|
+
this.checkCompletion();
|
|
213
|
+
}, 3000);
|
|
214
|
+
|
|
215
|
+
// Handle input
|
|
216
|
+
process.stdin.on('data', this.handleInput);
|
|
217
|
+
|
|
218
|
+
// Handle resize
|
|
219
|
+
process.stdout.on('resize', this.handleResize);
|
|
220
|
+
|
|
221
|
+
// Initial render
|
|
222
|
+
this.render();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
stop(): void {
|
|
226
|
+
this.running = false;
|
|
227
|
+
// Disable focus reporting, bracketed paste, restore cursor, and exit alternate screen buffer
|
|
228
|
+
process.stdout.write('\x1b[?1004l' + '\x1b[?2004l' + ansi.showCursor + ansi.reset + ansi.exitAltScreen);
|
|
229
|
+
process.stdin.setRawMode(false);
|
|
230
|
+
process.stdin.removeListener('data', this.handleInput);
|
|
231
|
+
process.stdout.removeListener('resize', this.handleResize);
|
|
232
|
+
|
|
233
|
+
// Restore terminal settings
|
|
234
|
+
try {
|
|
235
|
+
execSync('stty echo -raw sane', { stdio: 'ignore' });
|
|
236
|
+
} catch {}
|
|
237
|
+
|
|
238
|
+
if (this.pollInterval) {
|
|
239
|
+
clearInterval(this.pollInterval);
|
|
240
|
+
}
|
|
241
|
+
if (this.completionInterval) {
|
|
242
|
+
clearInterval(this.completionInterval);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private loadData(): void {
|
|
247
|
+
const newTasks = getTasks();
|
|
248
|
+
const newActiveTask = getActiveTask();
|
|
249
|
+
const newDoneTasks = getRecentlyDone();
|
|
250
|
+
const newClaudeTodos = getClaudeTodos()?.todos || [];
|
|
251
|
+
const newStatusline = getStatusline();
|
|
252
|
+
|
|
253
|
+
const tasksChanged = JSON.stringify(newTasks) !== JSON.stringify(this.state.tasks);
|
|
254
|
+
const activeChanged = JSON.stringify(newActiveTask) !== JSON.stringify(this.state.activeTask);
|
|
255
|
+
const doneChanged = JSON.stringify(newDoneTasks) !== JSON.stringify(this.state.doneTasks);
|
|
256
|
+
const claudeTodosChanged = JSON.stringify(newClaudeTodos) !== JSON.stringify(this.state.claudeTodos);
|
|
257
|
+
const statuslineChanged = JSON.stringify(newStatusline) !== JSON.stringify(this.state.statusline);
|
|
258
|
+
|
|
259
|
+
if (tasksChanged || activeChanged || doneChanged || claudeTodosChanged || statuslineChanged) {
|
|
260
|
+
this.state.tasks = newTasks;
|
|
261
|
+
this.state.activeTask = newActiveTask;
|
|
262
|
+
this.state.doneTasks = newDoneTasks;
|
|
263
|
+
this.state.claudeTodos = newClaudeTodos;
|
|
264
|
+
this.state.statusline = newStatusline;
|
|
265
|
+
this.render();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Check if Claude is idle and complete active task
|
|
270
|
+
private async checkCompletion(): Promise<void> {
|
|
271
|
+
if (!this.state.activeTask) return;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const isIdle = await isClaudeAtPrompt();
|
|
275
|
+
if (isIdle) {
|
|
276
|
+
completeActiveTask();
|
|
277
|
+
this.loadData();
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
// Ignore errors from prompt detection
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private handleResize = () => {
|
|
285
|
+
this.width = process.stdout.columns || 50;
|
|
286
|
+
this.height = process.stdout.rows || 40;
|
|
287
|
+
// Don't render during input mode to prevent flicker
|
|
288
|
+
if (this.state.inputMode === "none") {
|
|
289
|
+
this.render();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
private pausePolling(): void {
|
|
294
|
+
if (this.pollInterval) {
|
|
295
|
+
clearInterval(this.pollInterval);
|
|
296
|
+
this.pollInterval = null;
|
|
297
|
+
}
|
|
298
|
+
if (this.completionInterval) {
|
|
299
|
+
clearInterval(this.completionInterval);
|
|
300
|
+
this.completionInterval = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private restartPolling(): void {
|
|
305
|
+
if (this.pollInterval) return;
|
|
306
|
+
this.pollInterval = setInterval(() => {
|
|
307
|
+
if (this.state.inputMode === "none") {
|
|
308
|
+
this.loadData();
|
|
309
|
+
}
|
|
310
|
+
}, 1000);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private exitInputMode(): void {
|
|
314
|
+
this.state.inputBuffer = "";
|
|
315
|
+
this.state.inputCursor = 0;
|
|
316
|
+
this.state.inputMode = "none";
|
|
317
|
+
this.state.editingTaskId = null;
|
|
318
|
+
this.prevInputLineCount = 0;
|
|
319
|
+
this.render();
|
|
320
|
+
this.restartPolling();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private handlePaste(content: string): void {
|
|
324
|
+
// Only handle paste in input mode
|
|
325
|
+
if (this.state.inputMode === "none") {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (this.state.inputMode === "add") {
|
|
330
|
+
// Split by newlines and create multiple tasks (brain dump feature)
|
|
331
|
+
const lines = content.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
|
|
332
|
+
if (lines.length === 0) return;
|
|
333
|
+
|
|
334
|
+
if (lines.length === 1) {
|
|
335
|
+
// Single line - insert into buffer
|
|
336
|
+
const { inputBuffer, inputCursor } = this.state;
|
|
337
|
+
const line = lines[0] || '';
|
|
338
|
+
this.state.inputBuffer = inputBuffer.slice(0, inputCursor) + line + inputBuffer.slice(inputCursor);
|
|
339
|
+
this.state.inputCursor = inputCursor + line.length;
|
|
340
|
+
this.redrawInputText();
|
|
341
|
+
} else {
|
|
342
|
+
// Multiple lines - create tasks for each
|
|
343
|
+
lines.forEach(line => addTask(line));
|
|
344
|
+
this.state.tasks = getTasks();
|
|
345
|
+
this.exitInputMode();
|
|
346
|
+
}
|
|
347
|
+
} else if (this.state.inputMode === "edit") {
|
|
348
|
+
// In edit mode - insert at cursor
|
|
349
|
+
const { inputBuffer, inputCursor } = this.state;
|
|
350
|
+
// Join multiple lines with space for edit mode
|
|
351
|
+
const text = content.replace(/\r?\n/g, ' ').trim();
|
|
352
|
+
this.state.inputBuffer = inputBuffer.slice(0, inputCursor) + text + inputBuffer.slice(inputCursor);
|
|
353
|
+
this.state.inputCursor = inputCursor + text.length;
|
|
354
|
+
this.redrawInputText();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private handleInput = (data: Buffer) => {
|
|
359
|
+
const str = data.toString();
|
|
360
|
+
|
|
361
|
+
// Bracketed paste mode detection
|
|
362
|
+
const pasteStart = '\x1b[200~';
|
|
363
|
+
const pasteEnd = '\x1b[201~';
|
|
364
|
+
|
|
365
|
+
// Check for paste start
|
|
366
|
+
if (str.includes(pasteStart)) {
|
|
367
|
+
this.isPasting = true;
|
|
368
|
+
this.pasteBuffer = "";
|
|
369
|
+
// Extract content after paste start marker
|
|
370
|
+
const afterStart = str.split(pasteStart)[1] || "";
|
|
371
|
+
if (afterStart.includes(pasteEnd)) {
|
|
372
|
+
// Paste start and end in same chunk
|
|
373
|
+
const content = afterStart.split(pasteEnd)[0] || "";
|
|
374
|
+
this.handlePaste(content);
|
|
375
|
+
this.isPasting = false;
|
|
376
|
+
} else {
|
|
377
|
+
this.pasteBuffer = afterStart;
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Check for paste end (if we're in paste mode)
|
|
383
|
+
if (this.isPasting) {
|
|
384
|
+
if (str.includes(pasteEnd)) {
|
|
385
|
+
const beforeEnd = str.split(pasteEnd)[0];
|
|
386
|
+
this.pasteBuffer += beforeEnd;
|
|
387
|
+
this.handlePaste(this.pasteBuffer);
|
|
388
|
+
this.isPasting = false;
|
|
389
|
+
this.pasteBuffer = "";
|
|
390
|
+
} else {
|
|
391
|
+
this.pasteBuffer += str;
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Terminal focus events (sent by terminal when focus-events enabled)
|
|
397
|
+
if (str === '\x1b[I') {
|
|
398
|
+
// Focus in
|
|
399
|
+
if (!this.focused) {
|
|
400
|
+
this.focused = true;
|
|
401
|
+
this.render();
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (str === '\x1b[O') {
|
|
406
|
+
// Focus out
|
|
407
|
+
if (this.focused) {
|
|
408
|
+
this.focused = false;
|
|
409
|
+
this.render();
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (this.state.inputMode !== "none") {
|
|
415
|
+
this.handleInputMode(str);
|
|
416
|
+
} else {
|
|
417
|
+
this.handleNormalMode(str);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
private handleInputMode(str: string): void {
|
|
422
|
+
const { inputBuffer, inputCursor } = this.state;
|
|
423
|
+
|
|
424
|
+
// Shift+Enter (\n) - insert newline
|
|
425
|
+
// In iTerm2: Enter sends \r, Shift+Enter sends \n
|
|
426
|
+
if (str === '\n') {
|
|
427
|
+
this.state.inputBuffer = inputBuffer.slice(0, inputCursor) + '\n' + inputBuffer.slice(inputCursor);
|
|
428
|
+
this.state.inputCursor = inputCursor + 1;
|
|
429
|
+
this.redrawInputText();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Enter (\r) - submit
|
|
434
|
+
if (str === '\r') {
|
|
435
|
+
if (inputBuffer.trim()) {
|
|
436
|
+
if (this.state.inputMode === "add") {
|
|
437
|
+
addTask(inputBuffer.trim());
|
|
438
|
+
} else if (this.state.inputMode === "edit" && this.state.editingTaskId) {
|
|
439
|
+
updateTask(this.state.editingTaskId, inputBuffer.trim());
|
|
440
|
+
}
|
|
441
|
+
this.state.tasks = getTasks();
|
|
442
|
+
}
|
|
443
|
+
this.exitInputMode();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Escape - cancel
|
|
448
|
+
if (str === '\x1b') {
|
|
449
|
+
this.exitInputMode();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Backspace
|
|
454
|
+
if (str === '\x7f' || str === '\b') {
|
|
455
|
+
if (inputCursor > 0) {
|
|
456
|
+
this.state.inputBuffer = inputBuffer.slice(0, inputCursor - 1) + inputBuffer.slice(inputCursor);
|
|
457
|
+
this.state.inputCursor = inputCursor - 1;
|
|
458
|
+
// Always redraw for multi-line support
|
|
459
|
+
this.redrawInputText();
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Arrow keys
|
|
465
|
+
if (str === '\x1b[D' || str === '\x1bOD') { // Left
|
|
466
|
+
if (inputCursor > 0) {
|
|
467
|
+
this.state.inputCursor = inputCursor - 1;
|
|
468
|
+
this.moveCursor();
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (str === '\x1b[C' || str === '\x1bOC') { // Right
|
|
474
|
+
if (inputCursor < inputBuffer.length) {
|
|
475
|
+
this.state.inputCursor = inputCursor + 1;
|
|
476
|
+
this.moveCursor();
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Up arrow - move up one visual line
|
|
482
|
+
if (str === '\x1b[A' || str === '\x1bOA') {
|
|
483
|
+
const maxWidth = this.width - 10;
|
|
484
|
+
if (inputCursor >= maxWidth) {
|
|
485
|
+
this.state.inputCursor = inputCursor - maxWidth;
|
|
486
|
+
this.moveCursor();
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Down arrow - move down one visual line
|
|
492
|
+
if (str === '\x1b[B' || str === '\x1bOB') {
|
|
493
|
+
const maxWidth = this.width - 10;
|
|
494
|
+
const newPos = inputCursor + maxWidth;
|
|
495
|
+
if (newPos <= inputBuffer.length) {
|
|
496
|
+
this.state.inputCursor = newPos;
|
|
497
|
+
this.moveCursor();
|
|
498
|
+
} else if (inputCursor < inputBuffer.length) {
|
|
499
|
+
// If can't go down a full line, go to end
|
|
500
|
+
this.state.inputCursor = inputBuffer.length;
|
|
501
|
+
this.moveCursor();
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Option+Left - move to start of previous word (iTerm2: \x1b[1;3D or \x1bb)
|
|
507
|
+
if (str === '\x1b[1;3D' || str === '\x1bb') {
|
|
508
|
+
let pos = inputCursor;
|
|
509
|
+
// Skip any spaces before cursor
|
|
510
|
+
while (pos > 0 && inputBuffer[pos - 1] === ' ') pos--;
|
|
511
|
+
// Skip word characters
|
|
512
|
+
while (pos > 0 && inputBuffer[pos - 1] !== ' ') pos--;
|
|
513
|
+
this.state.inputCursor = pos;
|
|
514
|
+
this.moveCursor();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Option+Right - move to end of next word (iTerm2: \x1b[1;3C or \x1bf)
|
|
519
|
+
if (str === '\x1b[1;3C' || str === '\x1bf') {
|
|
520
|
+
let pos = inputCursor;
|
|
521
|
+
// Skip word characters
|
|
522
|
+
while (pos < inputBuffer.length && inputBuffer[pos] !== ' ') pos++;
|
|
523
|
+
// Skip any spaces after word
|
|
524
|
+
while (pos < inputBuffer.length && inputBuffer[pos] === ' ') pos++;
|
|
525
|
+
this.state.inputCursor = pos;
|
|
526
|
+
this.moveCursor();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Ctrl+A - start of current visual line
|
|
531
|
+
if (str === '\x01') {
|
|
532
|
+
const maxWidth = this.width - 10;
|
|
533
|
+
const visualLine = Math.floor(inputCursor / maxWidth);
|
|
534
|
+
this.state.inputCursor = visualLine * maxWidth;
|
|
535
|
+
this.moveCursor();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Ctrl+E - end of current visual line
|
|
540
|
+
if (str === '\x05') {
|
|
541
|
+
const maxWidth = this.width - 10;
|
|
542
|
+
const visualLine = Math.floor(inputCursor / maxWidth);
|
|
543
|
+
const lineEnd = Math.min((visualLine + 1) * maxWidth, inputBuffer.length);
|
|
544
|
+
this.state.inputCursor = lineEnd;
|
|
545
|
+
this.moveCursor();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Ctrl+U - clear to start
|
|
550
|
+
if (str === '\x15') {
|
|
551
|
+
this.state.inputBuffer = inputBuffer.slice(inputCursor);
|
|
552
|
+
this.state.inputCursor = 0;
|
|
553
|
+
this.redrawInputText();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Ctrl+K - clear to end
|
|
558
|
+
if (str === '\x0b') {
|
|
559
|
+
this.state.inputBuffer = inputBuffer.slice(0, inputCursor);
|
|
560
|
+
this.redrawInputText();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Ctrl+W - delete word before cursor
|
|
565
|
+
if (str === '\x17') {
|
|
566
|
+
if (inputCursor > 0) {
|
|
567
|
+
// Find start of previous word (skip trailing spaces, then skip word chars)
|
|
568
|
+
let pos = inputCursor;
|
|
569
|
+
while (pos > 0 && inputBuffer[pos - 1] === ' ') pos--;
|
|
570
|
+
while (pos > 0 && inputBuffer[pos - 1] !== ' ') pos--;
|
|
571
|
+
this.state.inputBuffer = inputBuffer.slice(0, pos) + inputBuffer.slice(inputCursor);
|
|
572
|
+
this.state.inputCursor = pos;
|
|
573
|
+
this.redrawInputText();
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Regular character
|
|
579
|
+
if (str.length === 1 && str.charCodeAt(0) >= 32 && str.charCodeAt(0) <= 126) {
|
|
580
|
+
this.state.inputBuffer = inputBuffer.slice(0, inputCursor) + str + inputBuffer.slice(inputCursor);
|
|
581
|
+
this.state.inputCursor = inputCursor + 1;
|
|
582
|
+
// Always redraw for multi-line support
|
|
583
|
+
this.redrawInputText();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private handleNormalMode(str: string): void {
|
|
589
|
+
// Escape - close
|
|
590
|
+
if (str === '\x1b') {
|
|
591
|
+
this.stop();
|
|
592
|
+
this.onClose?.();
|
|
593
|
+
process.exit(0);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Up arrow or k (navigates queue and done sections)
|
|
597
|
+
if (str === '\x1b[A' || str === '\x1bOA' || str === 'k') {
|
|
598
|
+
const { selectedSection, selectedIndex, doneSelectedIndex, tasks, doneTasks } = this.state;
|
|
599
|
+
|
|
600
|
+
if (selectedSection === "queue") {
|
|
601
|
+
if (tasks.length === 0) {
|
|
602
|
+
// No queue items, try to go to done
|
|
603
|
+
if (doneTasks.length > 0) {
|
|
604
|
+
this.state.selectedSection = "done";
|
|
605
|
+
this.state.doneSelectedIndex = doneTasks.length - 1;
|
|
606
|
+
}
|
|
607
|
+
} else if (selectedIndex > 0) {
|
|
608
|
+
this.state.selectedIndex--;
|
|
609
|
+
} else {
|
|
610
|
+
// At top of queue, wrap to bottom of done (or bottom of queue)
|
|
611
|
+
if (doneTasks.length > 0) {
|
|
612
|
+
this.state.selectedSection = "done";
|
|
613
|
+
this.state.doneSelectedIndex = Math.min(doneTasks.length - 1, 4); // Max 5 shown
|
|
614
|
+
} else {
|
|
615
|
+
this.state.selectedIndex = tasks.length - 1;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
// In done section
|
|
620
|
+
if (doneSelectedIndex > 0) {
|
|
621
|
+
this.state.doneSelectedIndex--;
|
|
622
|
+
} else {
|
|
623
|
+
// At top of done, wrap to bottom of queue (or bottom of done)
|
|
624
|
+
if (tasks.length > 0) {
|
|
625
|
+
this.state.selectedSection = "queue";
|
|
626
|
+
this.state.selectedIndex = tasks.length - 1;
|
|
627
|
+
} else {
|
|
628
|
+
this.state.doneSelectedIndex = Math.min(doneTasks.length - 1, 4);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
this.render();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Down arrow or j (navigates queue and done sections)
|
|
637
|
+
if (str === '\x1b[B' || str === '\x1bOB' || str === 'j') {
|
|
638
|
+
const { selectedSection, selectedIndex, doneSelectedIndex, tasks, doneTasks } = this.state;
|
|
639
|
+
|
|
640
|
+
if (selectedSection === "queue") {
|
|
641
|
+
if (tasks.length === 0) {
|
|
642
|
+
// No queue items, try to go to done
|
|
643
|
+
if (doneTasks.length > 0) {
|
|
644
|
+
this.state.selectedSection = "done";
|
|
645
|
+
this.state.doneSelectedIndex = 0;
|
|
646
|
+
}
|
|
647
|
+
} else if (selectedIndex < tasks.length - 1) {
|
|
648
|
+
this.state.selectedIndex++;
|
|
649
|
+
} else {
|
|
650
|
+
// At bottom of queue, wrap to top of done (or top of queue)
|
|
651
|
+
if (doneTasks.length > 0) {
|
|
652
|
+
this.state.selectedSection = "done";
|
|
653
|
+
this.state.doneSelectedIndex = 0;
|
|
654
|
+
} else {
|
|
655
|
+
this.state.selectedIndex = 0;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} else {
|
|
659
|
+
// In done section
|
|
660
|
+
const maxDoneIndex = Math.min(doneTasks.length - 1, 4); // Max 5 shown
|
|
661
|
+
if (doneSelectedIndex < maxDoneIndex) {
|
|
662
|
+
this.state.doneSelectedIndex++;
|
|
663
|
+
} else {
|
|
664
|
+
// At bottom of done, wrap to top of queue (or top of done)
|
|
665
|
+
if (tasks.length > 0) {
|
|
666
|
+
this.state.selectedSection = "queue";
|
|
667
|
+
this.state.selectedIndex = 0;
|
|
668
|
+
} else {
|
|
669
|
+
this.state.doneSelectedIndex = 0;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
this.render();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Number keys 1-9 (select queue item, switches to queue section)
|
|
678
|
+
if (/^[1-9]$/.test(str)) {
|
|
679
|
+
const index = parseInt(str, 10) - 1;
|
|
680
|
+
const sortedTasks = this.getSortedTasks();
|
|
681
|
+
if (index < sortedTasks.length) {
|
|
682
|
+
this.state.selectedSection = "queue";
|
|
683
|
+
this.state.selectedIndex = index;
|
|
684
|
+
this.render();
|
|
685
|
+
}
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Enter - send task to Claude (only works in queue section)
|
|
690
|
+
if (str === '\r' || str === '\n') {
|
|
691
|
+
if (this.state.selectedSection !== "queue") return;
|
|
692
|
+
const sortedTasks = this.getSortedTasks();
|
|
693
|
+
const task = sortedTasks[this.state.selectedIndex];
|
|
694
|
+
if (task) {
|
|
695
|
+
// Send to Claude and move to active
|
|
696
|
+
sendToClaudePane(task.content);
|
|
697
|
+
activateTask(task.id);
|
|
698
|
+
this.loadData();
|
|
699
|
+
this.state.selectedIndex = Math.max(0, this.state.selectedIndex - 1);
|
|
700
|
+
this.render();
|
|
701
|
+
focusClaudePane();
|
|
702
|
+
}
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Ctrl+Enter or 'c' - clarify mode (only works in queue section)
|
|
707
|
+
// CSI u format: \x1b[13;5u (iTerm2), 'c' as fallback
|
|
708
|
+
if (str === '\x1b[13;5u' || str === '\x1b\r' || str === '\x1b\n' || str === 'c') {
|
|
709
|
+
if (this.state.selectedSection !== "queue") return;
|
|
710
|
+
const sortedTasks = this.getSortedTasks();
|
|
711
|
+
const task = sortedTasks[this.state.selectedIndex];
|
|
712
|
+
if (task) {
|
|
713
|
+
const clarifyPrompt = `CLARIFY MODE
|
|
714
|
+
|
|
715
|
+
TASK ID: ${task.id}
|
|
716
|
+
TASK: ${task.content}
|
|
717
|
+
|
|
718
|
+
Interview me about this task using AskUserQuestion. Ask about anything relevant: technical implementation, UI/UX, edge cases, concerns, tradeoffs, constraints, dependencies, etc.
|
|
719
|
+
|
|
720
|
+
Guidelines:
|
|
721
|
+
- Don't ask obvious questions - if something is clear from the task description, don't ask about it
|
|
722
|
+
- Be thorough - keep interviewing until you have complete clarity
|
|
723
|
+
- Always include "Anything else I should know?" as a final question
|
|
724
|
+
|
|
725
|
+
After the interview:
|
|
726
|
+
1. Write specs to an Atomic Plan file (check project CLAUDE.md for plan folder path)
|
|
727
|
+
2. Update the task in the sidebar using this script:
|
|
728
|
+
|
|
729
|
+
\`\`\`bash
|
|
730
|
+
node << 'SCRIPT'
|
|
731
|
+
const fs = require('fs');
|
|
732
|
+
const crypto = require('crypto');
|
|
733
|
+
const path = require('path');
|
|
734
|
+
const sidebarDir = path.join(require('os').homedir(), '.claude-sidebar');
|
|
735
|
+
const hash = crypto.createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12);
|
|
736
|
+
const tasksPath = path.join(sidebarDir, 'projects', hash, 'tasks.json');
|
|
737
|
+
let tasks = JSON.parse(fs.readFileSync(tasksPath, 'utf-8'));
|
|
738
|
+
const task = tasks.find(t => t.id === '${task.id}');
|
|
739
|
+
if (task) {
|
|
740
|
+
task.clarified = true;
|
|
741
|
+
task.planPath = 'PLAN_FILENAME.md'; // REPLACE with actual plan filename
|
|
742
|
+
fs.writeFileSync(tasksPath, JSON.stringify(tasks, null, 2));
|
|
743
|
+
console.log('Task updated with clarified=true and planPath');
|
|
744
|
+
}
|
|
745
|
+
SCRIPT
|
|
746
|
+
\`\`\`
|
|
747
|
+
|
|
748
|
+
3. Ask me: "Execute this task now, or save for later?"
|
|
749
|
+
- If I say execute → work on the task
|
|
750
|
+
- If I say save → just confirm the task is clarified and stop`;
|
|
751
|
+
|
|
752
|
+
sendToClaudePane(clarifyPrompt);
|
|
753
|
+
// Don't activate - let the task stay in queue until user decides
|
|
754
|
+
this.render();
|
|
755
|
+
focusClaudePane();
|
|
756
|
+
}
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// 'a' - add task (always switches to queue section)
|
|
761
|
+
if (str === 'a') {
|
|
762
|
+
this.pausePolling();
|
|
763
|
+
this.state.selectedSection = "queue";
|
|
764
|
+
this.state.inputMode = "add";
|
|
765
|
+
this.state.inputBuffer = "";
|
|
766
|
+
this.state.inputCursor = 0;
|
|
767
|
+
this.prevInputLineCount = 1; // Start with 1 empty line
|
|
768
|
+
this.render();
|
|
769
|
+
this.setupInputCursor();
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// 'e' - edit task (only works in queue section)
|
|
774
|
+
if (str === 'e') {
|
|
775
|
+
if (this.state.selectedSection !== "queue") return;
|
|
776
|
+
const sortedTasks = this.getSortedTasks();
|
|
777
|
+
const task = sortedTasks[this.state.selectedIndex];
|
|
778
|
+
if (task) {
|
|
779
|
+
this.pausePolling();
|
|
780
|
+
this.state.inputMode = "edit";
|
|
781
|
+
this.state.editingTaskId = task.id;
|
|
782
|
+
this.state.inputBuffer = task.content;
|
|
783
|
+
this.state.inputCursor = task.content.length;
|
|
784
|
+
const maxWidth = this.width - 10;
|
|
785
|
+
this.prevInputLineCount = Math.max(1, Math.ceil(task.content.length / maxWidth));
|
|
786
|
+
this.render();
|
|
787
|
+
this.setupInputCursor();
|
|
788
|
+
}
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// 'd' - delete from queue, or confirm done in Review section
|
|
793
|
+
if (str === 'd') {
|
|
794
|
+
if (this.state.selectedSection === "queue") {
|
|
795
|
+
const sortedTasks = this.getSortedTasks();
|
|
796
|
+
const task = sortedTasks[this.state.selectedIndex];
|
|
797
|
+
if (task) {
|
|
798
|
+
removeTask(task.id);
|
|
799
|
+
this.state.tasks = getTasks();
|
|
800
|
+
this.state.selectedIndex = Math.max(0, this.state.selectedIndex - 1);
|
|
801
|
+
this.render();
|
|
802
|
+
}
|
|
803
|
+
} else {
|
|
804
|
+
// Confirm done - remove from Review section
|
|
805
|
+
const task = this.state.doneTasks[this.state.doneSelectedIndex];
|
|
806
|
+
if (task) {
|
|
807
|
+
removeFromDone(task.id);
|
|
808
|
+
this.state.doneTasks = getRecentlyDone();
|
|
809
|
+
// Adjust selection if needed
|
|
810
|
+
if (this.state.doneTasks.length === 0) {
|
|
811
|
+
// No more review tasks, go back to queue
|
|
812
|
+
this.state.selectedSection = "queue";
|
|
813
|
+
this.state.selectedIndex = Math.max(0, this.state.tasks.length - 1);
|
|
814
|
+
} else {
|
|
815
|
+
this.state.doneSelectedIndex = Math.min(
|
|
816
|
+
this.state.doneSelectedIndex,
|
|
817
|
+
this.state.doneTasks.length - 1
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
this.render();
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 'r' - return Review item to In Progress (not done yet)
|
|
827
|
+
if (str === 'r') {
|
|
828
|
+
if (this.state.selectedSection === "done") {
|
|
829
|
+
const task = this.state.doneTasks[this.state.doneSelectedIndex];
|
|
830
|
+
if (task) {
|
|
831
|
+
returnToActive(task.id);
|
|
832
|
+
this.loadData();
|
|
833
|
+
// Adjust selection if needed
|
|
834
|
+
if (this.state.doneTasks.length === 0) {
|
|
835
|
+
this.state.selectedSection = "queue";
|
|
836
|
+
this.state.selectedIndex = Math.max(0, this.state.tasks.length - 1);
|
|
837
|
+
} else {
|
|
838
|
+
this.state.doneSelectedIndex = Math.min(
|
|
839
|
+
this.state.doneSelectedIndex,
|
|
840
|
+
Math.max(0, this.state.doneTasks.length - 1)
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
this.render();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
private inputRow = 0;
|
|
851
|
+
private prevInputLineCount = 0;
|
|
852
|
+
|
|
853
|
+
private setupInputCursor(): void {
|
|
854
|
+
process.stdout.write(this.getCursorPosition() + ansi.showCursor);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private moveCursor(): void {
|
|
858
|
+
process.stdout.write(ansi.beginSync + this.getCursorPosition() + ansi.endSync);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Calculate visual row and column from cursor position in text with newlines
|
|
862
|
+
private getVisualCursorPos(): { row: number; col: number } {
|
|
863
|
+
const { inputBuffer, inputCursor } = this.state;
|
|
864
|
+
const maxWidth = this.width - 10;
|
|
865
|
+
|
|
866
|
+
let visualRow = 0;
|
|
867
|
+
let pos = 0;
|
|
868
|
+
|
|
869
|
+
// Walk through the text character by character
|
|
870
|
+
while (pos < inputCursor) {
|
|
871
|
+
if (inputBuffer[pos] === '\n') {
|
|
872
|
+
visualRow++;
|
|
873
|
+
pos++;
|
|
874
|
+
} else {
|
|
875
|
+
// Find the end of this line (next newline or end of text)
|
|
876
|
+
let lineStart = pos;
|
|
877
|
+
let lineEnd = inputBuffer.indexOf('\n', pos);
|
|
878
|
+
if (lineEnd === -1) lineEnd = inputBuffer.length;
|
|
879
|
+
const lineLen = lineEnd - lineStart;
|
|
880
|
+
|
|
881
|
+
// How many visual rows does this line take?
|
|
882
|
+
const visualLinesForThisLine = Math.max(1, Math.ceil(lineLen / maxWidth));
|
|
883
|
+
|
|
884
|
+
// Is cursor within this line?
|
|
885
|
+
if (inputCursor <= lineEnd) {
|
|
886
|
+
const posInLine = inputCursor - lineStart;
|
|
887
|
+
const extraRows = Math.floor(posInLine / maxWidth);
|
|
888
|
+
const col = posInLine % maxWidth;
|
|
889
|
+
return { row: visualRow + extraRows, col };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
visualRow += visualLinesForThisLine;
|
|
893
|
+
pos = lineEnd + 1; // Move past the newline
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Cursor at the very end after a newline
|
|
898
|
+
return { row: visualRow, col: 0 };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private getCursorPosition(): string {
|
|
902
|
+
const { row, col } = this.getVisualCursorPos();
|
|
903
|
+
const cursorRow = this.inputRow + row;
|
|
904
|
+
const cursorCol = 9 + col; // 2 indent + 2 star + 4 bracket = 8, plus 1 for 1-indexed
|
|
905
|
+
return ansi.cursorTo(cursorRow, cursorCol);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private redrawInputText(): void {
|
|
909
|
+
const { inputBuffer } = this.state;
|
|
910
|
+
const maxWidth = this.width - 10; // Account for " ★ [ ] " prefix (8 chars) + padding
|
|
911
|
+
|
|
912
|
+
// Wrap text into multiple lines
|
|
913
|
+
const wrappedLines = wrapText(inputBuffer, maxWidth);
|
|
914
|
+
if (wrappedLines.length === 0) wrappedLines.push('');
|
|
915
|
+
|
|
916
|
+
// Calculate cursor position using the newline-aware function
|
|
917
|
+
const { row, col } = this.getVisualCursorPos();
|
|
918
|
+
const cursorRow = this.inputRow + row;
|
|
919
|
+
const cursorCol = 9 + col;
|
|
920
|
+
|
|
921
|
+
// Redraw all wrapped lines
|
|
922
|
+
let output = ansi.beginSync;
|
|
923
|
+
wrappedLines.forEach((line, i) => {
|
|
924
|
+
const prefix = i === 0 ? ' [ ] ' : ' '; // 2 star area + 4 bracket or 6 spaces
|
|
925
|
+
const padding = ' '.repeat(Math.max(0, maxWidth - line.length));
|
|
926
|
+
output += ansi.cursorTo(this.inputRow + i, 1) +
|
|
927
|
+
`${ansi.bgGray} ${prefix}${ansi.black}${line}${padding} ${ansi.reset}`;
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// Clear any leftover lines from previous longer text
|
|
931
|
+
for (let i = wrappedLines.length; i < this.prevInputLineCount; i++) {
|
|
932
|
+
output += ansi.cursorTo(this.inputRow + i, 1) +
|
|
933
|
+
`${ansi.bgGray}${' '.repeat(this.width)}${ansi.reset}`;
|
|
934
|
+
}
|
|
935
|
+
this.prevInputLineCount = wrappedLines.length;
|
|
936
|
+
|
|
937
|
+
output += ansi.cursorTo(cursorRow, cursorCol) + ansi.endSync;
|
|
938
|
+
process.stdout.write(output);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private render(): void {
|
|
942
|
+
if (!this.running) return;
|
|
943
|
+
|
|
944
|
+
const lines: string[] = [];
|
|
945
|
+
const { tasks, selectedIndex, inputMode, editingTaskId, inputBuffer, inputCursor } = this.state;
|
|
946
|
+
|
|
947
|
+
// Use dimmed colors when unfocused
|
|
948
|
+
const bg = this.focused ? ansi.bgGray : ansi.dimBg;
|
|
949
|
+
const text = this.focused ? ansi.black : ansi.dimText;
|
|
950
|
+
const muted = this.focused ? ansi.gray : ansi.dimText;
|
|
951
|
+
const bold = this.focused ? ansi.bold : '';
|
|
952
|
+
|
|
953
|
+
// Fill with background color
|
|
954
|
+
const bgLine = `${bg}${' '.repeat(this.width)}${ansi.reset}`;
|
|
955
|
+
|
|
956
|
+
// Header padding
|
|
957
|
+
lines.push(bgLine);
|
|
958
|
+
|
|
959
|
+
// Repo and branch at top (from statusline if available, else fallback)
|
|
960
|
+
const { statusline } = this.state;
|
|
961
|
+
let branch = statusline?.branch || '';
|
|
962
|
+
let repo = statusline?.repo || '';
|
|
963
|
+
if (!branch) {
|
|
964
|
+
try {
|
|
965
|
+
branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
966
|
+
} catch {}
|
|
967
|
+
}
|
|
968
|
+
if (!repo) {
|
|
969
|
+
const cwd = process.cwd();
|
|
970
|
+
const parts = cwd.split('/').filter(Boolean);
|
|
971
|
+
repo = parts[parts.length - 1] || cwd;
|
|
972
|
+
}
|
|
973
|
+
const branchDisplay = branch ? `${branch}` : '';
|
|
974
|
+
const repoDisplay = repo ? `${repo}` : '';
|
|
975
|
+
const headerContent = branchDisplay && repoDisplay
|
|
976
|
+
? `${repoDisplay} · ${branchDisplay}`
|
|
977
|
+
: repoDisplay || branchDisplay;
|
|
978
|
+
lines.push(`${bg} ${text}${headerContent}${ansi.clearToEnd}${ansi.reset}`);
|
|
979
|
+
lines.push(bgLine); // Space after header
|
|
980
|
+
|
|
981
|
+
// In Progress section - combines sidebar active task + Claude's TodoWrite items
|
|
982
|
+
const { claudeTodos } = this.state;
|
|
983
|
+
const { activeTask, doneTasks } = this.state;
|
|
984
|
+
const activeTodos = claudeTodos.filter(t => t.status !== "completed");
|
|
985
|
+
// Content width: total width - 2 (margin) - 4 (indicator like "[ ] ") - 2 (right padding)
|
|
986
|
+
const maxContentWidth = this.width - 8;
|
|
987
|
+
|
|
988
|
+
// Always show In Progress section
|
|
989
|
+
lines.push(`${bg} ${bold}${text}In Progress${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
990
|
+
|
|
991
|
+
// Show sidebar active task first (sent from queue)
|
|
992
|
+
if (activeTask) {
|
|
993
|
+
const content = activeTask.content.slice(0, maxContentWidth);
|
|
994
|
+
lines.push(`${bg} ${ansi.green}▸ ${content}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Show Claude's TodoWrite items (what Claude is tracking)
|
|
998
|
+
activeTodos.forEach((todo) => {
|
|
999
|
+
let statusIcon: string;
|
|
1000
|
+
let todoColor = text;
|
|
1001
|
+
if (todo.status === "in_progress") {
|
|
1002
|
+
statusIcon = "● ";
|
|
1003
|
+
todoColor = ansi.green;
|
|
1004
|
+
} else {
|
|
1005
|
+
statusIcon = "○ ";
|
|
1006
|
+
}
|
|
1007
|
+
const content = todo.content.slice(0, maxContentWidth);
|
|
1008
|
+
lines.push(`${bg} ${todoColor}${statusIcon}${content}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
lines.push(bgLine);
|
|
1012
|
+
|
|
1013
|
+
// Review section (tasks Claude thinks are done, awaiting user confirmation)
|
|
1014
|
+
const { selectedSection, doneSelectedIndex } = this.state;
|
|
1015
|
+
if (doneTasks.length > 0) {
|
|
1016
|
+
lines.push(`${bg} ${bold}${text}Review (${doneTasks.length})${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1017
|
+
doneTasks.slice(0, 5).forEach((task, index) => {
|
|
1018
|
+
const isSelected = selectedSection === "done" && index === doneSelectedIndex && this.focused;
|
|
1019
|
+
const content = task.content.slice(0, maxContentWidth);
|
|
1020
|
+
const icon = isSelected ? "[?] " : " ? ";
|
|
1021
|
+
const color = isSelected ? text : muted;
|
|
1022
|
+
lines.push(`${bg} ${color}${icon}${content}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1023
|
+
});
|
|
1024
|
+
lines.push(bgLine);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// To-dos section - single flat list sorted by priority
|
|
1028
|
+
const sortedTasks = this.getSortedTasks();
|
|
1029
|
+
|
|
1030
|
+
// Track where the input line is for cursor positioning
|
|
1031
|
+
let inputLineRow = 0;
|
|
1032
|
+
|
|
1033
|
+
// Helper to render a task
|
|
1034
|
+
// Design: ★ for recommended, [>] for selected, [ ] for unselected
|
|
1035
|
+
// Clarified tasks show planPath on second line when selected
|
|
1036
|
+
const renderTask = (task: Task, index: number) => {
|
|
1037
|
+
const isSelected = selectedSection === "queue" && index === selectedIndex;
|
|
1038
|
+
const isEditing = inputMode === "edit" && editingTaskId === task.id;
|
|
1039
|
+
const star = task.recommended ? "★ " : " ";
|
|
1040
|
+
const bracket = (isSelected && this.focused) ? "[>] " : "[ ] ";
|
|
1041
|
+
const color = task.clarified ? text : muted;
|
|
1042
|
+
|
|
1043
|
+
if (isEditing) {
|
|
1044
|
+
inputLineRow = lines.length + 1;
|
|
1045
|
+
this.inputRow = inputLineRow;
|
|
1046
|
+
const wrappedLines = wrapText(inputBuffer, maxContentWidth - 2); // Account for star
|
|
1047
|
+
if (wrappedLines.length === 0) wrappedLines.push('');
|
|
1048
|
+
wrappedLines.forEach((line, i) => {
|
|
1049
|
+
const prefix = i === 0 ? `${star}${bracket}` : " "; // 6 spaces to align with text
|
|
1050
|
+
const padding = ' '.repeat(Math.max(0, maxContentWidth - 2 - line.length));
|
|
1051
|
+
lines.push(`${bg} ${prefix}${text}${line}${padding}${ansi.reset}`);
|
|
1052
|
+
});
|
|
1053
|
+
} else if (isSelected && this.focused && (task.content.length > maxContentWidth - 2 || task.content.includes('\n'))) {
|
|
1054
|
+
// Wrap long content or content with newlines when selected
|
|
1055
|
+
const wrappedLines = wrapText(task.content, maxContentWidth - 2);
|
|
1056
|
+
wrappedLines.forEach((line, i) => {
|
|
1057
|
+
const prefix = i === 0 ? `${star}${bracket}` : " ";
|
|
1058
|
+
lines.push(`${bg} ${color}${prefix}${line}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1059
|
+
});
|
|
1060
|
+
} else {
|
|
1061
|
+
// For non-selected or short content without newlines, show first line only
|
|
1062
|
+
const firstLine = task.content.split('\n')[0] || task.content;
|
|
1063
|
+
const content = firstLine.slice(0, maxContentWidth - 2);
|
|
1064
|
+
lines.push(`${bg} ${color}${star}${bracket}${content}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Show plan path on second line when selected and has planPath
|
|
1068
|
+
if (isSelected && this.focused && task.planPath && !isEditing) {
|
|
1069
|
+
const planDisplay = `→ ${task.planPath}`.slice(0, maxContentWidth - 2);
|
|
1070
|
+
lines.push(`${bg} ${muted} ${planDisplay}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// Render To-dos header and tasks
|
|
1075
|
+
const todoCount = sortedTasks.length > 0 ? ` (${sortedTasks.length})` : '';
|
|
1076
|
+
lines.push(`${bg} ${bold}${text}To-dos${todoCount}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1077
|
+
sortedTasks.forEach((task, index) => {
|
|
1078
|
+
renderTask(task, index);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// Add new task input
|
|
1082
|
+
if (inputMode === "add") {
|
|
1083
|
+
inputLineRow = lines.length + 1; // 1-indexed row number
|
|
1084
|
+
this.inputRow = inputLineRow; // Store for cursor positioning
|
|
1085
|
+
const wrappedLines = wrapText(inputBuffer, maxContentWidth - 2); // Account for star area
|
|
1086
|
+
if (wrappedLines.length === 0) wrappedLines.push('');
|
|
1087
|
+
wrappedLines.forEach((line, i) => {
|
|
1088
|
+
const prefix = i === 0 ? ' [ ] ' : ' '; // 2 space star area + 4 char bracket
|
|
1089
|
+
const padding = ' '.repeat(Math.max(0, maxContentWidth - 2 - line.length));
|
|
1090
|
+
lines.push(`${bg} ${prefix}${text}${line}${padding}${ansi.reset}`);
|
|
1091
|
+
});
|
|
1092
|
+
} else if (this.focused) {
|
|
1093
|
+
// Show hint to add task (only when focused)
|
|
1094
|
+
lines.push(`${bg} ${ansi.gray} [ ] press a to add${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Fill remaining space
|
|
1098
|
+
const contentHeight = lines.length;
|
|
1099
|
+
const footerHeight = statusline ? 4 : 3;
|
|
1100
|
+
const remainingHeight = this.height - contentHeight - footerHeight;
|
|
1101
|
+
for (let i = 0; i < remainingHeight; i++) {
|
|
1102
|
+
lines.push(bgLine);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Footer - context-aware help
|
|
1106
|
+
let helpText: string;
|
|
1107
|
+
if (inputMode !== "none") {
|
|
1108
|
+
helpText = "↵: submit | ⇧↵: newline | Esc: cancel";
|
|
1109
|
+
} else if (selectedSection === "done") {
|
|
1110
|
+
helpText = "d: done | r: return to progress | ↑↓: navigate";
|
|
1111
|
+
} else {
|
|
1112
|
+
helpText = "a: add | e: edit | d: del | ↵: send | c: clarify";
|
|
1113
|
+
}
|
|
1114
|
+
lines.push(`${bg} ${muted}${helpText}${ansi.reset}${bg}${ansi.clearToEnd}${ansi.reset}`);
|
|
1115
|
+
lines.push(bgLine);
|
|
1116
|
+
|
|
1117
|
+
// Context metadata at bottom (if available from Claude Code)
|
|
1118
|
+
if (statusline) {
|
|
1119
|
+
// Color-code context based on usage level
|
|
1120
|
+
const ctxPercent = statusline.contextPercent;
|
|
1121
|
+
const ctxColor = ctxPercent >= 80 ? ansi.red : ctxPercent >= 60 ? ansi.yellow : ansi.green;
|
|
1122
|
+
|
|
1123
|
+
// Visual progress bar (10 chars wide)
|
|
1124
|
+
const barWidth = 10;
|
|
1125
|
+
const filledCount = Math.round((ctxPercent / 100) * barWidth);
|
|
1126
|
+
const emptyCount = barWidth - filledCount;
|
|
1127
|
+
const progressBar = '█'.repeat(filledCount) + '░'.repeat(emptyCount);
|
|
1128
|
+
|
|
1129
|
+
const ctxDisplay = `${ctxColor}${progressBar}${ansi.reset}${bg} ${text}${ctxPercent}%`;
|
|
1130
|
+
const costDisplay = `$${statusline.costUsd.toFixed(2)}`;
|
|
1131
|
+
const durationDisplay = `${statusline.durationMin}m`;
|
|
1132
|
+
const statusInfo = `${ctxDisplay} ${costDisplay} ${durationDisplay}`;
|
|
1133
|
+
lines.push(`${bg} ${statusInfo}${ansi.clearToEnd}${ansi.reset}`);
|
|
1134
|
+
}
|
|
1135
|
+
lines.push(bgLine); // Bottom padding
|
|
1136
|
+
|
|
1137
|
+
// Output everything at once with synchronized output to prevent partial renders
|
|
1138
|
+
let output = '\x1b[?2026h' + ansi.cursorHome + lines.join('\n');
|
|
1139
|
+
|
|
1140
|
+
// Position cursor and show it if in input mode, otherwise hide it
|
|
1141
|
+
if (inputMode !== "none" && inputLineRow > 0) {
|
|
1142
|
+
// Calculate which visual line the cursor is on
|
|
1143
|
+
const visualLine = Math.floor(inputCursor / maxContentWidth);
|
|
1144
|
+
const col = inputCursor % maxContentWidth;
|
|
1145
|
+
const cursorRow = inputLineRow + visualLine;
|
|
1146
|
+
const cursorCol = 7 + col;
|
|
1147
|
+
output += ansi.cursorTo(cursorRow, cursorCol) + ansi.showCursor;
|
|
1148
|
+
} else {
|
|
1149
|
+
output += ansi.hideCursor;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
output += '\x1b[?2026l';
|
|
1153
|
+
process.stdout.write(output);
|
|
1154
|
+
}
|
|
1155
|
+
}
|