revspec 0.8.0 → 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/package.json +1 -1
- package/src/tui/app.ts +114 -31
- package/src/tui/help.ts +2 -1
- 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 +138 -84
- package/src/tui/ui/keymap.ts +1 -0
- package/src/tui/ui/theme.ts +3 -3
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,29 @@ export async function runTui(
|
|
|
123
124
|
// Command mode state
|
|
124
125
|
let commandBuffer: string | null = null;
|
|
125
126
|
|
|
126
|
-
//
|
|
127
|
-
|
|
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
|
+
|
|
128
148
|
function savePrevPosition(): void {
|
|
129
|
-
|
|
149
|
+
pushJump();
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
// Map visual row back to spec line number (for H/M/L)
|
|
@@ -203,7 +223,7 @@ export async function runTui(
|
|
|
203
223
|
const { open, pending } = state.activeThreadCount();
|
|
204
224
|
const total = open + pending;
|
|
205
225
|
if (total > 0) {
|
|
206
|
-
setBottomBarMessage(bottomBar,
|
|
226
|
+
setBottomBarMessage(bottomBar, `${total} unresolved thread(s). Use :q! to force quit`, "warn");
|
|
207
227
|
renderer.requestRender();
|
|
208
228
|
setTimeout(() => { refreshPager(); }, 2000);
|
|
209
229
|
return "stay";
|
|
@@ -224,7 +244,7 @@ export async function runTui(
|
|
|
224
244
|
refreshPager();
|
|
225
245
|
return "stay";
|
|
226
246
|
}
|
|
227
|
-
setBottomBarMessage(bottomBar, `
|
|
247
|
+
setBottomBarMessage(bottomBar, `Unknown command: ${cmd}`, "warn");
|
|
228
248
|
renderer.requestRender();
|
|
229
249
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
230
250
|
return "stay";
|
|
@@ -273,6 +293,7 @@ export async function runTui(
|
|
|
273
293
|
dismissOverlay();
|
|
274
294
|
},
|
|
275
295
|
onCancel: () => {
|
|
296
|
+
if (existingThread) state.markRead(existingThread.id);
|
|
276
297
|
dismissOverlay();
|
|
277
298
|
},
|
|
278
299
|
});
|
|
@@ -426,6 +447,10 @@ export async function runTui(
|
|
|
426
447
|
const keybinds = createKeybindRegistry(bindings, 300);
|
|
427
448
|
|
|
428
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
|
+
}
|
|
429
454
|
renderer.start();
|
|
430
455
|
|
|
431
456
|
// 8. Set up keybinding handler
|
|
@@ -488,6 +513,38 @@ export async function runTui(
|
|
|
488
513
|
return;
|
|
489
514
|
}
|
|
490
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
|
+
|
|
491
548
|
// Escape clears search highlights
|
|
492
549
|
if (key.name === "escape") {
|
|
493
550
|
if (searchQuery) {
|
|
@@ -563,31 +620,47 @@ export async function runTui(
|
|
|
563
620
|
if (searchQuery) {
|
|
564
621
|
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
|
|
565
622
|
if (match !== null) {
|
|
623
|
+
const wrapped = match <= state.cursorLine;
|
|
566
624
|
savePrevPosition();
|
|
567
625
|
state.cursorLine = match;
|
|
568
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();
|
|
569
635
|
}
|
|
570
636
|
} else {
|
|
571
|
-
setBottomBarMessage(bottomBar, "
|
|
637
|
+
setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
|
|
572
638
|
renderer.requestRender();
|
|
573
639
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
574
640
|
}
|
|
575
|
-
refreshPager();
|
|
576
641
|
break;
|
|
577
642
|
case "search-prev":
|
|
578
643
|
if (searchQuery) {
|
|
579
644
|
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
|
|
580
645
|
if (match !== null) {
|
|
646
|
+
const wrapped = match >= state.cursorLine;
|
|
581
647
|
savePrevPosition();
|
|
582
648
|
state.cursorLine = match;
|
|
583
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();
|
|
584
658
|
}
|
|
585
659
|
} else {
|
|
586
|
-
setBottomBarMessage(bottomBar, "
|
|
660
|
+
setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
|
|
587
661
|
renderer.requestRender();
|
|
588
662
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
589
663
|
}
|
|
590
|
-
refreshPager();
|
|
591
664
|
break;
|
|
592
665
|
case "comment":
|
|
593
666
|
showCommentInput();
|
|
@@ -603,14 +676,13 @@ export async function runTui(
|
|
|
603
676
|
state.markRead(thread.id);
|
|
604
677
|
appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
605
678
|
refreshPager();
|
|
606
|
-
|
|
607
|
-
? `
|
|
608
|
-
|
|
609
|
-
setBottomBarMessage(bottomBar, msg);
|
|
679
|
+
setBottomBarMessage(bottomBar,
|
|
680
|
+
wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
|
|
681
|
+
"success");
|
|
610
682
|
renderer.requestRender();
|
|
611
683
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
612
684
|
} else {
|
|
613
|
-
setBottomBarMessage(bottomBar, "
|
|
685
|
+
setBottomBarMessage(bottomBar, "No thread on this line");
|
|
614
686
|
renderer.requestRender();
|
|
615
687
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
616
688
|
}
|
|
@@ -624,7 +696,7 @@ export async function runTui(
|
|
|
624
696
|
appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
|
|
625
697
|
}
|
|
626
698
|
refreshPager();
|
|
627
|
-
setBottomBarMessage(bottomBar, `
|
|
699
|
+
setBottomBarMessage(bottomBar, `Resolved ${pending} pending thread(s)`, "success");
|
|
628
700
|
renderer.requestRender();
|
|
629
701
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
630
702
|
break;
|
|
@@ -632,7 +704,7 @@ export async function runTui(
|
|
|
632
704
|
case "delete-draft": {
|
|
633
705
|
const thread = state.threadAtLine(state.cursorLine);
|
|
634
706
|
if (!thread) {
|
|
635
|
-
setBottomBarMessage(bottomBar, "
|
|
707
|
+
setBottomBarMessage(bottomBar, "No thread on this line");
|
|
636
708
|
renderer.requestRender();
|
|
637
709
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
638
710
|
break;
|
|
@@ -646,7 +718,7 @@ export async function runTui(
|
|
|
646
718
|
state.deleteThread(thread.id);
|
|
647
719
|
appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
648
720
|
refreshPager();
|
|
649
|
-
setBottomBarMessage(bottomBar, `
|
|
721
|
+
setBottomBarMessage(bottomBar, `Deleted thread #${thread.id}`, "success");
|
|
650
722
|
renderer.requestRender();
|
|
651
723
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
652
724
|
},
|
|
@@ -659,16 +731,17 @@ export async function runTui(
|
|
|
659
731
|
}
|
|
660
732
|
case "submit":
|
|
661
733
|
if (state.threads.length === 0) {
|
|
662
|
-
setBottomBarMessage(bottomBar, "
|
|
734
|
+
setBottomBarMessage(bottomBar, "No threads to submit.");
|
|
663
735
|
renderer.requestRender();
|
|
664
736
|
break;
|
|
665
737
|
}
|
|
666
738
|
unresolvedGate(() => {
|
|
667
739
|
appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
|
|
668
740
|
|
|
741
|
+
const count = state.threads.length;
|
|
669
742
|
const spinnerOverlay = createSpinner({
|
|
670
743
|
renderer,
|
|
671
|
-
message:
|
|
744
|
+
message: `Submitting ${count} thread${count === 1 ? "" : "s"}...`,
|
|
672
745
|
onCancel: () => {
|
|
673
746
|
clearInterval(activeSpecPoll!);
|
|
674
747
|
activeSpecPoll = null;
|
|
@@ -678,7 +751,7 @@ export async function runTui(
|
|
|
678
751
|
clearInterval(activeSpecPoll!);
|
|
679
752
|
activeSpecPoll = null;
|
|
680
753
|
dismissOverlay();
|
|
681
|
-
setBottomBarMessage(bottomBar, "
|
|
754
|
+
setBottomBarMessage(bottomBar, "Agent did not update spec. Press S to retry.", "warn");
|
|
682
755
|
renderer.requestRender();
|
|
683
756
|
setTimeout(() => { refreshPager(); }, 3000);
|
|
684
757
|
},
|
|
@@ -701,6 +774,9 @@ export async function runTui(
|
|
|
701
774
|
searchQuery = null;
|
|
702
775
|
ensureCursorVisible();
|
|
703
776
|
refreshPager();
|
|
777
|
+
setBottomBarMessage(bottomBar, "Spec rewritten \u2014 review cleared", "success");
|
|
778
|
+
renderer.requestRender();
|
|
779
|
+
setTimeout(() => { refreshPager(); }, 2500);
|
|
704
780
|
}
|
|
705
781
|
} catch {}
|
|
706
782
|
}, 500);
|
|
@@ -730,7 +806,7 @@ export async function runTui(
|
|
|
730
806
|
ensureCursorVisible();
|
|
731
807
|
refreshPager();
|
|
732
808
|
} else {
|
|
733
|
-
setBottomBarMessage(bottomBar, "
|
|
809
|
+
setBottomBarMessage(bottomBar, "No threads");
|
|
734
810
|
renderer.requestRender();
|
|
735
811
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
736
812
|
}
|
|
@@ -744,7 +820,7 @@ export async function runTui(
|
|
|
744
820
|
ensureCursorVisible();
|
|
745
821
|
refreshPager();
|
|
746
822
|
} else {
|
|
747
|
-
setBottomBarMessage(bottomBar, "
|
|
823
|
+
setBottomBarMessage(bottomBar, "No threads");
|
|
748
824
|
renderer.requestRender();
|
|
749
825
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
750
826
|
}
|
|
@@ -758,7 +834,7 @@ export async function runTui(
|
|
|
758
834
|
ensureCursorVisible();
|
|
759
835
|
refreshPager();
|
|
760
836
|
} else {
|
|
761
|
-
setBottomBarMessage(bottomBar, "
|
|
837
|
+
setBottomBarMessage(bottomBar, "No unread replies");
|
|
762
838
|
renderer.requestRender();
|
|
763
839
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
764
840
|
}
|
|
@@ -772,7 +848,7 @@ export async function runTui(
|
|
|
772
848
|
ensureCursorVisible();
|
|
773
849
|
refreshPager();
|
|
774
850
|
} else {
|
|
775
|
-
setBottomBarMessage(bottomBar, "
|
|
851
|
+
setBottomBarMessage(bottomBar, "No unread replies");
|
|
776
852
|
renderer.requestRender();
|
|
777
853
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
778
854
|
}
|
|
@@ -789,7 +865,7 @@ export async function runTui(
|
|
|
789
865
|
ensureCursorVisible();
|
|
790
866
|
refreshPager();
|
|
791
867
|
} else {
|
|
792
|
-
setBottomBarMessage(bottomBar, `
|
|
868
|
+
setBottomBarMessage(bottomBar, `No h${level} headings`);
|
|
793
869
|
renderer.requestRender();
|
|
794
870
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
795
871
|
}
|
|
@@ -806,18 +882,25 @@ export async function runTui(
|
|
|
806
882
|
ensureCursorVisible();
|
|
807
883
|
refreshPager();
|
|
808
884
|
} else {
|
|
809
|
-
setBottomBarMessage(bottomBar, `
|
|
885
|
+
setBottomBarMessage(bottomBar, `No h${level} headings`);
|
|
810
886
|
renderer.requestRender();
|
|
811
887
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
812
888
|
}
|
|
813
889
|
break;
|
|
814
890
|
}
|
|
815
891
|
case "jump-back": {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
}
|
|
821
904
|
break;
|
|
822
905
|
}
|
|
823
906
|
case "screen-top": {
|
package/src/tui/help.ts
CHANGED
|
@@ -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
|
|
@@ -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,24 +35,59 @@ 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%",
|
|
@@ -59,101 +97,117 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
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/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 = {
|