revspec 0.7.3 → 0.8.1
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/README.md +3 -3
- package/package.json +1 -1
- package/src/state/review-state.ts +30 -0
- package/src/tui/app.ts +203 -21
- package/src/tui/comment-input.ts +24 -11
- package/src/tui/confirm.ts +3 -3
- package/src/tui/help.ts +10 -4
- package/src/tui/pager.ts +4 -4
- package/src/tui/spinner.ts +3 -1
- package/src/tui/status-bar.ts +32 -5
- package/src/tui/thread-list.ts +142 -88
- package/src/tui/ui/dialog.ts +2 -1
- package/src/tui/ui/keymap.ts +1 -0
- package/src/tui/ui/theme.ts +3 -3
package/README.md
CHANGED
|
@@ -30,11 +30,11 @@ Install the `/revspec` skill for Claude Code:
|
|
|
30
30
|
/plugin install revspec
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Or manually (symlink, stays updated with
|
|
33
|
+
Or manually (clone + symlink, stays updated with `git pull`):
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
|
|
37
|
-
ln -sfn /
|
|
36
|
+
git clone https://github.com/icyrainz/revspec.git ~/.local/share/revspec
|
|
37
|
+
ln -sfn ~/.local/share/revspec/skills/revspec ~/.claude/skills/revspec
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
## Usage
|
package/package.json
CHANGED
|
@@ -113,6 +113,36 @@ export class ReviewState {
|
|
|
113
113
|
return this.threads.reduce((max, t) => (t.line > max.line ? t : max)).line;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
nextHeading(level: number): number | null {
|
|
117
|
+
const prefix = "#".repeat(level) + " ";
|
|
118
|
+
const guard = "#".repeat(level + 1);
|
|
119
|
+
for (let i = this.cursorLine; i < this.specLines.length; i++) {
|
|
120
|
+
const line = this.specLines[i];
|
|
121
|
+
if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
|
|
122
|
+
}
|
|
123
|
+
// Wrap: search from top
|
|
124
|
+
for (let i = 0; i < this.cursorLine - 1; i++) {
|
|
125
|
+
const line = this.specLines[i];
|
|
126
|
+
if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
prevHeading(level: number): number | null {
|
|
132
|
+
const prefix = "#".repeat(level) + " ";
|
|
133
|
+
const guard = "#".repeat(level + 1);
|
|
134
|
+
for (let i = this.cursorLine - 2; i >= 0; i--) {
|
|
135
|
+
const line = this.specLines[i];
|
|
136
|
+
if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
|
|
137
|
+
}
|
|
138
|
+
// Wrap: search from bottom
|
|
139
|
+
for (let i = this.specLines.length - 1; i >= this.cursorLine; i--) {
|
|
140
|
+
const line = this.specLines[i];
|
|
141
|
+
if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
116
146
|
canApprove(): boolean {
|
|
117
147
|
// No threads = clean approval (spec is good as-is)
|
|
118
148
|
if (this.threads.length === 0) return true;
|
package/src/tui/app.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { createConfirm } from "./confirm";
|
|
|
27
27
|
import { createHelp } from "./help";
|
|
28
28
|
import { createSpinner } from "./spinner";
|
|
29
29
|
import { createKeybindRegistry, type KeyBinding } from "./ui/keybinds";
|
|
30
|
+
import { theme } from "./ui/theme";
|
|
30
31
|
|
|
31
32
|
export async function runTui(
|
|
32
33
|
specFile: string,
|
|
@@ -123,6 +124,40 @@ export async function runTui(
|
|
|
123
124
|
// Command mode state
|
|
124
125
|
let commandBuffer: string | null = null;
|
|
125
126
|
|
|
127
|
+
// Jump list — mirrors vim's :jumps behavior.
|
|
128
|
+
// pushJump() is called BEFORE each big jump to record the departure position.
|
|
129
|
+
// Ctrl+O traverses backward, Ctrl+I forward. Making a new jump while in the
|
|
130
|
+
// middle of the list discards forward history (same as vim).
|
|
131
|
+
const jumpList: number[] = [1];
|
|
132
|
+
let jumpIndex: number = 0;
|
|
133
|
+
const MAX_JUMP_LIST = 50;
|
|
134
|
+
|
|
135
|
+
function pushJump(): void {
|
|
136
|
+
const cur = state.cursorLine;
|
|
137
|
+
// Discard forward history when making a new jump from the middle
|
|
138
|
+
if (jumpIndex < jumpList.length - 1) {
|
|
139
|
+
jumpList.splice(jumpIndex + 1);
|
|
140
|
+
}
|
|
141
|
+
// Don't push duplicate of the list tail
|
|
142
|
+
if (jumpList.length > 0 && jumpList[jumpList.length - 1] === cur) return;
|
|
143
|
+
jumpList.push(cur);
|
|
144
|
+
if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
|
|
145
|
+
jumpIndex = jumpList.length - 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function savePrevPosition(): void {
|
|
149
|
+
pushJump();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Map visual row back to spec line number (for H/M/L)
|
|
153
|
+
function visualRowToSpecLine(targetRow: number): number {
|
|
154
|
+
for (let i = 0; i < state.specLines.length; i++) {
|
|
155
|
+
const row = i + countExtraVisualLines(state.specLines, i);
|
|
156
|
+
if (row >= targetRow) return i + 1;
|
|
157
|
+
}
|
|
158
|
+
return state.lineCount;
|
|
159
|
+
}
|
|
160
|
+
|
|
126
161
|
// Active spec poll interval (for submit spinner leak prevention)
|
|
127
162
|
let activeSpecPoll: ReturnType<typeof setInterval> | null = null;
|
|
128
163
|
|
|
@@ -188,7 +223,7 @@ export async function runTui(
|
|
|
188
223
|
const { open, pending } = state.activeThreadCount();
|
|
189
224
|
const total = open + pending;
|
|
190
225
|
if (total > 0) {
|
|
191
|
-
setBottomBarMessage(bottomBar,
|
|
226
|
+
setBottomBarMessage(bottomBar, `${total} unresolved thread(s). Use :q! to force quit`, "warn");
|
|
192
227
|
renderer.requestRender();
|
|
193
228
|
setTimeout(() => { refreshPager(); }, 2000);
|
|
194
229
|
return "stay";
|
|
@@ -203,12 +238,13 @@ export async function runTui(
|
|
|
203
238
|
// :{N} — jump to line number
|
|
204
239
|
const lineNum = parseInt(cmd, 10);
|
|
205
240
|
if (!isNaN(lineNum) && lineNum > 0) {
|
|
241
|
+
savePrevPosition();
|
|
206
242
|
state.cursorLine = Math.min(lineNum, state.lineCount);
|
|
207
243
|
ensureCursorVisible();
|
|
208
244
|
refreshPager();
|
|
209
245
|
return "stay";
|
|
210
246
|
}
|
|
211
|
-
setBottomBarMessage(bottomBar, `
|
|
247
|
+
setBottomBarMessage(bottomBar, `Unknown command: ${cmd}`, "warn");
|
|
212
248
|
renderer.requestRender();
|
|
213
249
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
214
250
|
return "stay";
|
|
@@ -257,6 +293,7 @@ export async function runTui(
|
|
|
257
293
|
dismissOverlay();
|
|
258
294
|
},
|
|
259
295
|
onCancel: () => {
|
|
296
|
+
if (existingThread) state.markRead(existingThread.id);
|
|
260
297
|
dismissOverlay();
|
|
261
298
|
},
|
|
262
299
|
});
|
|
@@ -271,6 +308,7 @@ export async function runTui(
|
|
|
271
308
|
cursorLine: state.cursorLine,
|
|
272
309
|
onResult: (lineNumber: number, query: string) => {
|
|
273
310
|
searchQuery = query;
|
|
311
|
+
savePrevPosition();
|
|
274
312
|
state.cursorLine = lineNumber;
|
|
275
313
|
dismissOverlay();
|
|
276
314
|
ensureCursorVisible();
|
|
@@ -289,6 +327,7 @@ export async function runTui(
|
|
|
289
327
|
renderer,
|
|
290
328
|
threads: state.threads,
|
|
291
329
|
onSelect: (lineNumber: number) => {
|
|
330
|
+
savePrevPosition();
|
|
292
331
|
state.cursorLine = lineNumber;
|
|
293
332
|
dismissOverlay();
|
|
294
333
|
ensureCursorVisible();
|
|
@@ -390,6 +429,16 @@ export async function runTui(
|
|
|
390
429
|
{ key: "[t", action: "prev-thread" },
|
|
391
430
|
{ key: "]r", action: "next-unread" },
|
|
392
431
|
{ key: "[r", action: "prev-unread" },
|
|
432
|
+
{ key: "]1", action: "next-h1" },
|
|
433
|
+
{ key: "[1", action: "prev-h1" },
|
|
434
|
+
{ key: "]2", action: "next-h2" },
|
|
435
|
+
{ key: "[2", action: "prev-h2" },
|
|
436
|
+
{ key: "]3", action: "next-h3" },
|
|
437
|
+
{ key: "[3", action: "prev-h3" },
|
|
438
|
+
{ key: "''", action: "jump-back" },
|
|
439
|
+
{ key: "H", action: "screen-top" },
|
|
440
|
+
{ key: "M", action: "screen-middle" },
|
|
441
|
+
{ key: "L", action: "screen-bottom" },
|
|
393
442
|
{ key: "zz", action: "center-cursor" },
|
|
394
443
|
{ key: "?", action: "help" },
|
|
395
444
|
{ key: "/", action: "search" },
|
|
@@ -398,6 +447,10 @@ export async function runTui(
|
|
|
398
447
|
const keybinds = createKeybindRegistry(bindings, 300);
|
|
399
448
|
|
|
400
449
|
refreshPager();
|
|
450
|
+
if (state.threads.length === 0) {
|
|
451
|
+
setBottomBarMessage(bottomBar, "Navigate to a line and press c to start reviewing", "info");
|
|
452
|
+
renderer.requestRender();
|
|
453
|
+
}
|
|
401
454
|
renderer.start();
|
|
402
455
|
|
|
403
456
|
// 8. Set up keybinding handler
|
|
@@ -460,6 +513,38 @@ export async function runTui(
|
|
|
460
513
|
return;
|
|
461
514
|
}
|
|
462
515
|
|
|
516
|
+
// Ctrl+O: jump back in jump list
|
|
517
|
+
if (key.ctrl && key.name === "o") {
|
|
518
|
+
// Starting backward traversal from head — save current position first
|
|
519
|
+
// (without splicing forward history, unlike pushJump)
|
|
520
|
+
if (jumpIndex === jumpList.length - 1) {
|
|
521
|
+
const cur = state.cursorLine;
|
|
522
|
+
if (jumpList[jumpIndex] !== cur) {
|
|
523
|
+
jumpList.push(cur);
|
|
524
|
+
if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
|
|
525
|
+
jumpIndex = jumpList.length - 1;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (jumpIndex > 0) {
|
|
529
|
+
jumpIndex--;
|
|
530
|
+
state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
|
|
531
|
+
ensureCursorVisible();
|
|
532
|
+
refreshPager();
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Ctrl+I / Tab: jump forward in jump list
|
|
538
|
+
if ((key.ctrl && key.name === "i") || key.name === "tab") {
|
|
539
|
+
if (jumpIndex < jumpList.length - 1) {
|
|
540
|
+
jumpIndex++;
|
|
541
|
+
state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
|
|
542
|
+
ensureCursorVisible();
|
|
543
|
+
refreshPager();
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
463
548
|
// Escape clears search highlights
|
|
464
549
|
if (key.name === "escape") {
|
|
465
550
|
if (searchQuery) {
|
|
@@ -512,11 +597,13 @@ export async function runTui(
|
|
|
512
597
|
break;
|
|
513
598
|
}
|
|
514
599
|
case "goto-bottom":
|
|
600
|
+
savePrevPosition();
|
|
515
601
|
state.cursorLine = state.lineCount;
|
|
516
602
|
ensureCursorVisible();
|
|
517
603
|
refreshPager();
|
|
518
604
|
break;
|
|
519
605
|
case "goto-top":
|
|
606
|
+
savePrevPosition();
|
|
520
607
|
state.cursorLine = 1;
|
|
521
608
|
ensureCursorVisible();
|
|
522
609
|
refreshPager();
|
|
@@ -533,29 +620,47 @@ export async function runTui(
|
|
|
533
620
|
if (searchQuery) {
|
|
534
621
|
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
|
|
535
622
|
if (match !== null) {
|
|
623
|
+
const wrapped = match <= state.cursorLine;
|
|
624
|
+
savePrevPosition();
|
|
536
625
|
state.cursorLine = match;
|
|
537
626
|
ensureCursorVisible();
|
|
627
|
+
refreshPager();
|
|
628
|
+
if (wrapped) {
|
|
629
|
+
setBottomBarMessage(bottomBar, "Search wrapped to top", "info");
|
|
630
|
+
renderer.requestRender();
|
|
631
|
+
setTimeout(() => { refreshPager(); }, 1200);
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
refreshPager();
|
|
538
635
|
}
|
|
539
636
|
} else {
|
|
540
|
-
setBottomBarMessage(bottomBar, "
|
|
637
|
+
setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
|
|
541
638
|
renderer.requestRender();
|
|
542
639
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
543
640
|
}
|
|
544
|
-
refreshPager();
|
|
545
641
|
break;
|
|
546
642
|
case "search-prev":
|
|
547
643
|
if (searchQuery) {
|
|
548
644
|
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
|
|
549
645
|
if (match !== null) {
|
|
646
|
+
const wrapped = match >= state.cursorLine;
|
|
647
|
+
savePrevPosition();
|
|
550
648
|
state.cursorLine = match;
|
|
551
649
|
ensureCursorVisible();
|
|
650
|
+
refreshPager();
|
|
651
|
+
if (wrapped) {
|
|
652
|
+
setBottomBarMessage(bottomBar, "Search wrapped to bottom", "info");
|
|
653
|
+
renderer.requestRender();
|
|
654
|
+
setTimeout(() => { refreshPager(); }, 1200);
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
refreshPager();
|
|
552
658
|
}
|
|
553
659
|
} else {
|
|
554
|
-
setBottomBarMessage(bottomBar, "
|
|
660
|
+
setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
|
|
555
661
|
renderer.requestRender();
|
|
556
662
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
557
663
|
}
|
|
558
|
-
refreshPager();
|
|
559
664
|
break;
|
|
560
665
|
case "comment":
|
|
561
666
|
showCommentInput();
|
|
@@ -571,14 +676,13 @@ export async function runTui(
|
|
|
571
676
|
state.markRead(thread.id);
|
|
572
677
|
appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
573
678
|
refreshPager();
|
|
574
|
-
|
|
575
|
-
? `
|
|
576
|
-
|
|
577
|
-
setBottomBarMessage(bottomBar, msg);
|
|
679
|
+
setBottomBarMessage(bottomBar,
|
|
680
|
+
wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
|
|
681
|
+
"success");
|
|
578
682
|
renderer.requestRender();
|
|
579
683
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
580
684
|
} else {
|
|
581
|
-
setBottomBarMessage(bottomBar, "
|
|
685
|
+
setBottomBarMessage(bottomBar, "No thread on this line");
|
|
582
686
|
renderer.requestRender();
|
|
583
687
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
584
688
|
}
|
|
@@ -592,7 +696,7 @@ export async function runTui(
|
|
|
592
696
|
appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
|
|
593
697
|
}
|
|
594
698
|
refreshPager();
|
|
595
|
-
setBottomBarMessage(bottomBar, `
|
|
699
|
+
setBottomBarMessage(bottomBar, `Resolved ${pending} pending thread(s)`, "success");
|
|
596
700
|
renderer.requestRender();
|
|
597
701
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
598
702
|
break;
|
|
@@ -600,7 +704,7 @@ export async function runTui(
|
|
|
600
704
|
case "delete-draft": {
|
|
601
705
|
const thread = state.threadAtLine(state.cursorLine);
|
|
602
706
|
if (!thread) {
|
|
603
|
-
setBottomBarMessage(bottomBar, "
|
|
707
|
+
setBottomBarMessage(bottomBar, "No thread on this line");
|
|
604
708
|
renderer.requestRender();
|
|
605
709
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
606
710
|
break;
|
|
@@ -614,7 +718,7 @@ export async function runTui(
|
|
|
614
718
|
state.deleteThread(thread.id);
|
|
615
719
|
appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
616
720
|
refreshPager();
|
|
617
|
-
setBottomBarMessage(bottomBar, `
|
|
721
|
+
setBottomBarMessage(bottomBar, `Deleted thread #${thread.id}`, "success");
|
|
618
722
|
renderer.requestRender();
|
|
619
723
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
620
724
|
},
|
|
@@ -627,16 +731,17 @@ export async function runTui(
|
|
|
627
731
|
}
|
|
628
732
|
case "submit":
|
|
629
733
|
if (state.threads.length === 0) {
|
|
630
|
-
setBottomBarMessage(bottomBar, "
|
|
734
|
+
setBottomBarMessage(bottomBar, "No threads to submit.");
|
|
631
735
|
renderer.requestRender();
|
|
632
736
|
break;
|
|
633
737
|
}
|
|
634
738
|
unresolvedGate(() => {
|
|
635
739
|
appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
|
|
636
740
|
|
|
741
|
+
const count = state.threads.length;
|
|
637
742
|
const spinnerOverlay = createSpinner({
|
|
638
743
|
renderer,
|
|
639
|
-
message:
|
|
744
|
+
message: `Submitting ${count} thread${count === 1 ? "" : "s"}...`,
|
|
640
745
|
onCancel: () => {
|
|
641
746
|
clearInterval(activeSpecPoll!);
|
|
642
747
|
activeSpecPoll = null;
|
|
@@ -646,7 +751,7 @@ export async function runTui(
|
|
|
646
751
|
clearInterval(activeSpecPoll!);
|
|
647
752
|
activeSpecPoll = null;
|
|
648
753
|
dismissOverlay();
|
|
649
|
-
setBottomBarMessage(bottomBar, "
|
|
754
|
+
setBottomBarMessage(bottomBar, "Agent did not update spec. Press S to retry.", "warn");
|
|
650
755
|
renderer.requestRender();
|
|
651
756
|
setTimeout(() => { refreshPager(); }, 3000);
|
|
652
757
|
},
|
|
@@ -669,6 +774,9 @@ export async function runTui(
|
|
|
669
774
|
searchQuery = null;
|
|
670
775
|
ensureCursorVisible();
|
|
671
776
|
refreshPager();
|
|
777
|
+
setBottomBarMessage(bottomBar, "Spec rewritten \u2014 review cleared", "success");
|
|
778
|
+
renderer.requestRender();
|
|
779
|
+
setTimeout(() => { refreshPager(); }, 2500);
|
|
672
780
|
}
|
|
673
781
|
} catch {}
|
|
674
782
|
}, 500);
|
|
@@ -693,11 +801,12 @@ export async function runTui(
|
|
|
693
801
|
case "next-thread": {
|
|
694
802
|
const next = state.nextThread();
|
|
695
803
|
if (next !== null) {
|
|
804
|
+
savePrevPosition();
|
|
696
805
|
state.cursorLine = next;
|
|
697
806
|
ensureCursorVisible();
|
|
698
807
|
refreshPager();
|
|
699
808
|
} else {
|
|
700
|
-
setBottomBarMessage(bottomBar, "
|
|
809
|
+
setBottomBarMessage(bottomBar, "No threads");
|
|
701
810
|
renderer.requestRender();
|
|
702
811
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
703
812
|
}
|
|
@@ -706,11 +815,12 @@ export async function runTui(
|
|
|
706
815
|
case "prev-thread": {
|
|
707
816
|
const prev = state.prevThread();
|
|
708
817
|
if (prev !== null) {
|
|
818
|
+
savePrevPosition();
|
|
709
819
|
state.cursorLine = prev;
|
|
710
820
|
ensureCursorVisible();
|
|
711
821
|
refreshPager();
|
|
712
822
|
} else {
|
|
713
|
-
setBottomBarMessage(bottomBar, "
|
|
823
|
+
setBottomBarMessage(bottomBar, "No threads");
|
|
714
824
|
renderer.requestRender();
|
|
715
825
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
716
826
|
}
|
|
@@ -719,11 +829,12 @@ export async function runTui(
|
|
|
719
829
|
case "next-unread": {
|
|
720
830
|
const nextLine = state.nextUnreadThread();
|
|
721
831
|
if (nextLine !== null) {
|
|
832
|
+
savePrevPosition();
|
|
722
833
|
state.cursorLine = nextLine;
|
|
723
834
|
ensureCursorVisible();
|
|
724
835
|
refreshPager();
|
|
725
836
|
} else {
|
|
726
|
-
setBottomBarMessage(bottomBar, "
|
|
837
|
+
setBottomBarMessage(bottomBar, "No unread replies");
|
|
727
838
|
renderer.requestRender();
|
|
728
839
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
729
840
|
}
|
|
@@ -732,16 +843,87 @@ export async function runTui(
|
|
|
732
843
|
case "prev-unread": {
|
|
733
844
|
const prevLine = state.prevUnreadThread();
|
|
734
845
|
if (prevLine !== null) {
|
|
846
|
+
savePrevPosition();
|
|
735
847
|
state.cursorLine = prevLine;
|
|
736
848
|
ensureCursorVisible();
|
|
737
849
|
refreshPager();
|
|
738
850
|
} else {
|
|
739
|
-
setBottomBarMessage(bottomBar, "
|
|
851
|
+
setBottomBarMessage(bottomBar, "No unread replies");
|
|
852
|
+
renderer.requestRender();
|
|
853
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
854
|
+
}
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
case "next-h1":
|
|
858
|
+
case "next-h2":
|
|
859
|
+
case "next-h3": {
|
|
860
|
+
const level = parseInt(action.slice(-1));
|
|
861
|
+
const next = state.nextHeading(level);
|
|
862
|
+
if (next !== null) {
|
|
863
|
+
savePrevPosition();
|
|
864
|
+
state.cursorLine = next;
|
|
865
|
+
ensureCursorVisible();
|
|
866
|
+
refreshPager();
|
|
867
|
+
} else {
|
|
868
|
+
setBottomBarMessage(bottomBar, `No h${level} headings`);
|
|
740
869
|
renderer.requestRender();
|
|
741
870
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
742
871
|
}
|
|
743
872
|
break;
|
|
744
873
|
}
|
|
874
|
+
case "prev-h1":
|
|
875
|
+
case "prev-h2":
|
|
876
|
+
case "prev-h3": {
|
|
877
|
+
const level = parseInt(action.slice(-1));
|
|
878
|
+
const prev = state.prevHeading(level);
|
|
879
|
+
if (prev !== null) {
|
|
880
|
+
savePrevPosition();
|
|
881
|
+
state.cursorLine = prev;
|
|
882
|
+
ensureCursorVisible();
|
|
883
|
+
refreshPager();
|
|
884
|
+
} else {
|
|
885
|
+
setBottomBarMessage(bottomBar, `No h${level} headings`);
|
|
886
|
+
renderer.requestRender();
|
|
887
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
888
|
+
}
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
case "jump-back": {
|
|
892
|
+
// '' swaps between current position and last jump entry
|
|
893
|
+
if (jumpList.length > 1) {
|
|
894
|
+
const cur = state.cursorLine;
|
|
895
|
+
const prevIdx = jumpIndex > 0 ? jumpIndex - 1 : 0;
|
|
896
|
+
const target = jumpList[prevIdx];
|
|
897
|
+
// Record current position at our spot so '' can swap back
|
|
898
|
+
jumpList[jumpIndex] = cur;
|
|
899
|
+
jumpIndex = prevIdx;
|
|
900
|
+
state.cursorLine = Math.min(target, state.lineCount);
|
|
901
|
+
ensureCursorVisible();
|
|
902
|
+
refreshPager();
|
|
903
|
+
}
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
case "screen-top": {
|
|
907
|
+
const topRow = pager.scrollBox.scrollTop;
|
|
908
|
+
savePrevPosition();
|
|
909
|
+
state.cursorLine = visualRowToSpecLine(topRow);
|
|
910
|
+
refreshPager();
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
case "screen-middle": {
|
|
914
|
+
const midRow = pager.scrollBox.scrollTop + Math.floor(pageSize() / 2);
|
|
915
|
+
savePrevPosition();
|
|
916
|
+
state.cursorLine = visualRowToSpecLine(midRow);
|
|
917
|
+
refreshPager();
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
case "screen-bottom": {
|
|
921
|
+
const botRow = pager.scrollBox.scrollTop + pageSize() - 1;
|
|
922
|
+
savePrevPosition();
|
|
923
|
+
state.cursorLine = visualRowToSpecLine(botRow);
|
|
924
|
+
refreshPager();
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
745
927
|
case "help":
|
|
746
928
|
showHelpOverlay();
|
|
747
929
|
break;
|
package/src/tui/comment-input.ts
CHANGED
|
@@ -168,8 +168,10 @@ function createThreadView(
|
|
|
168
168
|
const dialog = createDialog({
|
|
169
169
|
renderer,
|
|
170
170
|
title,
|
|
171
|
-
width: "
|
|
172
|
-
height: "
|
|
171
|
+
width: "70%",
|
|
172
|
+
height: "80%",
|
|
173
|
+
top: "8%",
|
|
174
|
+
left: "15%",
|
|
173
175
|
borderColor: theme.blue,
|
|
174
176
|
onDismiss: onCancel,
|
|
175
177
|
hints: insertHints,
|
|
@@ -220,18 +222,29 @@ function createThreadView(
|
|
|
220
222
|
scrollBox.add(renderMessage(msg));
|
|
221
223
|
}
|
|
222
224
|
|
|
223
|
-
// ---
|
|
224
|
-
|
|
225
|
-
|
|
225
|
+
// --- Layout: pack bottom elements into a single container ---
|
|
226
|
+
// Outside tmux, opentui's ScrollBox expands over intermediate siblings.
|
|
227
|
+
// Keeping exactly 2 direct children (ScrollBox + bottomPanel) avoids the issue,
|
|
228
|
+
// matching the pattern that works in help/thread-list dialogs.
|
|
229
|
+
dialog.container.remove(dialog.hintBox.id);
|
|
230
|
+
const bottomPanel = new BoxRenderable(renderer, {
|
|
231
|
+
width: "100%",
|
|
232
|
+
height: 6, // separator(1) + textarea(4) + hints(1)
|
|
233
|
+
flexShrink: 0,
|
|
234
|
+
flexGrow: 0,
|
|
235
|
+
flexDirection: "column",
|
|
236
|
+
});
|
|
237
|
+
const sep = new BoxRenderable(renderer, {
|
|
226
238
|
width: "100%",
|
|
227
239
|
height: 1,
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
flexShrink: 0,
|
|
241
|
+
border: ["top"],
|
|
242
|
+
borderColor: theme.border,
|
|
230
243
|
});
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
dialog.container.add(
|
|
244
|
+
bottomPanel.add(sep);
|
|
245
|
+
bottomPanel.add(textarea);
|
|
246
|
+
bottomPanel.add(dialog.hintBox);
|
|
247
|
+
dialog.container.add(bottomPanel);
|
|
235
248
|
|
|
236
249
|
// --- Mode helpers (need dialog.setHints available) ---
|
|
237
250
|
function enterInsert(): void {
|
package/src/tui/confirm.ts
CHANGED
|
@@ -31,10 +31,10 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
|
|
|
31
31
|
const dialog = createDialog({
|
|
32
32
|
renderer,
|
|
33
33
|
title,
|
|
34
|
-
width: "
|
|
34
|
+
width: "44%",
|
|
35
35
|
height: 9,
|
|
36
|
-
top: "
|
|
37
|
-
left: "
|
|
36
|
+
top: "30%",
|
|
37
|
+
left: "28%",
|
|
38
38
|
borderColor: theme.warning,
|
|
39
39
|
onDismiss: onCancel,
|
|
40
40
|
hints: CONFIRM_HINTS,
|
package/src/tui/help.ts
CHANGED
|
@@ -50,10 +50,10 @@ export function createHelp(opts: {
|
|
|
50
50
|
const dialog = createDialog({
|
|
51
51
|
renderer,
|
|
52
52
|
title: "Help",
|
|
53
|
-
width: "
|
|
54
|
-
height: Math.min(
|
|
53
|
+
width: "64%",
|
|
54
|
+
height: Math.min(32, renderer.height - 4),
|
|
55
55
|
top: "10%",
|
|
56
|
-
left: "
|
|
56
|
+
left: "18%",
|
|
57
57
|
borderColor: theme.info,
|
|
58
58
|
onDismiss: onClose,
|
|
59
59
|
hints: HELP_HINTS,
|
|
@@ -92,6 +92,12 @@ export function createHelp(opts: {
|
|
|
92
92
|
" Esc Clear search",
|
|
93
93
|
" ]t/[t Next/prev thread",
|
|
94
94
|
" ]r/[r Next/prev unread",
|
|
95
|
+
" ]1/[1 Next/prev h1 heading",
|
|
96
|
+
" ]2/[2 Next/prev h2 heading",
|
|
97
|
+
" ]3/[3 Next/prev h3 heading",
|
|
98
|
+
" Ctrl+o/i Jump list back/forward",
|
|
99
|
+
" '' Jump to previous position",
|
|
100
|
+
" H/M/L Screen top/middle/bottom",
|
|
95
101
|
]);
|
|
96
102
|
|
|
97
103
|
addHelpSection(dialog.content, renderer, "Review", [
|
|
@@ -99,7 +105,7 @@ export function createHelp(opts: {
|
|
|
99
105
|
" r Resolve thread (toggle)",
|
|
100
106
|
" R Resolve all pending",
|
|
101
107
|
" dd Delete thread",
|
|
102
|
-
" t List threads",
|
|
108
|
+
" t List threads (Ctrl+f to filter)",
|
|
103
109
|
" S Submit for rewrite",
|
|
104
110
|
" A Approve spec",
|
|
105
111
|
]);
|
package/src/tui/pager.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
45
45
|
if (isUnread) {
|
|
46
46
|
indicator = "\u2588";
|
|
47
47
|
} else if (thread.status === "resolved") {
|
|
48
|
-
indicator = "
|
|
48
|
+
indicator = "=";
|
|
49
49
|
} else {
|
|
50
50
|
indicator = "\u258c";
|
|
51
51
|
}
|
|
@@ -104,7 +104,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
104
104
|
indicator = "\u2588"; // █ full block — unread reply
|
|
105
105
|
indicatorColor = theme.yellow;
|
|
106
106
|
} else if (thread.status === "resolved") {
|
|
107
|
-
indicator = "
|
|
107
|
+
indicator = "="; // resolved
|
|
108
108
|
indicatorColor = theme.green;
|
|
109
109
|
} else {
|
|
110
110
|
indicator = "\u258c"; // ▌ half block — has thread
|
|
@@ -171,8 +171,8 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
171
171
|
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
172
172
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
173
173
|
}
|
|
174
|
-
} else if (searchQuery) {
|
|
175
|
-
//
|
|
174
|
+
} else if (searchQuery && specText.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
175
|
+
// Line contains search match — show colored match segments (no markdown styling)
|
|
176
176
|
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
177
177
|
const caseSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
178
178
|
const searchRegex = new RegExp(`(${escaped})`, caseSensitive ? "g" : "gi");
|
package/src/tui/spinner.ts
CHANGED
|
@@ -51,9 +51,11 @@ export function createSpinner(opts: {
|
|
|
51
51
|
container.add(text);
|
|
52
52
|
|
|
53
53
|
let frame = 0;
|
|
54
|
+
const startTime = Date.now();
|
|
54
55
|
const spinInterval = setInterval(() => {
|
|
55
56
|
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
56
|
-
|
|
57
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
58
|
+
text.content = `${SPINNER_FRAMES[frame]} ${message} (${elapsed}s)`;
|
|
57
59
|
renderer.requestRender();
|
|
58
60
|
}, 80);
|
|
59
61
|
|
package/src/tui/status-bar.ts
CHANGED
|
@@ -60,18 +60,45 @@ export function buildTopBar(
|
|
|
60
60
|
t.add(TextNodeRenderable.fromString("!! Spec changed externally", { fg: theme.red, attributes: TextAttributes.BOLD }));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
// Cursor position
|
|
63
|
+
// Cursor position + scroll percentage
|
|
64
|
+
const posLabel = state.cursorLine <= 1 ? "Top"
|
|
65
|
+
: state.cursorLine >= state.lineCount ? "Bot"
|
|
66
|
+
: `${Math.round(((state.cursorLine - 1) / (state.lineCount - 1)) * 100)}%`;
|
|
64
67
|
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
65
|
-
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount}`, { fg: theme.textMuted }));
|
|
68
|
+
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount} ${posLabel}`, { fg: theme.textMuted }));
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
export type MessageIcon = "warn" | "success" | "info";
|
|
72
|
+
|
|
73
|
+
const ICON_MAP: Record<MessageIcon, { symbol: string; fg: string }> = {
|
|
74
|
+
warn: { symbol: "!", fg: theme.yellow! },
|
|
75
|
+
success: { symbol: "*", fg: theme.green! },
|
|
76
|
+
info: { symbol: "-", fg: theme.blue! },
|
|
77
|
+
};
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
|
-
* Set a transient message on the bottom bar
|
|
80
|
+
* Set a transient message on the bottom bar.
|
|
81
|
+
* With icon: renders as " ⚠ │ message text"
|
|
82
|
+
* Without icon: renders as " message text"
|
|
70
83
|
*/
|
|
71
|
-
export function setBottomBarMessage(
|
|
84
|
+
export function setBottomBarMessage(
|
|
85
|
+
bar: BottomBarComponents,
|
|
86
|
+
message: string,
|
|
87
|
+
iconOrFg?: MessageIcon | string,
|
|
88
|
+
): void {
|
|
72
89
|
const t = bar.text;
|
|
73
90
|
t.clear();
|
|
74
|
-
|
|
91
|
+
|
|
92
|
+
// Detect if it's an icon type or a raw fg color
|
|
93
|
+
const icon = iconOrFg && iconOrFg in ICON_MAP ? ICON_MAP[iconOrFg as MessageIcon] : null;
|
|
94
|
+
const fg = icon ? icon.fg : (iconOrFg as string | undefined) ?? theme.text;
|
|
95
|
+
|
|
96
|
+
if (icon) {
|
|
97
|
+
t.add(TextNodeRenderable.fromString(` ${icon.symbol} `, { fg: icon.fg }));
|
|
98
|
+
t.add(TextNodeRenderable.fromString(message, { fg: fg! }));
|
|
99
|
+
} else {
|
|
100
|
+
t.add(TextNodeRenderable.fromString(` ${message}`, { fg: fg! }));
|
|
101
|
+
}
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
/**
|
package/src/tui/thread-list.ts
CHANGED
|
@@ -24,6 +24,9 @@ export interface ThreadListOverlay {
|
|
|
24
24
|
|
|
25
25
|
const MAX_PREVIEW_LENGTH = 50;
|
|
26
26
|
|
|
27
|
+
type FilterMode = "all" | "active" | "resolved";
|
|
28
|
+
const FILTER_CYCLE: FilterMode[] = ["all", "active", "resolved"];
|
|
29
|
+
|
|
27
30
|
function previewText(thread: Thread): string {
|
|
28
31
|
if (thread.messages.length === 0) return "(empty)";
|
|
29
32
|
const first = thread.messages[0];
|
|
@@ -32,128 +35,179 @@ function previewText(thread: Thread): string {
|
|
|
32
35
|
return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
function filterThreads(threads: Thread[], mode: FilterMode): Thread[] {
|
|
39
|
+
switch (mode) {
|
|
40
|
+
case "active":
|
|
41
|
+
return threads.filter((t) => t.status === "open" || t.status === "pending");
|
|
42
|
+
case "resolved":
|
|
43
|
+
return threads.filter((t) => t.status === "resolved");
|
|
44
|
+
case "all":
|
|
45
|
+
default:
|
|
46
|
+
return threads;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildTitle(threads: Thread[], mode: FilterMode): string {
|
|
51
|
+
const activeCount = threads.filter(
|
|
52
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
53
|
+
).length;
|
|
54
|
+
const total = threads.length;
|
|
55
|
+
const label = mode === "all" ? "all" : mode;
|
|
56
|
+
return `Threads (${activeCount} active, ${total} total) [${label}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function threadsToOptions(threads: Thread[]) {
|
|
60
|
+
return threads.map((t) => {
|
|
61
|
+
const icon = STATUS_ICONS[t.status];
|
|
62
|
+
return {
|
|
63
|
+
name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
|
|
64
|
+
description: `${t.status} - ${t.messages.length} message(s)`,
|
|
65
|
+
value: t.line,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
/**
|
|
36
71
|
* Create a thread list overlay showing all threads.
|
|
37
72
|
* Select + Enter: jump to that thread's line.
|
|
73
|
+
* Ctrl+F: cycle filter (all → active → resolved).
|
|
38
74
|
* Escape: cancel.
|
|
39
75
|
*/
|
|
40
76
|
export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
41
77
|
const { renderer, threads, onSelect, onCancel } = opts;
|
|
42
78
|
|
|
79
|
+
// Exclude outdated threads from the pool
|
|
43
80
|
const allThreads = threads.filter(
|
|
44
81
|
(t) => t.status === "open" || t.status === "pending" || t.status === "resolved"
|
|
45
82
|
);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
83
|
+
|
|
84
|
+
let filterIndex = 0;
|
|
85
|
+
let currentFilter: FilterMode = FILTER_CYCLE[0];
|
|
86
|
+
let filtered = filterThreads(allThreads, currentFilter);
|
|
49
87
|
|
|
50
88
|
const dialog = createDialog({
|
|
51
89
|
renderer,
|
|
52
|
-
title:
|
|
53
|
-
width: "
|
|
54
|
-
height: "
|
|
55
|
-
top: "
|
|
56
|
-
left: "
|
|
90
|
+
title: buildTitle(allThreads, currentFilter),
|
|
91
|
+
width: "56%",
|
|
92
|
+
height: "50%",
|
|
93
|
+
top: "20%",
|
|
94
|
+
left: "22%",
|
|
57
95
|
borderColor: theme.mauve,
|
|
58
96
|
onDismiss: onCancel,
|
|
59
97
|
hints: THREAD_LIST_HINTS,
|
|
60
98
|
});
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
focusedBackgroundColor: theme.backgroundPanel,
|
|
91
|
-
focusedTextColor: theme.text,
|
|
92
|
-
selectedBackgroundColor: theme.backgroundElement,
|
|
93
|
-
selectedTextColor: "#f5c2e7",
|
|
94
|
-
descriptionColor: theme.textDim,
|
|
95
|
-
selectedDescriptionColor: theme.textMuted,
|
|
96
|
-
showDescription: true,
|
|
97
|
-
wrapSelection: true,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
dialog.content.add(select);
|
|
100
|
+
const emptyMsg = new TextRenderable(renderer, {
|
|
101
|
+
content: "No threads. Press [Esc] to close.",
|
|
102
|
+
width: "100%",
|
|
103
|
+
height: 1,
|
|
104
|
+
fg: theme.textDim,
|
|
105
|
+
wrapMode: "none",
|
|
106
|
+
visible: filtered.length === 0,
|
|
107
|
+
});
|
|
108
|
+
dialog.content.add(emptyMsg);
|
|
109
|
+
|
|
110
|
+
const select = new SelectRenderable(renderer, {
|
|
111
|
+
width: "100%",
|
|
112
|
+
flexGrow: 1,
|
|
113
|
+
options: threadsToOptions(filtered),
|
|
114
|
+
selectedIndex: 0,
|
|
115
|
+
backgroundColor: theme.backgroundPanel,
|
|
116
|
+
textColor: theme.text,
|
|
117
|
+
focusedBackgroundColor: theme.backgroundPanel,
|
|
118
|
+
focusedTextColor: theme.text,
|
|
119
|
+
selectedBackgroundColor: theme.backgroundElement,
|
|
120
|
+
selectedTextColor: "#f5c2e7",
|
|
121
|
+
descriptionColor: theme.textDim,
|
|
122
|
+
selectedDescriptionColor: theme.textMuted,
|
|
123
|
+
showDescription: true,
|
|
124
|
+
wrapSelection: true,
|
|
125
|
+
visible: filtered.length > 0,
|
|
126
|
+
});
|
|
127
|
+
dialog.content.add(select);
|
|
101
128
|
|
|
129
|
+
if (filtered.length > 0) {
|
|
102
130
|
setTimeout(() => {
|
|
103
131
|
renderer.focusRenderable(select);
|
|
104
132
|
renderer.requestRender();
|
|
105
133
|
}, 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function applyFilter(): void {
|
|
137
|
+
filtered = filterThreads(allThreads, currentFilter);
|
|
138
|
+
dialog.container.title = ` ${buildTitle(allThreads, currentFilter)} `;
|
|
139
|
+
select.options = threadsToOptions(filtered);
|
|
140
|
+
select.visible = filtered.length > 0;
|
|
141
|
+
emptyMsg.visible = filtered.length === 0;
|
|
142
|
+
if (filtered.length === 0) {
|
|
143
|
+
emptyMsg.content = `No ${currentFilter === "all" ? "" : currentFilter + " "}threads. Press [Ctrl+f] to change filter.`;
|
|
144
|
+
}
|
|
145
|
+
if (filtered.length > 0) {
|
|
146
|
+
setTimeout(() => {
|
|
147
|
+
renderer.focusRenderable(select);
|
|
148
|
+
renderer.requestRender();
|
|
149
|
+
}, 0);
|
|
150
|
+
}
|
|
151
|
+
renderer.requestRender();
|
|
152
|
+
}
|
|
106
153
|
|
|
107
|
-
|
|
108
|
-
|
|
154
|
+
// SelectRenderable ITEM_SELECTED event
|
|
155
|
+
select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
156
|
+
const selected = select.getSelectedOption();
|
|
157
|
+
if (selected && selected.value != null) {
|
|
158
|
+
onSelect(selected.value as number);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Manual key handler — SelectRenderable focus is unreliable
|
|
163
|
+
const keyHandler = (key: KeyEvent) => {
|
|
164
|
+
if (key.name === "q") {
|
|
165
|
+
key.preventDefault();
|
|
166
|
+
key.stopPropagation();
|
|
167
|
+
onCancel();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Ctrl+F: cycle filter
|
|
171
|
+
if (key.ctrl && key.name === "f") {
|
|
172
|
+
key.preventDefault();
|
|
173
|
+
key.stopPropagation();
|
|
174
|
+
filterIndex = (filterIndex + 1) % FILTER_CYCLE.length;
|
|
175
|
+
currentFilter = FILTER_CYCLE[filterIndex];
|
|
176
|
+
applyFilter();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (filtered.length === 0) return;
|
|
180
|
+
if (key.name === "return" || key.name === "y") {
|
|
181
|
+
key.preventDefault();
|
|
182
|
+
key.stopPropagation();
|
|
109
183
|
const selected = select.getSelectedOption();
|
|
110
184
|
if (selected && selected.value != null) {
|
|
111
185
|
onSelect(selected.value as number);
|
|
112
186
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
if (key.name === "j" || key.name === "down") {
|
|
133
|
-
key.preventDefault();
|
|
134
|
-
key.stopPropagation();
|
|
135
|
-
select.moveDown();
|
|
136
|
-
renderer.requestRender();
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (key.name === "k" || key.name === "up") {
|
|
140
|
-
key.preventDefault();
|
|
141
|
-
key.stopPropagation();
|
|
142
|
-
select.moveUp();
|
|
143
|
-
renderer.requestRender();
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
renderer.keyInput.on("keypress", keyHandler);
|
|
148
|
-
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (key.name === "j" || key.name === "down") {
|
|
190
|
+
key.preventDefault();
|
|
191
|
+
key.stopPropagation();
|
|
192
|
+
select.moveDown();
|
|
193
|
+
renderer.requestRender();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (key.name === "k" || key.name === "up") {
|
|
197
|
+
key.preventDefault();
|
|
198
|
+
key.stopPropagation();
|
|
199
|
+
select.moveUp();
|
|
200
|
+
renderer.requestRender();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
149
205
|
|
|
150
206
|
return {
|
|
151
207
|
container: dialog.container,
|
|
152
208
|
cleanup() {
|
|
153
209
|
dialog.cleanup();
|
|
154
|
-
|
|
155
|
-
renderer.keyInput.off("keypress", keyHandler);
|
|
156
|
-
}
|
|
210
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
157
211
|
},
|
|
158
212
|
};
|
|
159
213
|
}
|
package/src/tui/ui/dialog.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface DialogOptions {
|
|
|
23
23
|
export interface DialogComponents {
|
|
24
24
|
container: BoxRenderable;
|
|
25
25
|
content: ScrollBoxRenderable;
|
|
26
|
+
hintBox: BoxRenderable;
|
|
26
27
|
hintText: TextRenderable;
|
|
27
28
|
setHints: (hints: Hint[]) => void;
|
|
28
29
|
cleanup: () => void;
|
|
@@ -102,5 +103,5 @@ export function createDialog(opts: DialogOptions): DialogComponents {
|
|
|
102
103
|
renderer.keyInput.off("keypress", keyHandler);
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
return { container, content, hintText, setHints, cleanup };
|
|
106
|
+
return { container, content, hintBox, hintText, setHints, cleanup };
|
|
106
107
|
}
|
package/src/tui/ui/keymap.ts
CHANGED
package/src/tui/ui/theme.ts
CHANGED
|
@@ -29,9 +29,9 @@ export const theme = {
|
|
|
29
29
|
|
|
30
30
|
export const STATUS_ICONS: Record<string, string> = {
|
|
31
31
|
open: "\u258c", // ▌ half block
|
|
32
|
-
pending: "\
|
|
33
|
-
resolved: "
|
|
34
|
-
outdated: "
|
|
32
|
+
pending: "\u2588", // █ full block
|
|
33
|
+
resolved: "=",
|
|
34
|
+
outdated: "-",
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
export const SPLIT_BORDER = {
|