revspec 0.8.1 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Terminal-based spec review tool with real-time AI conversation",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/tui/app.ts CHANGED
@@ -124,6 +124,15 @@ export async function runTui(
124
124
  // Command mode state
125
125
  let commandBuffer: string | null = null;
126
126
 
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
+
127
136
  // Jump list — mirrors vim's :jumps behavior.
128
137
  // pushJump() is called BEFORE each big jump to record the departure position.
129
138
  // Ctrl+O traverses backward, Ctrl+I forward. Making a new jump while in the
@@ -219,22 +228,22 @@ export async function runTui(
219
228
 
220
229
  // Process command buffer input
221
230
  function processCommand(cmd: string, resolve: () => void): "exit" | "stay" {
222
- if (cmd === "q" || cmd === "wq" || cmd === "qw") {
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)) {
223
238
  const { open, pending } = state.activeThreadCount();
224
239
  const total = open + pending;
225
240
  if (total > 0) {
226
- setBottomBarMessage(bottomBar, `${total} unresolved thread(s). Use :q! to force quit`, "warn");
227
- renderer.requestRender();
228
- setTimeout(() => { refreshPager(); }, 2000);
241
+ showTransient(`${total} unresolved thread(s). Use :q! to force quit`, "warn", 2000);
229
242
  return "stay";
230
243
  }
231
244
  exitTui(resolve, "session-end");
232
245
  return "exit";
233
246
  }
234
- if (cmd === "q!") {
235
- exitTui(resolve, "session-end");
236
- return "exit";
237
- }
238
247
  // :{N} — jump to line number
239
248
  const lineNum = parseInt(cmd, 10);
240
249
  if (!isNaN(lineNum) && lineNum > 0) {
@@ -244,9 +253,7 @@ export async function runTui(
244
253
  refreshPager();
245
254
  return "stay";
246
255
  }
247
- setBottomBarMessage(bottomBar, `Unknown command: ${cmd}`, "warn");
248
- renderer.requestRender();
249
- setTimeout(() => { refreshPager(); }, 1500);
256
+ showTransient(`Unknown command: ${cmd}`, "warn");
250
257
  return "stay";
251
258
  }
252
259
 
@@ -448,7 +455,7 @@ export async function runTui(
448
455
 
449
456
  refreshPager();
450
457
  if (state.threads.length === 0) {
451
- setBottomBarMessage(bottomBar, "Navigate to a line and press c to start reviewing", "info");
458
+ setBottomBarMessage(bottomBar, "Navigate to a line and press c to comment | ? for help", "info");
452
459
  renderer.requestRender();
453
460
  }
454
461
  renderer.start();
@@ -626,17 +633,13 @@ export async function runTui(
626
633
  ensureCursorVisible();
627
634
  refreshPager();
628
635
  if (wrapped) {
629
- setBottomBarMessage(bottomBar, "Search wrapped to top", "info");
630
- renderer.requestRender();
631
- setTimeout(() => { refreshPager(); }, 1200);
636
+ showTransient("Search wrapped to top", "info", 1200);
632
637
  }
633
638
  } else {
634
639
  refreshPager();
635
640
  }
636
641
  } else {
637
- setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
638
- renderer.requestRender();
639
- setTimeout(() => { refreshPager(); }, 1500);
642
+ showTransient("No active search \u2014 use / to search");
640
643
  }
641
644
  break;
642
645
  case "search-prev":
@@ -649,17 +652,13 @@ export async function runTui(
649
652
  ensureCursorVisible();
650
653
  refreshPager();
651
654
  if (wrapped) {
652
- setBottomBarMessage(bottomBar, "Search wrapped to bottom", "info");
653
- renderer.requestRender();
654
- setTimeout(() => { refreshPager(); }, 1200);
655
+ showTransient("Search wrapped to bottom", "info", 1200);
655
656
  }
656
657
  } else {
657
658
  refreshPager();
658
659
  }
659
660
  } else {
660
- setBottomBarMessage(bottomBar, "No active search \u2014 use / to search");
661
- renderer.requestRender();
662
- setTimeout(() => { refreshPager(); }, 1500);
661
+ showTransient("No active search \u2014 use / to search");
663
662
  }
664
663
  break;
665
664
  case "comment":
@@ -676,37 +675,33 @@ export async function runTui(
676
675
  state.markRead(thread.id);
677
676
  appendEvent(jsonlPath, { type: wasResolved ? "unresolve" : "resolve", threadId: thread.id, author: "reviewer", ts: Date.now() });
678
677
  refreshPager();
679
- setBottomBarMessage(bottomBar,
678
+ showTransient(
680
679
  wasResolved ? `Reopened thread #${thread.id}` : `Resolved thread #${thread.id}`,
681
680
  "success");
682
- renderer.requestRender();
683
- setTimeout(() => { refreshPager(); }, 1500);
684
681
  } else {
685
- setBottomBarMessage(bottomBar, "No thread on this line");
686
- renderer.requestRender();
687
- setTimeout(() => { refreshPager(); }, 1500);
682
+ showTransient("No thread on this line");
688
683
  }
689
684
  break;
690
685
  }
691
686
  case "resolve-all": {
692
687
  const { pending } = state.activeThreadCount();
688
+ if (pending === 0) {
689
+ showTransient("No pending threads");
690
+ break;
691
+ }
693
692
  const pendingThreads = state.threads.filter(t => t.status === "pending");
694
693
  state.resolveAllPending();
695
694
  for (const t of pendingThreads) {
696
695
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
697
696
  }
698
697
  refreshPager();
699
- setBottomBarMessage(bottomBar, `Resolved ${pending} pending thread(s)`, "success");
700
- renderer.requestRender();
701
- setTimeout(() => { refreshPager(); }, 1500);
698
+ showTransient(`Resolved ${pending} pending thread(s)`, "success");
702
699
  break;
703
700
  }
704
701
  case "delete-draft": {
705
702
  const thread = state.threadAtLine(state.cursorLine);
706
703
  if (!thread) {
707
- setBottomBarMessage(bottomBar, "No thread on this line");
708
- renderer.requestRender();
709
- setTimeout(() => { refreshPager(); }, 1500);
704
+ showTransient("No thread on this line");
710
705
  break;
711
706
  }
712
707
  const deleteOverlay = createConfirm({
@@ -718,9 +713,7 @@ export async function runTui(
718
713
  state.deleteThread(thread.id);
719
714
  appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
720
715
  refreshPager();
721
- setBottomBarMessage(bottomBar, `Deleted thread #${thread.id}`, "success");
722
- renderer.requestRender();
723
- setTimeout(() => { refreshPager(); }, 1500);
716
+ showTransient(`Deleted thread #${thread.id}`, "success");
724
717
  },
725
718
  onCancel: () => {
726
719
  dismissOverlay();
@@ -751,9 +744,7 @@ export async function runTui(
751
744
  clearInterval(activeSpecPoll!);
752
745
  activeSpecPoll = null;
753
746
  dismissOverlay();
754
- setBottomBarMessage(bottomBar, "Agent did not update spec. Press S to retry.", "warn");
755
- renderer.requestRender();
756
- setTimeout(() => { refreshPager(); }, 3000);
747
+ showTransient("AI did not update the spec. Press S to resubmit.", "warn", 3000);
757
748
  },
758
749
  });
759
750
  showOverlay(spinnerOverlay);
@@ -774,9 +765,7 @@ export async function runTui(
774
765
  searchQuery = null;
775
766
  ensureCursorVisible();
776
767
  refreshPager();
777
- setBottomBarMessage(bottomBar, "Spec rewritten \u2014 review cleared", "success");
778
- renderer.requestRender();
779
- setTimeout(() => { refreshPager(); }, 2500);
768
+ showTransient("Spec rewritten \u2014 review cleared", "success", 2500);
780
769
  }
781
770
  } catch {}
782
771
  }, 500);
@@ -799,30 +788,30 @@ export async function runTui(
799
788
  });
800
789
  break;
801
790
  case "next-thread": {
802
- const next = state.nextThread();
803
- if (next !== null) {
791
+ const nextT = state.nextThread();
792
+ if (nextT !== null) {
793
+ const wrapped = nextT <= state.cursorLine;
804
794
  savePrevPosition();
805
- state.cursorLine = next;
795
+ state.cursorLine = nextT;
806
796
  ensureCursorVisible();
807
797
  refreshPager();
798
+ if (wrapped) showTransient("Wrapped to first thread", "info", 1200);
808
799
  } else {
809
- setBottomBarMessage(bottomBar, "No threads");
810
- renderer.requestRender();
811
- setTimeout(() => { refreshPager(); }, 1500);
800
+ showTransient("No threads");
812
801
  }
813
802
  break;
814
803
  }
815
804
  case "prev-thread": {
816
- const prev = state.prevThread();
817
- if (prev !== null) {
805
+ const prevT = state.prevThread();
806
+ if (prevT !== null) {
807
+ const wrapped = prevT >= state.cursorLine;
818
808
  savePrevPosition();
819
- state.cursorLine = prev;
809
+ state.cursorLine = prevT;
820
810
  ensureCursorVisible();
821
811
  refreshPager();
812
+ if (wrapped) showTransient("Wrapped to last thread", "info", 1200);
822
813
  } else {
823
- setBottomBarMessage(bottomBar, "No threads");
824
- renderer.requestRender();
825
- setTimeout(() => { refreshPager(); }, 1500);
814
+ showTransient("No threads");
826
815
  }
827
816
  break;
828
817
  }
@@ -834,9 +823,7 @@ export async function runTui(
834
823
  ensureCursorVisible();
835
824
  refreshPager();
836
825
  } else {
837
- setBottomBarMessage(bottomBar, "No unread replies");
838
- renderer.requestRender();
839
- setTimeout(() => { refreshPager(); }, 1500);
826
+ showTransient("No unread replies");
840
827
  }
841
828
  break;
842
829
  }
@@ -848,9 +835,7 @@ export async function runTui(
848
835
  ensureCursorVisible();
849
836
  refreshPager();
850
837
  } else {
851
- setBottomBarMessage(bottomBar, "No unread replies");
852
- renderer.requestRender();
853
- setTimeout(() => { refreshPager(); }, 1500);
838
+ showTransient("No unread replies");
854
839
  }
855
840
  break;
856
841
  }
@@ -865,9 +850,7 @@ export async function runTui(
865
850
  ensureCursorVisible();
866
851
  refreshPager();
867
852
  } else {
868
- setBottomBarMessage(bottomBar, `No h${level} headings`);
869
- renderer.requestRender();
870
- setTimeout(() => { refreshPager(); }, 1500);
853
+ showTransient(`No h${level} headings`);
871
854
  }
872
855
  break;
873
856
  }
@@ -882,9 +865,7 @@ export async function runTui(
882
865
  ensureCursorVisible();
883
866
  refreshPager();
884
867
  } else {
885
- setBottomBarMessage(bottomBar, `No h${level} headings`);
886
- renderer.requestRender();
887
- setTimeout(() => { refreshPager(); }, 1500);
868
+ showTransient(`No h${level} headings`);
888
869
  }
889
870
  break;
890
871
  }
@@ -931,6 +912,7 @@ export async function runTui(
931
912
  showSearchOverlay();
932
913
  break;
933
914
  case "command-mode":
915
+ if (messageTimer) { clearTimeout(messageTimer); messageTimer = null; }
934
916
  commandBuffer = "";
935
917
  refreshPager();
936
918
  break;
@@ -65,7 +65,7 @@ function createThreadView(
65
65
  focusedBackgroundColor: theme.backgroundPanel,
66
66
  focusedTextColor: theme.text,
67
67
  wrapMode: "word",
68
- placeholder: "Press c to reply...",
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
 
@@ -35,7 +35,7 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
35
35
  height: 9,
36
36
  top: "30%",
37
37
  left: "28%",
38
- borderColor: theme.warning,
38
+ borderColor: theme.mauve,
39
39
  onDismiss: onCancel,
40
40
  hints: CONFIRM_HINTS,
41
41
  });
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.info,
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
 
package/src/tui/pager.ts CHANGED
@@ -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.text : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
130
+ { fg: isCursor ? theme.yellow : theme.textDim, bg: isCursor ? theme.backgroundElement : undefined }
131
131
  ));
132
132
  lineNode.add(TextNodeRenderable.fromString(
133
133
  indicator,
@@ -32,7 +32,7 @@ export function createSpinner(opts: {
32
32
  backgroundColor: theme.backgroundPanel,
33
33
  border: true,
34
34
  borderStyle: "single",
35
- borderColor: theme.blue,
35
+ borderColor: theme.mauve,
36
36
  title: " Submitting ",
37
37
  flexDirection: "column",
38
38
  paddingLeft: 2,
@@ -92,7 +92,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
92
92
  height: "50%",
93
93
  top: "20%",
94
94
  left: "22%",
95
- borderColor: theme.mauve,
95
+ borderColor: theme.blue,
96
96
  onDismiss: onCancel,
97
97
  hints: THREAD_LIST_HINTS,
98
98
  });
@@ -177,7 +177,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
177
177
  return;
178
178
  }
179
179
  if (filtered.length === 0) return;
180
- if (key.name === "return" || key.name === "y") {
180
+ if (key.name === "return") {
181
181
  key.preventDefault();
182
182
  key.stopPropagation();
183
183
  const selected = select.getSelectedOption();
@@ -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
- text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: theme.blue }));
15
- text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
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
  }
@@ -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: "y/Enter", action: "jump" },
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: "no" },
55
+ { key: "q/Esc", action: "cancel" },
56
56
  ];