revspec 0.7.2 → 0.8.0

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
@@ -21,6 +21,22 @@ git clone https://github.com/icyrainz/revspec.git
21
21
  cd revspec && bun install && bun link
22
22
  ```
23
23
 
24
+ ### Claude Code plugin
25
+
26
+ Install the `/revspec` skill for Claude Code:
27
+
28
+ ```bash
29
+ /plugin marketplace add icyrainz/revspec
30
+ /plugin install revspec
31
+ ```
32
+
33
+ Or manually (clone + symlink, stays updated with `git pull`):
34
+
35
+ ```bash
36
+ git clone https://github.com/icyrainz/revspec.git ~/.local/share/revspec
37
+ ln -sfn ~/.local/share/revspec/skills/revspec ~/.claude/skills/revspec
38
+ ```
39
+
24
40
  ## Usage
25
41
 
26
42
  ```bash
@@ -137,16 +153,6 @@ Sends an AI reply that appears instantly in the reviewer's TUI.
137
153
  8. Repeat 3-7 until A (approve)
138
154
  ```
139
155
 
140
- ### Claude Code skill
141
-
142
- Install the `/revspec` skill for Claude Code:
143
-
144
- ```bash
145
- ./scripts/install-skill.sh
146
- ```
147
-
148
- Then use `/revspec` in Claude Code after generating a spec.
149
-
150
156
  ## Testing
151
157
 
152
158
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Terminal-based spec review tool with real-time AI conversation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,17 +1,18 @@
1
1
  ---
2
2
  name: revspec
3
- description: Launch revspec to review a spec document with real-time AI feedback. Use when the user says /revspec, "review the spec", "let me review this", or after generating a spec/design document that needs human review. Also use when a brainstorming or writing-plans skill produces a markdown spec file.
3
+ description: Launch revspec to review a spec document with real-time AI feedback. Use when the user says /revspec, "review the spec", "let me review this", or after generating a spec/design document that needs human review. Also use when a brainstorming or writing-plans skill produces a markdown spec file. Works on any markdown file (ADRs, READMEs, etc.) but primarily designed for spec review. Works even when no .md file exists yet (inline or plan mode content gets saved to a file first). Supports `/revspec <path>` to review a specific file and resuming previous review sessions.
4
4
  ---
5
5
 
6
6
  # Revspec — Live Spec Review
7
7
 
8
- Launch revspec to let the human review a spec document with real-time AI conversation. The reviewer comments on specific lines, you reply instantly, and the discussion continues until the spec is approved.
8
+ Launch revspec to let the human review a spec document with real-time AI conversation. The reviewer comments on specific lines, you reply instantly, and the discussion continues until the spec is approved. Also works on any markdown file.
9
9
 
10
10
  ## When to Use
11
11
 
12
12
  - After writing or updating a spec/design document
13
13
  - When the user explicitly asks to review a spec
14
14
  - After the brainstorming or writing-plans skill produces a `.md` file
15
+ - When the user provides a file path: `/revspec <path>`
15
16
 
16
17
  ## How It Works
17
18
 
@@ -21,12 +22,22 @@ You and the human communicate through revspec's CLI:
21
22
 
22
23
  The reviewer stays in the revspec TUI for the entire session. You run the watch/reply loop.
23
24
 
24
- ## Step 1: Find the Spec File
25
+ ## Step 1: Find or Create the File
25
26
 
26
- Detect which spec was recently created or modified in this conversation. Look for:
27
+ **If a file path was provided** (e.g., `/revspec docs/my-spec.md`):
28
+ Use it directly — skip the search below.
29
+
30
+ **Otherwise**, detect which file was recently created or modified in this conversation:
27
31
  - Files written to `docs/superpowers/specs/*.md`
28
32
  - The last `.md` file you created or edited
29
- - If ambiguous, ask the user which file to review
33
+
34
+ **If the content was written inline** (proposed in conversation output or plan mode, never saved to a file):
35
+ 1. Save the content to a `.md` file — use `docs/superpowers/specs/<topic>.md` or a reasonable path in the project
36
+ 2. Confirm the file path with the user before proceeding
37
+
38
+ If ambiguous, ask the user which file to review.
39
+
40
+ **Resuming a previous review:** Check if a `.review.jsonl` file already exists for the target file (e.g., `spec.review.jsonl`). If it does, the TUI will restore previous threads automatically — let the user know they're resuming where they left off.
30
41
 
31
42
  ## Step 2: Launch Revspec
32
43
 
@@ -42,7 +53,21 @@ echo $TMUX
42
53
  tmux split-window -t "$TMUX_PANE" -v "revspec <spec-file>"
43
54
  ```
44
55
 
45
- **If no tmux:**
56
+ **If no tmux, but on macOS:** detect the terminal and open a new window:
57
+
58
+ ```bash
59
+ echo $TERM_PROGRAM
60
+ ```
61
+
62
+ | `$TERM_PROGRAM` | Launch command |
63
+ |---|---|
64
+ | `Apple_Terminal` | `osascript -e 'tell application "Terminal" to do script "revspec <spec-file>"'` |
65
+ | `iTerm.app` | `osascript -e 'tell application "iTerm2" to create window with default profile command "revspec <spec-file>"'` |
66
+ | `WezTerm` | `wezterm start -- revspec <spec-file>` |
67
+ | `ghostty` | `ghostty -e revspec <spec-file>` |
68
+ | Other/unknown | Fall back to `osascript` with Terminal.app |
69
+
70
+ **Otherwise (not macOS):**
46
71
  Tell the user: "Please run in another terminal: `revspec <spec-file>`"
47
72
 
48
73
  ## Step 3: Run the Watch/Reply Loop
@@ -113,6 +113,36 @@ export class ReviewState {
113
113
  return this.threads.reduce((max, t) => (t.line > max.line ? t : max)).line;
114
114
  }
115
115
 
116
+ nextHeading(level: number): number | null {
117
+ const prefix = "#".repeat(level) + " ";
118
+ const guard = "#".repeat(level + 1);
119
+ for (let i = this.cursorLine; i < this.specLines.length; i++) {
120
+ const line = this.specLines[i];
121
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
122
+ }
123
+ // Wrap: search from top
124
+ for (let i = 0; i < this.cursorLine - 1; i++) {
125
+ const line = this.specLines[i];
126
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ prevHeading(level: number): number | null {
132
+ const prefix = "#".repeat(level) + " ";
133
+ const guard = "#".repeat(level + 1);
134
+ for (let i = this.cursorLine - 2; i >= 0; i--) {
135
+ const line = this.specLines[i];
136
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
137
+ }
138
+ // Wrap: search from bottom
139
+ for (let i = this.specLines.length - 1; i >= this.cursorLine; i--) {
140
+ const line = this.specLines[i];
141
+ if (line.startsWith(prefix) && !line.startsWith(guard)) return i + 1;
142
+ }
143
+ return null;
144
+ }
145
+
116
146
  canApprove(): boolean {
117
147
  // No threads = clean approval (spec is good as-is)
118
148
  if (this.threads.length === 0) return true;
package/src/tui/app.ts CHANGED
@@ -123,6 +123,21 @@ export async function runTui(
123
123
  // Command mode state
124
124
  let commandBuffer: string | null = null;
125
125
 
126
+ // Previous position for '' jump-back
127
+ let prevCursorLine: number = 1;
128
+ function savePrevPosition(): void {
129
+ prevCursorLine = state.cursorLine;
130
+ }
131
+
132
+ // Map visual row back to spec line number (for H/M/L)
133
+ function visualRowToSpecLine(targetRow: number): number {
134
+ for (let i = 0; i < state.specLines.length; i++) {
135
+ const row = i + countExtraVisualLines(state.specLines, i);
136
+ if (row >= targetRow) return i + 1;
137
+ }
138
+ return state.lineCount;
139
+ }
140
+
126
141
  // Active spec poll interval (for submit spinner leak prevention)
127
142
  let activeSpecPoll: ReturnType<typeof setInterval> | null = null;
128
143
 
@@ -203,6 +218,7 @@ export async function runTui(
203
218
  // :{N} — jump to line number
204
219
  const lineNum = parseInt(cmd, 10);
205
220
  if (!isNaN(lineNum) && lineNum > 0) {
221
+ savePrevPosition();
206
222
  state.cursorLine = Math.min(lineNum, state.lineCount);
207
223
  ensureCursorVisible();
208
224
  refreshPager();
@@ -271,6 +287,7 @@ export async function runTui(
271
287
  cursorLine: state.cursorLine,
272
288
  onResult: (lineNumber: number, query: string) => {
273
289
  searchQuery = query;
290
+ savePrevPosition();
274
291
  state.cursorLine = lineNumber;
275
292
  dismissOverlay();
276
293
  ensureCursorVisible();
@@ -289,6 +306,7 @@ export async function runTui(
289
306
  renderer,
290
307
  threads: state.threads,
291
308
  onSelect: (lineNumber: number) => {
309
+ savePrevPosition();
292
310
  state.cursorLine = lineNumber;
293
311
  dismissOverlay();
294
312
  ensureCursorVisible();
@@ -390,6 +408,16 @@ export async function runTui(
390
408
  { key: "[t", action: "prev-thread" },
391
409
  { key: "]r", action: "next-unread" },
392
410
  { key: "[r", action: "prev-unread" },
411
+ { key: "]1", action: "next-h1" },
412
+ { key: "[1", action: "prev-h1" },
413
+ { key: "]2", action: "next-h2" },
414
+ { key: "[2", action: "prev-h2" },
415
+ { key: "]3", action: "next-h3" },
416
+ { key: "[3", action: "prev-h3" },
417
+ { key: "''", action: "jump-back" },
418
+ { key: "H", action: "screen-top" },
419
+ { key: "M", action: "screen-middle" },
420
+ { key: "L", action: "screen-bottom" },
393
421
  { key: "zz", action: "center-cursor" },
394
422
  { key: "?", action: "help" },
395
423
  { key: "/", action: "search" },
@@ -512,11 +540,13 @@ export async function runTui(
512
540
  break;
513
541
  }
514
542
  case "goto-bottom":
543
+ savePrevPosition();
515
544
  state.cursorLine = state.lineCount;
516
545
  ensureCursorVisible();
517
546
  refreshPager();
518
547
  break;
519
548
  case "goto-top":
549
+ savePrevPosition();
520
550
  state.cursorLine = 1;
521
551
  ensureCursorVisible();
522
552
  refreshPager();
@@ -533,6 +563,7 @@ export async function runTui(
533
563
  if (searchQuery) {
534
564
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
535
565
  if (match !== null) {
566
+ savePrevPosition();
536
567
  state.cursorLine = match;
537
568
  ensureCursorVisible();
538
569
  }
@@ -547,6 +578,7 @@ export async function runTui(
547
578
  if (searchQuery) {
548
579
  const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, -1);
549
580
  if (match !== null) {
581
+ savePrevPosition();
550
582
  state.cursorLine = match;
551
583
  ensureCursorVisible();
552
584
  }
@@ -626,6 +658,11 @@ export async function runTui(
626
658
  break;
627
659
  }
628
660
  case "submit":
661
+ if (state.threads.length === 0) {
662
+ setBottomBarMessage(bottomBar, " No threads to submit.");
663
+ renderer.requestRender();
664
+ break;
665
+ }
629
666
  unresolvedGate(() => {
630
667
  appendEvent(jsonlPath, { type: "submit", author: "reviewer", ts: Date.now() });
631
668
 
@@ -688,6 +725,7 @@ export async function runTui(
688
725
  case "next-thread": {
689
726
  const next = state.nextThread();
690
727
  if (next !== null) {
728
+ savePrevPosition();
691
729
  state.cursorLine = next;
692
730
  ensureCursorVisible();
693
731
  refreshPager();
@@ -701,6 +739,7 @@ export async function runTui(
701
739
  case "prev-thread": {
702
740
  const prev = state.prevThread();
703
741
  if (prev !== null) {
742
+ savePrevPosition();
704
743
  state.cursorLine = prev;
705
744
  ensureCursorVisible();
706
745
  refreshPager();
@@ -714,6 +753,7 @@ export async function runTui(
714
753
  case "next-unread": {
715
754
  const nextLine = state.nextUnreadThread();
716
755
  if (nextLine !== null) {
756
+ savePrevPosition();
717
757
  state.cursorLine = nextLine;
718
758
  ensureCursorVisible();
719
759
  refreshPager();
@@ -727,6 +767,7 @@ export async function runTui(
727
767
  case "prev-unread": {
728
768
  const prevLine = state.prevUnreadThread();
729
769
  if (prevLine !== null) {
770
+ savePrevPosition();
730
771
  state.cursorLine = prevLine;
731
772
  ensureCursorVisible();
732
773
  refreshPager();
@@ -737,6 +778,69 @@ export async function runTui(
737
778
  }
738
779
  break;
739
780
  }
781
+ case "next-h1":
782
+ case "next-h2":
783
+ case "next-h3": {
784
+ const level = parseInt(action.slice(-1));
785
+ const next = state.nextHeading(level);
786
+ if (next !== null) {
787
+ savePrevPosition();
788
+ state.cursorLine = next;
789
+ ensureCursorVisible();
790
+ refreshPager();
791
+ } else {
792
+ setBottomBarMessage(bottomBar, ` No h${level} headings`);
793
+ renderer.requestRender();
794
+ setTimeout(() => { refreshPager(); }, 1500);
795
+ }
796
+ break;
797
+ }
798
+ case "prev-h1":
799
+ case "prev-h2":
800
+ case "prev-h3": {
801
+ const level = parseInt(action.slice(-1));
802
+ const prev = state.prevHeading(level);
803
+ if (prev !== null) {
804
+ savePrevPosition();
805
+ state.cursorLine = prev;
806
+ ensureCursorVisible();
807
+ refreshPager();
808
+ } else {
809
+ setBottomBarMessage(bottomBar, ` No h${level} headings`);
810
+ renderer.requestRender();
811
+ setTimeout(() => { refreshPager(); }, 1500);
812
+ }
813
+ break;
814
+ }
815
+ case "jump-back": {
816
+ const tmp = state.cursorLine;
817
+ state.cursorLine = prevCursorLine;
818
+ prevCursorLine = tmp;
819
+ ensureCursorVisible();
820
+ refreshPager();
821
+ break;
822
+ }
823
+ case "screen-top": {
824
+ const topRow = pager.scrollBox.scrollTop;
825
+ savePrevPosition();
826
+ state.cursorLine = visualRowToSpecLine(topRow);
827
+ refreshPager();
828
+ break;
829
+ }
830
+ case "screen-middle": {
831
+ const midRow = pager.scrollBox.scrollTop + Math.floor(pageSize() / 2);
832
+ savePrevPosition();
833
+ state.cursorLine = visualRowToSpecLine(midRow);
834
+ refreshPager();
835
+ break;
836
+ }
837
+ case "screen-bottom": {
838
+ const botRow = pager.scrollBox.scrollTop + pageSize() - 1;
839
+ savePrevPosition();
840
+ state.cursorLine = visualRowToSpecLine(botRow);
841
+ refreshPager();
842
+ break;
843
+ }
740
844
  case "help":
741
845
  showHelpOverlay();
742
846
  break;
@@ -168,8 +168,10 @@ function createThreadView(
168
168
  const dialog = createDialog({
169
169
  renderer,
170
170
  title,
171
- width: "80%",
172
- height: "85%",
171
+ width: "70%",
172
+ height: "80%",
173
+ top: "8%",
174
+ left: "15%",
173
175
  borderColor: theme.blue,
174
176
  onDismiss: onCancel,
175
177
  hints: insertHints,
@@ -220,18 +222,29 @@ function createThreadView(
220
222
  scrollBox.add(renderMessage(msg));
221
223
  }
222
224
 
223
- // --- Separator ---
224
- const sep = new TextRenderable(renderer, {
225
- content: "\u2500".repeat(40),
225
+ // --- Layout: pack bottom elements into a single container ---
226
+ // Outside tmux, opentui's ScrollBox expands over intermediate siblings.
227
+ // Keeping exactly 2 direct children (ScrollBox + bottomPanel) avoids the issue,
228
+ // matching the pattern that works in help/thread-list dialogs.
229
+ dialog.container.remove(dialog.hintBox.id);
230
+ const bottomPanel = new BoxRenderable(renderer, {
231
+ width: "100%",
232
+ height: 6, // separator(1) + textarea(4) + hints(1)
233
+ flexShrink: 0,
234
+ flexGrow: 0,
235
+ flexDirection: "column",
236
+ });
237
+ const sep = new BoxRenderable(renderer, {
226
238
  width: "100%",
227
239
  height: 1,
228
- fg: theme.backgroundElement,
229
- wrapMode: "none",
240
+ flexShrink: 0,
241
+ border: ["top"],
242
+ borderColor: theme.border,
230
243
  });
231
- dialog.container.add(sep);
232
-
233
- // --- Textarea (visible in both modes, focused only in insert) ---
234
- dialog.container.add(textarea);
244
+ bottomPanel.add(sep);
245
+ bottomPanel.add(textarea);
246
+ bottomPanel.add(dialog.hintBox);
247
+ dialog.container.add(bottomPanel);
235
248
 
236
249
  // --- Mode helpers (need dialog.setHints available) ---
237
250
  function enterInsert(): void {
@@ -31,10 +31,10 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
31
31
  const dialog = createDialog({
32
32
  renderer,
33
33
  title,
34
- width: "50%",
34
+ width: "44%",
35
35
  height: 9,
36
- top: "35%",
37
- left: "25%",
36
+ top: "30%",
37
+ left: "28%",
38
38
  borderColor: theme.warning,
39
39
  onDismiss: onCancel,
40
40
  hints: CONFIRM_HINTS,
package/src/tui/help.ts CHANGED
@@ -50,10 +50,10 @@ export function createHelp(opts: {
50
50
  const dialog = createDialog({
51
51
  renderer,
52
52
  title: "Help",
53
- width: "60%",
54
- height: Math.min(34, renderer.height - 2),
53
+ width: "64%",
54
+ height: Math.min(32, renderer.height - 4),
55
55
  top: "10%",
56
- left: "20%",
56
+ left: "18%",
57
57
  borderColor: theme.info,
58
58
  onDismiss: onClose,
59
59
  hints: HELP_HINTS,
@@ -92,6 +92,11 @@ export function createHelp(opts: {
92
92
  " Esc Clear search",
93
93
  " ]t/[t Next/prev thread",
94
94
  " ]r/[r Next/prev unread",
95
+ " ]1/[1 Next/prev h1 heading",
96
+ " ]2/[2 Next/prev h2 heading",
97
+ " ]3/[3 Next/prev h3 heading",
98
+ " '' Jump to previous position",
99
+ " H/M/L Screen top/middle/bottom",
95
100
  ]);
96
101
 
97
102
  addHelpSection(dialog.content, renderer, "Review", [
@@ -50,10 +50,10 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
50
50
  const dialog = createDialog({
51
51
  renderer,
52
52
  title: `Threads (${activeCount} active, ${allThreads.length} total)`,
53
- width: "70%",
54
- height: "60%",
55
- top: "15%",
56
- left: "15%",
53
+ width: "56%",
54
+ height: "50%",
55
+ top: "20%",
56
+ left: "22%",
57
57
  borderColor: theme.mauve,
58
58
  onDismiss: onCancel,
59
59
  hints: THREAD_LIST_HINTS,
@@ -23,6 +23,7 @@ export interface DialogOptions {
23
23
  export interface DialogComponents {
24
24
  container: BoxRenderable;
25
25
  content: ScrollBoxRenderable;
26
+ hintBox: BoxRenderable;
26
27
  hintText: TextRenderable;
27
28
  setHints: (hints: Hint[]) => void;
28
29
  cleanup: () => void;
@@ -102,5 +103,5 @@ export function createDialog(opts: DialogOptions): DialogComponents {
102
103
  renderer.keyInput.off("keypress", keyHandler);
103
104
  }
104
105
 
105
- return { container, content, hintText, setHints, cleanup };
106
+ return { container, content, hintBox, hintText, setHints, cleanup };
106
107
  }
@@ -1,8 +1,8 @@
1
1
  export const theme = {
2
2
  // Surfaces
3
- base: "#1e1e2e",
3
+ base: undefined,
4
4
  backgroundPanel: "#313244",
5
- backgroundElement: "#45475a",
5
+ backgroundElement: undefined,
6
6
 
7
7
  // Text hierarchy
8
8
  text: "#cdd6f4",