revspec 0.8.0 → 0.8.2
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 +11 -12
- package/package.json +1 -1
- package/src/tui/app.ts +139 -74
- package/src/tui/comment-input.ts +4 -1
- package/src/tui/confirm.ts +1 -1
- package/src/tui/help.ts +5 -4
- package/src/tui/pager.ts +5 -5
- package/src/tui/spinner.ts +4 -2
- package/src/tui/status-bar.ts +32 -5
- package/src/tui/thread-list.ts +139 -85
- package/src/tui/ui/hint-bar.ts +9 -3
- package/src/tui/ui/keymap.ts +4 -3
- package/src/tui/ui/theme.ts +3 -3
package/README.md
CHANGED
|
@@ -54,12 +54,18 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
|
|
|
54
54
|
| `j/k` | Move cursor down/up |
|
|
55
55
|
| `gg` / `G` | Go to top / bottom |
|
|
56
56
|
| `Ctrl+D/U` | Half page down/up |
|
|
57
|
+
| `H/M/L` | Jump to screen top / middle / bottom |
|
|
57
58
|
| `zz` | Center cursor line in viewport |
|
|
58
59
|
| `/` | Search (smartcase) |
|
|
59
60
|
| `n/N` | Next/prev search match |
|
|
60
61
|
| `Esc` | Clear search highlights |
|
|
61
62
|
| `]t/[t` | Next/prev thread |
|
|
62
63
|
| `]r/[r` | Next/prev unread AI reply |
|
|
64
|
+
| `]1/[1` | Next/prev h1 heading |
|
|
65
|
+
| `]2/[2` | Next/prev h2 heading |
|
|
66
|
+
| `]3/[3` | Next/prev h3 heading |
|
|
67
|
+
| `Ctrl+O/I` | Jump list back/forward |
|
|
68
|
+
| `''` | Jump to previous position |
|
|
63
69
|
|
|
64
70
|
**Review**
|
|
65
71
|
|
|
@@ -69,7 +75,7 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
|
|
|
69
75
|
| `r` | Resolve thread (toggle) |
|
|
70
76
|
| `R` | Resolve all pending |
|
|
71
77
|
| `dd` | Delete thread (with confirm) |
|
|
72
|
-
| `t` | List threads |
|
|
78
|
+
| `t` | List threads (`Ctrl+F` to filter all/active/resolved) |
|
|
73
79
|
| `S` | Submit for rewrite (AI updates spec, TUI reloads) |
|
|
74
80
|
| `A` | Approve spec (finalize and exit) |
|
|
75
81
|
|
|
@@ -78,24 +84,17 @@ Opens a TUI with vim-style navigation. Press `c` on any line to open a thread an
|
|
|
78
84
|
| Key | Action |
|
|
79
85
|
|-----|--------|
|
|
80
86
|
| `:q` | Quit (warns if unresolved threads) |
|
|
81
|
-
| `:q!` | Force quit |
|
|
87
|
+
| `:q!` | Force quit (also `:wq!`, `:qa!`, etc.) |
|
|
82
88
|
| `:{N}` | Jump to line N |
|
|
83
89
|
| `Ctrl+C` | Force quit |
|
|
84
90
|
| `?` | Help |
|
|
85
91
|
|
|
86
|
-
**Popups**
|
|
87
|
-
|
|
88
|
-
| Key | Action |
|
|
89
|
-
|-----|--------|
|
|
90
|
-
| `y/Enter` | Confirm / select |
|
|
91
|
-
| `q/Esc` | Cancel / close |
|
|
92
|
-
|
|
93
92
|
### Thread popup
|
|
94
93
|
|
|
95
|
-
The thread popup has two modes:
|
|
94
|
+
The thread popup has two vim-style modes, indicated by border color and label:
|
|
96
95
|
|
|
97
|
-
- **Insert mode** — type your comment, `Tab` sends, `Esc` switches to normal mode
|
|
98
|
-
- **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `q/Esc` to close
|
|
96
|
+
- **Insert mode** (green border) — type your comment, `Tab` sends, `Esc` switches to normal mode
|
|
97
|
+
- **Normal mode** (blue border) — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `q/Esc` to close
|
|
99
98
|
|
|
100
99
|
### Markdown rendering
|
|
101
100
|
|
package/package.json
CHANGED
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,10 +124,38 @@ export async function runTui(
|
|
|
123
124
|
// Command mode state
|
|
124
125
|
let commandBuffer: string | null = null;
|
|
125
126
|
|
|
126
|
-
//
|
|
127
|
-
let
|
|
127
|
+
// Transient message timer — prevents stale timeouts from clobbering each other
|
|
128
|
+
let messageTimer: ReturnType<typeof setTimeout> | null = null;
|
|
129
|
+
function showTransient(message: string, icon?: import("./status-bar").MessageIcon, ms = 1500): void {
|
|
130
|
+
if (messageTimer) clearTimeout(messageTimer);
|
|
131
|
+
setBottomBarMessage(bottomBar, message, icon);
|
|
132
|
+
renderer.requestRender();
|
|
133
|
+
messageTimer = setTimeout(() => { messageTimer = null; refreshPager(); }, ms);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Jump list — mirrors vim's :jumps behavior.
|
|
137
|
+
// pushJump() is called BEFORE each big jump to record the departure position.
|
|
138
|
+
// Ctrl+O traverses backward, Ctrl+I forward. Making a new jump while in the
|
|
139
|
+
// middle of the list discards forward history (same as vim).
|
|
140
|
+
const jumpList: number[] = [1];
|
|
141
|
+
let jumpIndex: number = 0;
|
|
142
|
+
const MAX_JUMP_LIST = 50;
|
|
143
|
+
|
|
144
|
+
function pushJump(): void {
|
|
145
|
+
const cur = state.cursorLine;
|
|
146
|
+
// Discard forward history when making a new jump from the middle
|
|
147
|
+
if (jumpIndex < jumpList.length - 1) {
|
|
148
|
+
jumpList.splice(jumpIndex + 1);
|
|
149
|
+
}
|
|
150
|
+
// Don't push duplicate of the list tail
|
|
151
|
+
if (jumpList.length > 0 && jumpList[jumpList.length - 1] === cur) return;
|
|
152
|
+
jumpList.push(cur);
|
|
153
|
+
if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
|
|
154
|
+
jumpIndex = jumpList.length - 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
128
157
|
function savePrevPosition(): void {
|
|
129
|
-
|
|
158
|
+
pushJump();
|
|
130
159
|
}
|
|
131
160
|
|
|
132
161
|
// Map visual row back to spec line number (for H/M/L)
|
|
@@ -199,22 +228,22 @@ export async function runTui(
|
|
|
199
228
|
|
|
200
229
|
// Process command buffer input
|
|
201
230
|
function processCommand(cmd: string, resolve: () => void): "exit" | "stay" {
|
|
202
|
-
|
|
231
|
+
const forceQuit = ["q!", "qa!", "wq!", "wqa!", "qw!", "qwa!"];
|
|
232
|
+
const safeQuit = ["q", "qa", "wq", "wqa", "qw", "qwa"];
|
|
233
|
+
if (forceQuit.includes(cmd)) {
|
|
234
|
+
exitTui(resolve, "session-end");
|
|
235
|
+
return "exit";
|
|
236
|
+
}
|
|
237
|
+
if (safeQuit.includes(cmd)) {
|
|
203
238
|
const { open, pending } = state.activeThreadCount();
|
|
204
239
|
const total = open + pending;
|
|
205
240
|
if (total > 0) {
|
|
206
|
-
|
|
207
|
-
renderer.requestRender();
|
|
208
|
-
setTimeout(() => { refreshPager(); }, 2000);
|
|
241
|
+
showTransient(`${total} unresolved thread(s). Use :q! to force quit`, "warn", 2000);
|
|
209
242
|
return "stay";
|
|
210
243
|
}
|
|
211
244
|
exitTui(resolve, "session-end");
|
|
212
245
|
return "exit";
|
|
213
246
|
}
|
|
214
|
-
if (cmd === "q!") {
|
|
215
|
-
exitTui(resolve, "session-end");
|
|
216
|
-
return "exit";
|
|
217
|
-
}
|
|
218
247
|
// :{N} — jump to line number
|
|
219
248
|
const lineNum = parseInt(cmd, 10);
|
|
220
249
|
if (!isNaN(lineNum) && lineNum > 0) {
|
|
@@ -224,9 +253,7 @@ export async function runTui(
|
|
|
224
253
|
refreshPager();
|
|
225
254
|
return "stay";
|
|
226
255
|
}
|
|
227
|
-
|
|
228
|
-
renderer.requestRender();
|
|
229
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
256
|
+
showTransient(`Unknown command: ${cmd}`, "warn");
|
|
230
257
|
return "stay";
|
|
231
258
|
}
|
|
232
259
|
|
|
@@ -273,6 +300,7 @@ export async function runTui(
|
|
|
273
300
|
dismissOverlay();
|
|
274
301
|
},
|
|
275
302
|
onCancel: () => {
|
|
303
|
+
if (existingThread) state.markRead(existingThread.id);
|
|
276
304
|
dismissOverlay();
|
|
277
305
|
},
|
|
278
306
|
});
|
|
@@ -426,6 +454,10 @@ export async function runTui(
|
|
|
426
454
|
const keybinds = createKeybindRegistry(bindings, 300);
|
|
427
455
|
|
|
428
456
|
refreshPager();
|
|
457
|
+
if (state.threads.length === 0) {
|
|
458
|
+
setBottomBarMessage(bottomBar, "Navigate to a line and press c to comment | ? for help", "info");
|
|
459
|
+
renderer.requestRender();
|
|
460
|
+
}
|
|
429
461
|
renderer.start();
|
|
430
462
|
|
|
431
463
|
// 8. Set up keybinding handler
|
|
@@ -488,6 +520,38 @@ export async function runTui(
|
|
|
488
520
|
return;
|
|
489
521
|
}
|
|
490
522
|
|
|
523
|
+
// Ctrl+O: jump back in jump list
|
|
524
|
+
if (key.ctrl && key.name === "o") {
|
|
525
|
+
// Starting backward traversal from head — save current position first
|
|
526
|
+
// (without splicing forward history, unlike pushJump)
|
|
527
|
+
if (jumpIndex === jumpList.length - 1) {
|
|
528
|
+
const cur = state.cursorLine;
|
|
529
|
+
if (jumpList[jumpIndex] !== cur) {
|
|
530
|
+
jumpList.push(cur);
|
|
531
|
+
if (jumpList.length > MAX_JUMP_LIST) jumpList.shift();
|
|
532
|
+
jumpIndex = jumpList.length - 1;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (jumpIndex > 0) {
|
|
536
|
+
jumpIndex--;
|
|
537
|
+
state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
|
|
538
|
+
ensureCursorVisible();
|
|
539
|
+
refreshPager();
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Ctrl+I / Tab: jump forward in jump list
|
|
545
|
+
if ((key.ctrl && key.name === "i") || key.name === "tab") {
|
|
546
|
+
if (jumpIndex < jumpList.length - 1) {
|
|
547
|
+
jumpIndex++;
|
|
548
|
+
state.cursorLine = Math.min(jumpList[jumpIndex], state.lineCount);
|
|
549
|
+
ensureCursorVisible();
|
|
550
|
+
refreshPager();
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
491
555
|
// Escape clears search highlights
|
|
492
556
|
if (key.name === "escape") {
|
|
493
557
|
if (searchQuery) {
|
|
@@ -563,31 +627,39 @@ export async function runTui(
|
|
|
563
627
|
if (searchQuery) {
|
|
564
628
|
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
|
|
565
629
|
if (match !== null) {
|
|
630
|
+
const wrapped = match <= state.cursorLine;
|
|
566
631
|
savePrevPosition();
|
|
567
632
|
state.cursorLine = match;
|
|
568
633
|
ensureCursorVisible();
|
|
634
|
+
refreshPager();
|
|
635
|
+
if (wrapped) {
|
|
636
|
+
showTransient("Search wrapped to top", "info", 1200);
|
|
637
|
+
}
|
|
638
|
+
} else {
|
|
639
|
+
refreshPager();
|
|
569
640
|
}
|
|
570
641
|
} else {
|
|
571
|
-
|
|
572
|
-
renderer.requestRender();
|
|
573
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
642
|
+
showTransient("No active search \u2014 use / to search");
|
|
574
643
|
}
|
|
575
|
-
refreshPager();
|
|
576
644
|
break;
|
|
577
645
|
case "search-prev":
|
|
578
646
|
if (searchQuery) {
|
|
579
647
|
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
|
|
580
648
|
if (match !== null) {
|
|
649
|
+
const wrapped = match >= state.cursorLine;
|
|
581
650
|
savePrevPosition();
|
|
582
651
|
state.cursorLine = match;
|
|
583
652
|
ensureCursorVisible();
|
|
653
|
+
refreshPager();
|
|
654
|
+
if (wrapped) {
|
|
655
|
+
showTransient("Search wrapped to bottom", "info", 1200);
|
|
656
|
+
}
|
|
657
|
+
} else {
|
|
658
|
+
refreshPager();
|
|
584
659
|
}
|
|
585
660
|
} else {
|
|
586
|
-
|
|
587
|
-
renderer.requestRender();
|
|
588
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
661
|
+
showTransient("No active search \u2014 use / to search");
|
|
589
662
|
}
|
|
590
|
-
refreshPager();
|
|
591
663
|
break;
|
|
592
664
|
case "comment":
|
|
593
665
|
showCommentInput();
|
|
@@ -603,38 +675,33 @@ export async function runTui(
|
|
|
603
675
|
state.markRead(thread.id);
|
|
604
676
|
appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
605
677
|
refreshPager();
|
|
606
|
-
|
|
607
|
-
? `
|
|
608
|
-
|
|
609
|
-
setBottomBarMessage(bottomBar, msg);
|
|
610
|
-
renderer.requestRender();
|
|
611
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
678
|
+
showTransient(
|
|
679
|
+
wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
|
|
680
|
+
"success");
|
|
612
681
|
} else {
|
|
613
|
-
|
|
614
|
-
renderer.requestRender();
|
|
615
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
682
|
+
showTransient("No thread on this line");
|
|
616
683
|
}
|
|
617
684
|
break;
|
|
618
685
|
}
|
|
619
686
|
case "resolve-all": {
|
|
620
687
|
const { pending } = state.activeThreadCount();
|
|
688
|
+
if (pending === 0) {
|
|
689
|
+
showTransient("No pending threads");
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
621
692
|
const pendingThreads = state.threads.filter(t => t.status === "pending");
|
|
622
693
|
state.resolveAllPending();
|
|
623
694
|
for (const t of pendingThreads) {
|
|
624
695
|
appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
|
|
625
696
|
}
|
|
626
697
|
refreshPager();
|
|
627
|
-
|
|
628
|
-
renderer.requestRender();
|
|
629
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
698
|
+
showTransient(`Resolved ${pending} pending thread(s)`, "success");
|
|
630
699
|
break;
|
|
631
700
|
}
|
|
632
701
|
case "delete-draft": {
|
|
633
702
|
const thread = state.threadAtLine(state.cursorLine);
|
|
634
703
|
if (!thread) {
|
|
635
|
-
|
|
636
|
-
renderer.requestRender();
|
|
637
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
704
|
+
showTransient("No thread on this line");
|
|
638
705
|
break;
|
|
639
706
|
}
|
|
640
707
|
const deleteOverlay = createConfirm({
|
|
@@ -646,9 +713,7 @@ export async function runTui(
|
|
|
646
713
|
state.deleteThread(thread.id);
|
|
647
714
|
appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
648
715
|
refreshPager();
|
|
649
|
-
|
|
650
|
-
renderer.requestRender();
|
|
651
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
716
|
+
showTransient(`Deleted thread #${thread.id}`, "success");
|
|
652
717
|
},
|
|
653
718
|
onCancel: () => {
|
|
654
719
|
dismissOverlay();
|
|
@@ -659,16 +724,17 @@ export async function runTui(
|
|
|
659
724
|
}
|
|
660
725
|
case "submit":
|
|
661
726
|
if (state.threads.length === 0) {
|
|
662
|
-
setBottomBarMessage(bottomBar, "
|
|
727
|
+
setBottomBarMessage(bottomBar, "No threads to submit.");
|
|
663
728
|
renderer.requestRender();
|
|
664
729
|
break;
|
|
665
730
|
}
|
|
666
731
|
unresolvedGate(() => {
|
|
667
732
|
appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
|
|
668
733
|
|
|
734
|
+
const count = state.threads.length;
|
|
669
735
|
const spinnerOverlay = createSpinner({
|
|
670
736
|
renderer,
|
|
671
|
-
message:
|
|
737
|
+
message: `Submitting ${count} thread${count === 1 ? "" : "s"}...`,
|
|
672
738
|
onCancel: () => {
|
|
673
739
|
clearInterval(activeSpecPoll!);
|
|
674
740
|
activeSpecPoll = null;
|
|
@@ -678,9 +744,7 @@ export async function runTui(
|
|
|
678
744
|
clearInterval(activeSpecPoll!);
|
|
679
745
|
activeSpecPoll = null;
|
|
680
746
|
dismissOverlay();
|
|
681
|
-
|
|
682
|
-
renderer.requestRender();
|
|
683
|
-
setTimeout(() => { refreshPager(); }, 3000);
|
|
747
|
+
showTransient("AI did not update the spec. Press S to resubmit.", "warn", 3000);
|
|
684
748
|
},
|
|
685
749
|
});
|
|
686
750
|
showOverlay(spinnerOverlay);
|
|
@@ -701,6 +765,7 @@ export async function runTui(
|
|
|
701
765
|
searchQuery = null;
|
|
702
766
|
ensureCursorVisible();
|
|
703
767
|
refreshPager();
|
|
768
|
+
showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
|
|
704
769
|
}
|
|
705
770
|
} catch {}
|
|
706
771
|
}, 500);
|
|
@@ -723,30 +788,30 @@ export async function runTui(
|
|
|
723
788
|
});
|
|
724
789
|
break;
|
|
725
790
|
case "next-thread": {
|
|
726
|
-
const
|
|
727
|
-
if (
|
|
791
|
+
const nextT = state.nextThread();
|
|
792
|
+
if (nextT !== null) {
|
|
793
|
+
const wrapped = nextT <= state.cursorLine;
|
|
728
794
|
savePrevPosition();
|
|
729
|
-
state.cursorLine =
|
|
795
|
+
state.cursorLine = nextT;
|
|
730
796
|
ensureCursorVisible();
|
|
731
797
|
refreshPager();
|
|
798
|
+
if (wrapped) showTransient("Wrapped to first thread", "info", 1200);
|
|
732
799
|
} else {
|
|
733
|
-
|
|
734
|
-
renderer.requestRender();
|
|
735
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
800
|
+
showTransient("No threads");
|
|
736
801
|
}
|
|
737
802
|
break;
|
|
738
803
|
}
|
|
739
804
|
case "prev-thread": {
|
|
740
|
-
const
|
|
741
|
-
if (
|
|
805
|
+
const prevT = state.prevThread();
|
|
806
|
+
if (prevT !== null) {
|
|
807
|
+
const wrapped = prevT >= state.cursorLine;
|
|
742
808
|
savePrevPosition();
|
|
743
|
-
state.cursorLine =
|
|
809
|
+
state.cursorLine = prevT;
|
|
744
810
|
ensureCursorVisible();
|
|
745
811
|
refreshPager();
|
|
812
|
+
if (wrapped) showTransient("Wrapped to last thread", "info", 1200);
|
|
746
813
|
} else {
|
|
747
|
-
|
|
748
|
-
renderer.requestRender();
|
|
749
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
814
|
+
showTransient("No threads");
|
|
750
815
|
}
|
|
751
816
|
break;
|
|
752
817
|
}
|
|
@@ -758,9 +823,7 @@ export async function runTui(
|
|
|
758
823
|
ensureCursorVisible();
|
|
759
824
|
refreshPager();
|
|
760
825
|
} else {
|
|
761
|
-
|
|
762
|
-
renderer.requestRender();
|
|
763
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
826
|
+
showTransient("No unread replies");
|
|
764
827
|
}
|
|
765
828
|
break;
|
|
766
829
|
}
|
|
@@ -772,9 +835,7 @@ export async function runTui(
|
|
|
772
835
|
ensureCursorVisible();
|
|
773
836
|
refreshPager();
|
|
774
837
|
} else {
|
|
775
|
-
|
|
776
|
-
renderer.requestRender();
|
|
777
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
838
|
+
showTransient("No unread replies");
|
|
778
839
|
}
|
|
779
840
|
break;
|
|
780
841
|
}
|
|
@@ -789,9 +850,7 @@ export async function runTui(
|
|
|
789
850
|
ensureCursorVisible();
|
|
790
851
|
refreshPager();
|
|
791
852
|
} else {
|
|
792
|
-
|
|
793
|
-
renderer.requestRender();
|
|
794
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
853
|
+
showTransient(`No h${level} headings`);
|
|
795
854
|
}
|
|
796
855
|
break;
|
|
797
856
|
}
|
|
@@ -806,18 +865,23 @@ export async function runTui(
|
|
|
806
865
|
ensureCursorVisible();
|
|
807
866
|
refreshPager();
|
|
808
867
|
} else {
|
|
809
|
-
|
|
810
|
-
renderer.requestRender();
|
|
811
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
868
|
+
showTransient(`No h${level} headings`);
|
|
812
869
|
}
|
|
813
870
|
break;
|
|
814
871
|
}
|
|
815
872
|
case "jump-back": {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
873
|
+
// '' swaps between current position and last jump entry
|
|
874
|
+
if (jumpList.length > 1) {
|
|
875
|
+
const cur = state.cursorLine;
|
|
876
|
+
const prevIdx = jumpIndex > 0 ? jumpIndex - 1 : 0;
|
|
877
|
+
const target = jumpList[prevIdx];
|
|
878
|
+
// Record current position at our spot so '' can swap back
|
|
879
|
+
jumpList[jumpIndex] = cur;
|
|
880
|
+
jumpIndex = prevIdx;
|
|
881
|
+
state.cursorLine = Math.min(target, state.lineCount);
|
|
882
|
+
ensureCursorVisible();
|
|
883
|
+
refreshPager();
|
|
884
|
+
}
|
|
821
885
|
break;
|
|
822
886
|
}
|
|
823
887
|
case "screen-top": {
|
|
@@ -848,6 +912,7 @@ export async function runTui(
|
|
|
848
912
|
showSearchOverlay();
|
|
849
913
|
break;
|
|
850
914
|
case "command-mode":
|
|
915
|
+
if (messageTimer) { clearTimeout(messageTimer); messageTimer = null; }
|
|
851
916
|
commandBuffer = "";
|
|
852
917
|
refreshPager();
|
|
853
918
|
break;
|
package/src/tui/comment-input.ts
CHANGED
|
@@ -65,7 +65,7 @@ function createThreadView(
|
|
|
65
65
|
focusedBackgroundColor: theme.backgroundPanel,
|
|
66
66
|
focusedTextColor: theme.text,
|
|
67
67
|
wrapMode: "word",
|
|
68
|
-
placeholder: "
|
|
68
|
+
placeholder: "Type your comment...",
|
|
69
69
|
placeholderColor: theme.textDim,
|
|
70
70
|
initialValue: "",
|
|
71
71
|
});
|
|
@@ -107,6 +107,7 @@ function createThreadView(
|
|
|
107
107
|
onCancel();
|
|
108
108
|
return;
|
|
109
109
|
|
|
110
|
+
case "i":
|
|
110
111
|
case "c":
|
|
111
112
|
enterInsert();
|
|
112
113
|
return;
|
|
@@ -251,6 +252,7 @@ function createThreadView(
|
|
|
251
252
|
mode = "insert";
|
|
252
253
|
textarea.focus();
|
|
253
254
|
dialog.setHints(insertHints);
|
|
255
|
+
dialog.container.borderColor = theme.green;
|
|
254
256
|
renderer.requestRender();
|
|
255
257
|
}
|
|
256
258
|
|
|
@@ -258,6 +260,7 @@ function createThreadView(
|
|
|
258
260
|
mode = "normal";
|
|
259
261
|
textarea.blur();
|
|
260
262
|
dialog.setHints(normalHints);
|
|
263
|
+
dialog.container.borderColor = theme.blue;
|
|
261
264
|
renderer.requestRender();
|
|
262
265
|
}
|
|
263
266
|
|
package/src/tui/confirm.ts
CHANGED
package/src/tui/help.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function createHelp(opts: {
|
|
|
54
54
|
height: Math.min(32, renderer.height - 4),
|
|
55
55
|
top: "10%",
|
|
56
56
|
left: "18%",
|
|
57
|
-
borderColor: theme.
|
|
57
|
+
borderColor: theme.blue,
|
|
58
58
|
onDismiss: onClose,
|
|
59
59
|
hints: HELP_HINTS,
|
|
60
60
|
});
|
|
@@ -77,8 +77,8 @@ export function createHelp(opts: {
|
|
|
77
77
|
]);
|
|
78
78
|
|
|
79
79
|
addHelpSection(dialog.content, renderer, "Thread Popup", [
|
|
80
|
-
" New thread: INSERT mode — type and Tab to send.",
|
|
81
|
-
" Existing thread: NORMAL mode — read conversation,",
|
|
80
|
+
" New thread: INSERT mode (green border) — type and Tab to send.",
|
|
81
|
+
" Existing thread: NORMAL mode (blue border) — read conversation,",
|
|
82
82
|
" c to reply, r to resolve, q/Esc to close.",
|
|
83
83
|
]);
|
|
84
84
|
|
|
@@ -95,6 +95,7 @@ export function createHelp(opts: {
|
|
|
95
95
|
" ]1/[1 Next/prev h1 heading",
|
|
96
96
|
" ]2/[2 Next/prev h2 heading",
|
|
97
97
|
" ]3/[3 Next/prev h3 heading",
|
|
98
|
+
" Ctrl+o/i Jump list back/forward",
|
|
98
99
|
" '' Jump to previous position",
|
|
99
100
|
" H/M/L Screen top/middle/bottom",
|
|
100
101
|
]);
|
|
@@ -104,7 +105,7 @@ export function createHelp(opts: {
|
|
|
104
105
|
" r Resolve thread (toggle)",
|
|
105
106
|
" R Resolve all pending",
|
|
106
107
|
" dd Delete thread",
|
|
107
|
-
" t List threads",
|
|
108
|
+
" t List threads (Ctrl+f to filter)",
|
|
108
109
|
" S Submit for rewrite",
|
|
109
110
|
" A Approve spec",
|
|
110
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
|
|
@@ -127,7 +127,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
127
127
|
// Gutter: cursor + indicator + line number (dimmed)
|
|
128
128
|
lineNode.add(TextNodeRenderable.fromString(
|
|
129
129
|
`${prefix}`,
|
|
130
|
-
{ fg: isCursor ? theme.
|
|
130
|
+
{ fg: isCursor ? theme.yellow : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
|
|
131
131
|
));
|
|
132
132
|
lineNode.add(TextNodeRenderable.fromString(
|
|
133
133
|
indicator,
|
|
@@ -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
|
@@ -32,7 +32,7 @@ export function createSpinner(opts: {
|
|
|
32
32
|
backgroundColor: theme.backgroundPanel,
|
|
33
33
|
border: true,
|
|
34
34
|
borderStyle: "single",
|
|
35
|
-
borderColor: theme.
|
|
35
|
+
borderColor: theme.mauve,
|
|
36
36
|
title: " Submitting ",
|
|
37
37
|
flexDirection: "column",
|
|
38
38
|
paddingLeft: 2,
|
|
@@ -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:
|
|
90
|
+
title: buildTitle(allThreads, currentFilter),
|
|
53
91
|
width: "56%",
|
|
54
92
|
height: "50%",
|
|
55
93
|
top: "20%",
|
|
56
94
|
left: "22%",
|
|
57
|
-
borderColor: theme.
|
|
95
|
+
borderColor: theme.blue,
|
|
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") {
|
|
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/hint-bar.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TextRenderable, TextNodeRenderable } from "@opentui/core";
|
|
1
|
+
import { TextRenderable, TextNodeRenderable, TextAttributes } from "@opentui/core";
|
|
2
2
|
import { theme } from "./theme";
|
|
3
3
|
|
|
4
4
|
export interface Hint {
|
|
@@ -11,8 +11,14 @@ export function buildHints(text: TextRenderable, hints: Hint[]): void {
|
|
|
11
11
|
text.add(TextNodeRenderable.fromString(" ", {}));
|
|
12
12
|
for (let i = 0; i < hints.length; i++) {
|
|
13
13
|
const h = hints[i];
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Mode labels (empty action) get distinct styling so they stand out
|
|
15
|
+
const isMode = h.action === "";
|
|
16
|
+
const keyFg = isMode ? (h.key === "INSERT" ? theme.green : theme.blue) : theme.blue;
|
|
17
|
+
const keyAttrs = isMode ? TextAttributes.BOLD : undefined;
|
|
18
|
+
text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: keyFg, attributes: keyAttrs }));
|
|
19
|
+
if (!isMode) {
|
|
20
|
+
text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
|
|
21
|
+
}
|
|
16
22
|
if (i < hints.length - 1) {
|
|
17
23
|
text.add(TextNodeRenderable.fromString(" ", {}));
|
|
18
24
|
}
|
package/src/tui/ui/keymap.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const PAGER_HINTS = {
|
|
|
21
21
|
|
|
22
22
|
export const THREAD_NORMAL_HINTS: Hint[] = [
|
|
23
23
|
{ key: "NORMAL", action: "" },
|
|
24
|
-
{ key: "c", action: "reply" },
|
|
24
|
+
{ key: "i/c", action: "reply" },
|
|
25
25
|
{ key: "r", action: "resolve" },
|
|
26
26
|
{ key: "q/Esc", action: "close" },
|
|
27
27
|
];
|
|
@@ -36,7 +36,8 @@ export const THREAD_INSERT_HINTS: Hint[] = [
|
|
|
36
36
|
|
|
37
37
|
export const THREAD_LIST_HINTS: Hint[] = [
|
|
38
38
|
{ key: "j/k", action: "navigate" },
|
|
39
|
-
{ key: "
|
|
39
|
+
{ key: "Enter", action: "jump" },
|
|
40
|
+
{ key: "Ctrl+f", action: "filter" },
|
|
40
41
|
{ key: "q/Esc", action: "close" },
|
|
41
42
|
];
|
|
42
43
|
|
|
@@ -51,5 +52,5 @@ export const HELP_HINTS: Hint[] = [
|
|
|
51
52
|
|
|
52
53
|
export const CONFIRM_HINTS: Hint[] = [
|
|
53
54
|
{ key: "y/Enter", action: "yes" },
|
|
54
|
-
{ key: "q/Esc", action: "
|
|
55
|
+
{ key: "q/Esc", action: "cancel" },
|
|
55
56
|
];
|
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 = {
|