revspec 0.8.1 → 0.8.3
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 +78 -84
- package/src/tui/comment-input.ts +4 -1
- package/src/tui/confirm.ts +1 -1
- package/src/tui/help.ts +3 -3
- package/src/tui/live-watcher.ts +9 -7
- package/src/tui/pager.ts +5 -22
- package/src/tui/spinner.ts +2 -2
- package/src/tui/status-bar.ts +21 -11
- package/src/tui/thread-list.ts +7 -3
- package/src/tui/ui/hint-bar.ts +9 -3
- package/src/tui/ui/keymap.ts +3 -3
- package/src/tui/ui/markdown.ts +1 -1
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, `i/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
|
@@ -61,9 +61,12 @@ export async function runTui(
|
|
|
61
61
|
|
|
62
62
|
// Create and start the live watcher
|
|
63
63
|
const liveWatcher: LiveWatcher = createLiveWatcher(jsonlPath, (ownerEvents) => {
|
|
64
|
+
let lastReplyThread: { id: string; line: number } | null = null;
|
|
64
65
|
for (const event of ownerEvents) {
|
|
65
66
|
if (event.type === "reply" && event.threadId && event.text) {
|
|
66
67
|
state.addOwnerReply(event.threadId, event.text, event.ts);
|
|
68
|
+
const thread = state.threads.find((t) => t.id === event.threadId);
|
|
69
|
+
if (thread) lastReplyThread = { id: thread.id, line: thread.line };
|
|
67
70
|
// If the thread popup is open for this thread, push the message in
|
|
68
71
|
if (activeOverlay?.addMessage && activeOverlay?.threadId === event.threadId) {
|
|
69
72
|
activeOverlay.addMessage({ author: "owner", text: event.text, ts: event.ts });
|
|
@@ -71,6 +74,10 @@ export async function runTui(
|
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
refreshPager();
|
|
77
|
+
// Flash notification for AI replies when not viewing that thread
|
|
78
|
+
if (lastReplyThread && activeOverlay?.threadId !== lastReplyThread.id) {
|
|
79
|
+
showTransient(`AI replied on line ${lastReplyThread.line}`, "info");
|
|
80
|
+
}
|
|
74
81
|
});
|
|
75
82
|
liveWatcher.start();
|
|
76
83
|
|
|
@@ -113,8 +120,11 @@ export async function runTui(
|
|
|
113
120
|
|
|
114
121
|
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
|
|
115
122
|
buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
// Don't overwrite transient messages (welcome hint, warnings) during navigation
|
|
124
|
+
if (!messageTimer) {
|
|
125
|
+
const hasThread = !!state.threadAtLine(state.cursorLine);
|
|
126
|
+
buildBottomBar(bottomBar, commandBuffer, hasThread);
|
|
127
|
+
}
|
|
118
128
|
renderer.requestRender();
|
|
119
129
|
}
|
|
120
130
|
|
|
@@ -124,6 +134,15 @@ export async function runTui(
|
|
|
124
134
|
// Command mode state
|
|
125
135
|
let commandBuffer: string | null = null;
|
|
126
136
|
|
|
137
|
+
// Transient message timer — prevents stale timeouts from clobbering each other
|
|
138
|
+
let messageTimer: ReturnType<typeof setTimeout> | null = null;
|
|
139
|
+
function showTransient(message: string, icon?: import("./status-bar").MessageIcon, ms = 1500): void {
|
|
140
|
+
if (messageTimer) clearTimeout(messageTimer);
|
|
141
|
+
setBottomBarMessage(bottomBar, message, icon);
|
|
142
|
+
renderer.requestRender();
|
|
143
|
+
messageTimer = setTimeout(() => { messageTimer = null; refreshPager(); }, ms);
|
|
144
|
+
}
|
|
145
|
+
|
|
127
146
|
// Jump list — mirrors vim's :jumps behavior.
|
|
128
147
|
// pushJump() is called BEFORE each big jump to record the departure position.
|
|
129
148
|
// Ctrl+O traverses backward, Ctrl+I forward. Making a new jump while in the
|
|
@@ -219,22 +238,22 @@ export async function runTui(
|
|
|
219
238
|
|
|
220
239
|
// Process command buffer input
|
|
221
240
|
function processCommand(cmd: string, resolve: () => void): "exit" | "stay" {
|
|
222
|
-
|
|
241
|
+
const forceQuit = ["q!", "qa!", "wq!", "wqa!", "qw!", "qwa!"];
|
|
242
|
+
const safeQuit = ["q", "qa", "wq", "wqa", "qw", "qwa"];
|
|
243
|
+
if (forceQuit.includes(cmd)) {
|
|
244
|
+
exitTui(resolve, "session-end");
|
|
245
|
+
return "exit";
|
|
246
|
+
}
|
|
247
|
+
if (safeQuit.includes(cmd)) {
|
|
223
248
|
const { open, pending } = state.activeThreadCount();
|
|
224
249
|
const total = open + pending;
|
|
225
250
|
if (total > 0) {
|
|
226
|
-
|
|
227
|
-
renderer.requestRender();
|
|
228
|
-
setTimeout(() => { refreshPager(); }, 2000);
|
|
251
|
+
showTransient(`${total} unresolved thread(s). Use :q! to force quit`, "warn", 2000);
|
|
229
252
|
return "stay";
|
|
230
253
|
}
|
|
231
254
|
exitTui(resolve, "session-end");
|
|
232
255
|
return "exit";
|
|
233
256
|
}
|
|
234
|
-
if (cmd === "q!") {
|
|
235
|
-
exitTui(resolve, "session-end");
|
|
236
|
-
return "exit";
|
|
237
|
-
}
|
|
238
257
|
// :{N} — jump to line number
|
|
239
258
|
const lineNum = parseInt(cmd, 10);
|
|
240
259
|
if (!isNaN(lineNum) && lineNum > 0) {
|
|
@@ -244,9 +263,7 @@ export async function runTui(
|
|
|
244
263
|
refreshPager();
|
|
245
264
|
return "stay";
|
|
246
265
|
}
|
|
247
|
-
|
|
248
|
-
renderer.requestRender();
|
|
249
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
266
|
+
showTransient(`Unknown command: ${cmd}`, "warn");
|
|
250
267
|
return "stay";
|
|
251
268
|
}
|
|
252
269
|
|
|
@@ -284,13 +301,24 @@ export async function runTui(
|
|
|
284
301
|
}
|
|
285
302
|
},
|
|
286
303
|
onResolve: () => {
|
|
304
|
+
let didResolve = false;
|
|
287
305
|
if (existingThread) {
|
|
288
306
|
const wasResolved = existingThread.status === "resolved";
|
|
307
|
+
didResolve = !wasResolved;
|
|
289
308
|
state.resolveThread(existingThread.id);
|
|
290
309
|
state.markRead(existingThread.id);
|
|
291
310
|
appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: existingThread.id, author: "reviewer", ts: Date.now() });
|
|
292
311
|
}
|
|
293
312
|
dismissOverlay();
|
|
313
|
+
// Auto-advance to next thread only when resolving (not reopening)
|
|
314
|
+
if (didResolve) {
|
|
315
|
+
const nextLine = state.nextThread();
|
|
316
|
+
if (nextLine !== null) {
|
|
317
|
+
state.cursorLine = nextLine;
|
|
318
|
+
ensureCursorVisible();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
refreshPager();
|
|
294
322
|
},
|
|
295
323
|
onCancel: () => {
|
|
296
324
|
if (existingThread) state.markRead(existingThread.id);
|
|
@@ -448,8 +476,7 @@ export async function runTui(
|
|
|
448
476
|
|
|
449
477
|
refreshPager();
|
|
450
478
|
if (state.threads.length === 0) {
|
|
451
|
-
|
|
452
|
-
renderer.requestRender();
|
|
479
|
+
showTransient("Navigate to a line and press c to comment | ? for help", "info", 8000);
|
|
453
480
|
}
|
|
454
481
|
renderer.start();
|
|
455
482
|
|
|
@@ -626,17 +653,13 @@ export async function runTui(
|
|
|
626
653
|
ensureCursorVisible();
|
|
627
654
|
refreshPager();
|
|
628
655
|
if (wrapped) {
|
|
629
|
-
|
|
630
|
-
renderer.requestRender();
|
|
631
|
-
setTimeout(() => { refreshPager(); }, 1200);
|
|
656
|
+
showTransient("Search wrapped to top", "info", 1200);
|
|
632
657
|
}
|
|
633
658
|
} else {
|
|
634
659
|
refreshPager();
|
|
635
660
|
}
|
|
636
661
|
} else {
|
|
637
|
-
|
|
638
|
-
renderer.requestRender();
|
|
639
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
662
|
+
showTransient("No active search \u2014 use / to search");
|
|
640
663
|
}
|
|
641
664
|
break;
|
|
642
665
|
case "search-prev":
|
|
@@ -649,17 +672,13 @@ export async function runTui(
|
|
|
649
672
|
ensureCursorVisible();
|
|
650
673
|
refreshPager();
|
|
651
674
|
if (wrapped) {
|
|
652
|
-
|
|
653
|
-
renderer.requestRender();
|
|
654
|
-
setTimeout(() => { refreshPager(); }, 1200);
|
|
675
|
+
showTransient("Search wrapped to bottom", "info", 1200);
|
|
655
676
|
}
|
|
656
677
|
} else {
|
|
657
678
|
refreshPager();
|
|
658
679
|
}
|
|
659
680
|
} else {
|
|
660
|
-
|
|
661
|
-
renderer.requestRender();
|
|
662
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
681
|
+
showTransient("No active search \u2014 use / to search");
|
|
663
682
|
}
|
|
664
683
|
break;
|
|
665
684
|
case "comment":
|
|
@@ -676,37 +695,33 @@ export async function runTui(
|
|
|
676
695
|
state.markRead(thread.id);
|
|
677
696
|
appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
678
697
|
refreshPager();
|
|
679
|
-
|
|
698
|
+
showTransient(
|
|
680
699
|
wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
|
|
681
700
|
"success");
|
|
682
|
-
renderer.requestRender();
|
|
683
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
684
701
|
} else {
|
|
685
|
-
|
|
686
|
-
renderer.requestRender();
|
|
687
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
702
|
+
showTransient("No thread on this line");
|
|
688
703
|
}
|
|
689
704
|
break;
|
|
690
705
|
}
|
|
691
706
|
case "resolve-all": {
|
|
692
707
|
const { pending } = state.activeThreadCount();
|
|
708
|
+
if (pending === 0) {
|
|
709
|
+
showTransient("No pending threads");
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
693
712
|
const pendingThreads = state.threads.filter(t => t.status === "pending");
|
|
694
713
|
state.resolveAllPending();
|
|
695
714
|
for (const t of pendingThreads) {
|
|
696
715
|
appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
|
|
697
716
|
}
|
|
698
717
|
refreshPager();
|
|
699
|
-
|
|
700
|
-
renderer.requestRender();
|
|
701
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
718
|
+
showTransient(`Resolved ${pending} pending thread(s)`, "success");
|
|
702
719
|
break;
|
|
703
720
|
}
|
|
704
721
|
case "delete-draft": {
|
|
705
722
|
const thread = state.threadAtLine(state.cursorLine);
|
|
706
723
|
if (!thread) {
|
|
707
|
-
|
|
708
|
-
renderer.requestRender();
|
|
709
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
724
|
+
showTransient("No thread on this line");
|
|
710
725
|
break;
|
|
711
726
|
}
|
|
712
727
|
const deleteOverlay = createConfirm({
|
|
@@ -718,9 +733,7 @@ export async function runTui(
|
|
|
718
733
|
state.deleteThread(thread.id);
|
|
719
734
|
appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
720
735
|
refreshPager();
|
|
721
|
-
|
|
722
|
-
renderer.requestRender();
|
|
723
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
736
|
+
showTransient(`Deleted thread #${thread.id}`, "success");
|
|
724
737
|
},
|
|
725
738
|
onCancel: () => {
|
|
726
739
|
dismissOverlay();
|
|
@@ -751,9 +764,7 @@ export async function runTui(
|
|
|
751
764
|
clearInterval(activeSpecPoll!);
|
|
752
765
|
activeSpecPoll = null;
|
|
753
766
|
dismissOverlay();
|
|
754
|
-
|
|
755
|
-
renderer.requestRender();
|
|
756
|
-
setTimeout(() => { refreshPager(); }, 3000);
|
|
767
|
+
showTransient("AI did not update the spec. Press S to resubmit.", "warn", 3000);
|
|
757
768
|
},
|
|
758
769
|
});
|
|
759
770
|
showOverlay(spinnerOverlay);
|
|
@@ -772,11 +783,12 @@ export async function runTui(
|
|
|
772
783
|
liveWatcher.start();
|
|
773
784
|
dismissOverlay();
|
|
774
785
|
searchQuery = null;
|
|
786
|
+
jumpList.length = 0;
|
|
787
|
+
jumpList.push(1);
|
|
788
|
+
jumpIndex = 0;
|
|
775
789
|
ensureCursorVisible();
|
|
776
790
|
refreshPager();
|
|
777
|
-
|
|
778
|
-
renderer.requestRender();
|
|
779
|
-
setTimeout(() => { refreshPager(); }, 2500);
|
|
791
|
+
showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
|
|
780
792
|
}
|
|
781
793
|
} catch {}
|
|
782
794
|
}, 500);
|
|
@@ -784,45 +796,34 @@ export async function runTui(
|
|
|
784
796
|
break;
|
|
785
797
|
case "approve":
|
|
786
798
|
unresolvedGate(() => {
|
|
787
|
-
|
|
788
|
-
renderer,
|
|
789
|
-
message: "Approve spec and proceed to implementation?",
|
|
790
|
-
onConfirm: () => {
|
|
791
|
-
dismissOverlay();
|
|
792
|
-
exitTui(resolve, "approve");
|
|
793
|
-
},
|
|
794
|
-
onCancel: () => {
|
|
795
|
-
dismissOverlay();
|
|
796
|
-
},
|
|
797
|
-
});
|
|
798
|
-
showOverlay(confirmOverlay);
|
|
799
|
+
exitTui(resolve, "approve");
|
|
799
800
|
});
|
|
800
801
|
break;
|
|
801
802
|
case "next-thread": {
|
|
802
|
-
const
|
|
803
|
-
if (
|
|
803
|
+
const nextT = state.nextThread();
|
|
804
|
+
if (nextT !== null) {
|
|
805
|
+
const wrapped = nextT <= state.cursorLine;
|
|
804
806
|
savePrevPosition();
|
|
805
|
-
state.cursorLine =
|
|
807
|
+
state.cursorLine = nextT;
|
|
806
808
|
ensureCursorVisible();
|
|
807
809
|
refreshPager();
|
|
810
|
+
if (wrapped) showTransient("Wrapped to first thread", "info", 1200);
|
|
808
811
|
} else {
|
|
809
|
-
|
|
810
|
-
renderer.requestRender();
|
|
811
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
812
|
+
showTransient("No threads");
|
|
812
813
|
}
|
|
813
814
|
break;
|
|
814
815
|
}
|
|
815
816
|
case "prev-thread": {
|
|
816
|
-
const
|
|
817
|
-
if (
|
|
817
|
+
const prevT = state.prevThread();
|
|
818
|
+
if (prevT !== null) {
|
|
819
|
+
const wrapped = prevT >= state.cursorLine;
|
|
818
820
|
savePrevPosition();
|
|
819
|
-
state.cursorLine =
|
|
821
|
+
state.cursorLine = prevT;
|
|
820
822
|
ensureCursorVisible();
|
|
821
823
|
refreshPager();
|
|
824
|
+
if (wrapped) showTransient("Wrapped to last thread", "info", 1200);
|
|
822
825
|
} else {
|
|
823
|
-
|
|
824
|
-
renderer.requestRender();
|
|
825
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
826
|
+
showTransient("No threads");
|
|
826
827
|
}
|
|
827
828
|
break;
|
|
828
829
|
}
|
|
@@ -834,9 +835,7 @@ export async function runTui(
|
|
|
834
835
|
ensureCursorVisible();
|
|
835
836
|
refreshPager();
|
|
836
837
|
} else {
|
|
837
|
-
|
|
838
|
-
renderer.requestRender();
|
|
839
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
838
|
+
showTransient("No unread replies");
|
|
840
839
|
}
|
|
841
840
|
break;
|
|
842
841
|
}
|
|
@@ -848,9 +847,7 @@ export async function runTui(
|
|
|
848
847
|
ensureCursorVisible();
|
|
849
848
|
refreshPager();
|
|
850
849
|
} else {
|
|
851
|
-
|
|
852
|
-
renderer.requestRender();
|
|
853
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
850
|
+
showTransient("No unread replies");
|
|
854
851
|
}
|
|
855
852
|
break;
|
|
856
853
|
}
|
|
@@ -865,9 +862,7 @@ export async function runTui(
|
|
|
865
862
|
ensureCursorVisible();
|
|
866
863
|
refreshPager();
|
|
867
864
|
} else {
|
|
868
|
-
|
|
869
|
-
renderer.requestRender();
|
|
870
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
865
|
+
showTransient(`No h${level} headings`);
|
|
871
866
|
}
|
|
872
867
|
break;
|
|
873
868
|
}
|
|
@@ -882,9 +877,7 @@ export async function runTui(
|
|
|
882
877
|
ensureCursorVisible();
|
|
883
878
|
refreshPager();
|
|
884
879
|
} else {
|
|
885
|
-
|
|
886
|
-
renderer.requestRender();
|
|
887
|
-
setTimeout(() => { refreshPager(); }, 1500);
|
|
880
|
+
showTransient(`No h${level} headings`);
|
|
888
881
|
}
|
|
889
882
|
break;
|
|
890
883
|
}
|
|
@@ -931,6 +924,7 @@ export async function runTui(
|
|
|
931
924
|
showSearchOverlay();
|
|
932
925
|
break;
|
|
933
926
|
case "command-mode":
|
|
927
|
+
if (messageTimer) { clearTimeout(messageTimer); messageTimer = null; }
|
|
934
928
|
commandBuffer = "";
|
|
935
929
|
refreshPager();
|
|
936
930
|
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 —
|
|
80
|
+
" New thread: INSERT mode (green border) — type and Tab to send.",
|
|
81
|
+
" Existing thread: NORMAL mode (blue border) — scroll conversation,",
|
|
82
82
|
" c to reply, r to resolve, q/Esc to close.",
|
|
83
83
|
]);
|
|
84
84
|
|
package/src/tui/live-watcher.ts
CHANGED
|
@@ -15,14 +15,16 @@ export function createLiveWatcher(
|
|
|
15
15
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
16
16
|
|
|
17
17
|
function check() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
try {
|
|
19
|
+
const { events, newOffset } = readEventsFromOffset(jsonlPath, offset)
|
|
20
|
+
if (events.length > 0) {
|
|
21
|
+
offset = newOffset
|
|
22
|
+
const ownerEvents = events.filter((e) => e.author === "owner")
|
|
23
|
+
if (ownerEvents.length > 0) {
|
|
24
|
+
onOwnerEvents(ownerEvents)
|
|
25
|
+
}
|
|
24
26
|
}
|
|
25
|
-
}
|
|
27
|
+
} catch {}
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
return {
|
package/src/tui/pager.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ReviewState } from "../state/review-state";
|
|
2
|
-
import type { Thread } from "../protocol/types";
|
|
3
2
|
import {
|
|
4
3
|
ScrollBoxRenderable,
|
|
5
4
|
TextRenderable,
|
|
@@ -10,16 +9,6 @@ import {
|
|
|
10
9
|
import { theme } from "./ui/theme";
|
|
11
10
|
import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
|
|
12
11
|
|
|
13
|
-
const MAX_HINT_LENGTH = 40;
|
|
14
|
-
|
|
15
|
-
function threadHint(thread: Thread): string {
|
|
16
|
-
if (thread.messages.length === 0) return "";
|
|
17
|
-
const last = thread.messages[thread.messages.length - 1];
|
|
18
|
-
const text = last.text.replace(/\n/g, " ");
|
|
19
|
-
if (text.length <= MAX_HINT_LENGTH) return text;
|
|
20
|
-
return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
// --- Plain text builder (for tests) ---
|
|
24
13
|
|
|
25
14
|
/**
|
|
@@ -127,7 +116,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
127
116
|
// Gutter: cursor + indicator + line number (dimmed)
|
|
128
117
|
lineNode.add(TextNodeRenderable.fromString(
|
|
129
118
|
`${prefix}`,
|
|
130
|
-
{ fg: isCursor ? theme.
|
|
119
|
+
{ fg: isCursor ? theme.mauve : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
|
|
131
120
|
));
|
|
132
121
|
lineNode.add(TextNodeRenderable.fromString(
|
|
133
122
|
indicator,
|
|
@@ -171,7 +160,10 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
171
160
|
lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
|
|
172
161
|
renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
|
|
173
162
|
}
|
|
174
|
-
} else if (searchQuery &&
|
|
163
|
+
} else if (searchQuery && (() => {
|
|
164
|
+
const cs = searchQuery !== searchQuery.toLowerCase();
|
|
165
|
+
return cs ? specText.includes(searchQuery) : specText.toLowerCase().includes(searchQuery.toLowerCase());
|
|
166
|
+
})()) {
|
|
175
167
|
// Line contains search match — show colored match segments (no markdown styling)
|
|
176
168
|
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
177
169
|
const caseSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
@@ -193,15 +185,6 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
193
185
|
addSegments(lineNode, segments, theme.text, isCursor ? theme.backgroundElement : undefined);
|
|
194
186
|
}
|
|
195
187
|
|
|
196
|
-
// Thread hint (dimmed, inline)
|
|
197
|
-
if (thread && thread.messages.length > 0) {
|
|
198
|
-
const hint = threadHint(thread);
|
|
199
|
-
lineNode.add(TextNodeRenderable.fromString(
|
|
200
|
-
` \u00ab ${hint}`,
|
|
201
|
-
{ fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
|
|
202
|
-
));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
188
|
// Newline between lines (except last)
|
|
206
189
|
if (i < state.specLines.length - 1) {
|
|
207
190
|
lineNode.add(TextNodeRenderable.fromString("\n", {}));
|
package/src/tui/spinner.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "@opentui/core";
|
|
7
7
|
import { theme } from "./ui/theme";
|
|
8
8
|
|
|
9
|
-
const SPINNER_FRAMES = ["
|
|
9
|
+
const SPINNER_FRAMES = ["|", "/", "-", "\\"];
|
|
10
10
|
|
|
11
11
|
export interface SpinnerOverlay {
|
|
12
12
|
container: BoxRenderable;
|
|
@@ -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,
|
package/src/tui/status-bar.ts
CHANGED
|
@@ -33,16 +33,16 @@ export function buildTopBar(
|
|
|
33
33
|
// Filename — bold
|
|
34
34
|
t.add(TextNodeRenderable.fromString(` ${name}`, { fg: theme.text, attributes: TextAttributes.BOLD }));
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
// Thread progress — shown when threads exist
|
|
37
|
+
if (state.threads.length > 0) {
|
|
38
|
+
const resolved = state.threads.filter((t) => t.status === "resolved").length;
|
|
39
|
+
const total = state.threads.length;
|
|
40
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
41
|
+
if (resolved === total) {
|
|
42
|
+
t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.green }));
|
|
43
|
+
} else {
|
|
44
|
+
t.add(TextNodeRenderable.fromString(`${resolved}/${total} resolved`, { fg: theme.yellow }));
|
|
45
|
+
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Unread replies
|
|
@@ -50,7 +50,7 @@ export function buildTopBar(
|
|
|
50
50
|
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
51
51
|
t.add(TextNodeRenderable.fromString(
|
|
52
52
|
`${unreadCount} new repl${unreadCount === 1 ? "y" : "ies"}`,
|
|
53
|
-
{ fg: theme.
|
|
53
|
+
{ fg: theme.yellow, attributes: TextAttributes.BOLD }
|
|
54
54
|
));
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -66,6 +66,16 @@ export function buildTopBar(
|
|
|
66
66
|
: `${Math.round(((state.cursorLine - 1) / (state.lineCount - 1)) * 100)}%`;
|
|
67
67
|
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
68
68
|
t.add(TextNodeRenderable.fromString(`L${state.cursorLine}/${state.lineCount} ${posLabel}`, { fg: theme.textMuted }));
|
|
69
|
+
|
|
70
|
+
// Current section breadcrumb — nearest heading above cursor
|
|
71
|
+
for (let i = state.cursorLine - 1; i >= 0; i--) {
|
|
72
|
+
const match = state.specLines[i].match(/^(#{1,3})\s+(.+)/);
|
|
73
|
+
if (match) {
|
|
74
|
+
t.add(TextNodeRenderable.fromString(" \u00b7 ", { fg: theme.textDim }));
|
|
75
|
+
t.add(TextNodeRenderable.fromString(match[2].trim(), { fg: theme.textDim, attributes: TextAttributes.ITALIC }));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
export type MessageIcon = "warn" | "success" | "info";
|
package/src/tui/thread-list.ts
CHANGED
|
@@ -57,7 +57,11 @@ function buildTitle(threads: Thread[], mode: FilterMode): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function threadsToOptions(threads: Thread[]) {
|
|
60
|
-
|
|
60
|
+
const STATUS_ORDER: Record<string, number> = { open: 0, pending: 1, resolved: 2 };
|
|
61
|
+
const sorted = [...threads].sort((a, b) =>
|
|
62
|
+
(STATUS_ORDER[a.status] ?? 3) - (STATUS_ORDER[b.status] ?? 3) || a.line - b.line
|
|
63
|
+
);
|
|
64
|
+
return sorted.map((t) => {
|
|
61
65
|
const icon = STATUS_ICONS[t.status];
|
|
62
66
|
return {
|
|
63
67
|
name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
|
|
@@ -92,7 +96,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
92
96
|
height: "50%",
|
|
93
97
|
top: "20%",
|
|
94
98
|
left: "22%",
|
|
95
|
-
borderColor: theme.
|
|
99
|
+
borderColor: theme.blue,
|
|
96
100
|
onDismiss: onCancel,
|
|
97
101
|
hints: THREAD_LIST_HINTS,
|
|
98
102
|
});
|
|
@@ -177,7 +181,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
177
181
|
return;
|
|
178
182
|
}
|
|
179
183
|
if (filtered.length === 0) return;
|
|
180
|
-
if (key.name === "return"
|
|
184
|
+
if (key.name === "return") {
|
|
181
185
|
key.preventDefault();
|
|
182
186
|
key.stopPropagation();
|
|
183
187
|
const selected = select.getSelectedOption();
|
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,7 @@ 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
40
|
{ key: "Ctrl+f", action: "filter" },
|
|
41
41
|
{ key: "q/Esc", action: "close" },
|
|
42
42
|
];
|
|
@@ -52,5 +52,5 @@ export const HELP_HINTS: Hint[] = [
|
|
|
52
52
|
|
|
53
53
|
export const CONFIRM_HINTS: Hint[] = [
|
|
54
54
|
{ key: "y/Enter", action: "yes" },
|
|
55
|
-
{ key: "q/Esc", action: "
|
|
55
|
+
{ key: "q/Esc", action: "cancel" },
|
|
56
56
|
];
|
package/src/tui/ui/markdown.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function parseInlineMarkdown(text: string): StyledSegment[] {
|
|
|
29
29
|
// 7: ~~strikethrough~~
|
|
30
30
|
// 8: [link text](url) — display text only
|
|
31
31
|
// 9: `code`
|
|
32
|
-
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
|
|
32
|
+
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|(?<!\w)__(.+?)__(?!\w)|(?<!\w)_(.+?)_(?!\w)|~~(.+?)~~|\[([^\]]+)\]\([^)]+\)|`([^`]+)`)/g;
|
|
33
33
|
let pos = 0;
|
|
34
34
|
let match;
|
|
35
35
|
while ((match = regex.exec(text)) !== null) {
|