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