revspec 0.7.3 → 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
@@ -30,11 +30,11 @@ Install the `/revspec` skill for Claude Code:
30
30
  /plugin install revspec
31
31
  ```
32
32
 
33
- Or manually (symlink, stays updated with repo):
33
+ Or manually (clone + symlink, stays updated with `git pull`):
34
34
 
35
35
  ```bash
36
- mkdir -p ~/.claude/skills
37
- ln -sfn /path/to/revspec/skills/revspec ~/.claude/skills/revspec
36
+ git clone https://github.com/icyrainz/revspec.git ~/.local/share/revspec
37
+ ln -sfn ~/.local/share/revspec/skills/revspec ~/.claude/skills/revspec
38
38
  ```
39
39
 
40
40
  ## Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.7.3",
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",
@@ -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
  }
@@ -693,6 +725,7 @@ export async function runTui(
693
725
  case "next-thread": {
694
726
  const next = state.nextThread();
695
727
  if (next !== null) {
728
+ savePrevPosition();
696
729
  state.cursorLine = next;
697
730
  ensureCursorVisible();
698
731
  refreshPager();
@@ -706,6 +739,7 @@ export async function runTui(
706
739
  case "prev-thread": {
707
740
  const prev = state.prevThread();
708
741
  if (prev !== null) {
742
+ savePrevPosition();
709
743
  state.cursorLine = prev;
710
744
  ensureCursorVisible();
711
745
  refreshPager();
@@ -719,6 +753,7 @@ export async function runTui(
719
753
  case "next-unread": {
720
754
  const nextLine = state.nextUnreadThread();
721
755
  if (nextLine !== null) {
756
+ savePrevPosition();
722
757
  state.cursorLine = nextLine;
723
758
  ensureCursorVisible();
724
759
  refreshPager();
@@ -732,6 +767,7 @@ export async function runTui(
732
767
  case "prev-unread": {
733
768
  const prevLine = state.prevUnreadThread();
734
769
  if (prevLine !== null) {
770
+ savePrevPosition();
735
771
  state.cursorLine = prevLine;
736
772
  ensureCursorVisible();
737
773
  refreshPager();
@@ -742,6 +778,69 @@ export async function runTui(
742
778
  }
743
779
  break;
744
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
+ }
745
844
  case "help":
746
845
  showHelpOverlay();
747
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
  }