revspec 0.5.0 → 0.7.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.
Files changed (47) hide show
  1. package/README.md +84 -67
  2. package/bin/revspec.ts +4 -38
  3. package/package.json +20 -3
  4. package/skills/revspec/SKILL.md +38 -31
  5. package/src/cli/reply.ts +1 -1
  6. package/src/cli/watch.ts +69 -41
  7. package/src/protocol/live-events.ts +6 -16
  8. package/src/state/review-state.ts +37 -24
  9. package/src/tui/app.ts +168 -107
  10. package/src/tui/comment-input.ts +21 -14
  11. package/src/tui/confirm.ts +4 -6
  12. package/src/tui/help.ts +77 -20
  13. package/src/tui/pager.ts +4 -2
  14. package/src/tui/search.ts +9 -4
  15. package/src/tui/spinner.ts +81 -0
  16. package/src/tui/status-bar.ts +9 -8
  17. package/src/tui/thread-list.ts +62 -22
  18. package/src/tui/ui/keymap.ts +55 -0
  19. package/.github/workflows/ci.yml +0 -18
  20. package/CLAUDE.md +0 -27
  21. package/bun.lock +0 -213
  22. package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
  23. package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
  24. package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
  25. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
  26. package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
  27. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
  28. package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
  29. package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
  30. package/scripts/install-skill.sh +0 -20
  31. package/scripts/release.sh +0 -52
  32. package/test/cli-reply.test.ts +0 -140
  33. package/test/cli-watch.test.ts +0 -216
  34. package/test/cli.test.ts +0 -160
  35. package/test/e2e-live.test.ts +0 -171
  36. package/test/live-interaction.test.ts +0 -398
  37. package/test/opentui-smoke.test.ts +0 -12
  38. package/test/protocol/live-events.test.ts +0 -509
  39. package/test/protocol/live-merge.test.ts +0 -167
  40. package/test/protocol/merge.test.ts +0 -100
  41. package/test/protocol/read.test.ts +0 -92
  42. package/test/protocol/types.test.ts +0 -95
  43. package/test/protocol/write.test.ts +0 -72
  44. package/test/state/review-state.test.ts +0 -399
  45. package/test/tui/pager.test.ts +0 -159
  46. package/test/tui/ui/keybinds.test.ts +0 -71
  47. package/tsconfig.json +0 -14
@@ -5,6 +5,7 @@ import {
5
5
  } from "@opentui/core";
6
6
  import { theme } from "./ui/theme";
7
7
  import { createDialog } from "./ui/dialog";
8
+ import { CONFIRM_HINTS } from "./ui/keymap";
8
9
 
9
10
  export interface ConfirmOptions {
10
11
  renderer: CliRenderer;
@@ -36,10 +37,7 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
36
37
  left: "25%",
37
38
  borderColor: theme.warning,
38
39
  onDismiss: onCancel,
39
- hints: [
40
- { key: "y", action: "yes" },
41
- { key: "n/Esc", action: "no" },
42
- ],
40
+ hints: CONFIRM_HINTS,
43
41
  });
44
42
 
45
43
  const msgText = new TextRenderable(renderer, {
@@ -52,13 +50,13 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
52
50
  dialog.content.add(msgText);
53
51
 
54
52
  const extraKeyHandler = (key: KeyEvent) => {
55
- if (key.name === "y") {
53
+ if (key.name === "y" || key.name === "return") {
56
54
  key.preventDefault();
57
55
  key.stopPropagation();
58
56
  onConfirm();
59
57
  return;
60
58
  }
61
- if (key.name === "n") {
59
+ if (key.name === "q") {
62
60
  key.preventDefault();
63
61
  key.stopPropagation();
64
62
  onCancel();
package/src/tui/help.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from "@opentui/core";
7
7
  import { theme } from "./ui/theme";
8
8
  import { createDialog } from "./ui/dialog";
9
+ import { HELP_HINTS } from "./ui/keymap";
9
10
 
10
11
  export interface HelpOverlay {
11
12
  container: import("@opentui/core").BoxRenderable;
@@ -50,15 +51,12 @@ export function createHelp(opts: {
50
51
  renderer,
51
52
  title: "Help",
52
53
  width: "60%",
53
- height: Math.min(26, renderer.height - 2),
54
+ height: Math.min(34, renderer.height - 2),
54
55
  top: "10%",
55
56
  left: "20%",
56
57
  borderColor: theme.info,
57
58
  onDismiss: onClose,
58
- hints: [
59
- { key: "q/?/Esc", action: "close" },
60
- { key: "j/k", action: "scroll" },
61
- ],
59
+ hints: HELP_HINTS,
62
60
  });
63
61
 
64
62
  // Version header
@@ -71,41 +69,58 @@ export function createHelp(opts: {
71
69
  wrapMode: "none",
72
70
  }));
73
71
 
72
+ addHelpSection(dialog.content, renderer, "Quick Start", [
73
+ " Navigate to a line and press c to comment.",
74
+ " The AI replies in real-time via the thread popup.",
75
+ " Press r to resolve, S to submit for rewrite.",
76
+ " Press A to approve when done.",
77
+ ]);
78
+
79
+ addHelpSection(dialog.content, renderer, "Thread Popup", [
80
+ " New thread: INSERT mode — type and Tab to send.",
81
+ " Existing thread: NORMAL mode — read conversation,",
82
+ " c to reply, r to resolve, q/Esc to close.",
83
+ ]);
84
+
74
85
  addHelpSection(dialog.content, renderer, "Navigation", [
75
86
  " j/k Down/up",
76
- " gg Go to first line / scroll to top",
77
- " G Go to last line / scroll to bottom",
87
+ " gg/G Top/bottom",
78
88
  " Ctrl+d/u Half page down/up",
79
- " / Search",
80
- " n/N Next/prev search match",
81
- " Esc Clear search highlights",
89
+ " zz Center cursor line",
90
+ " / Search (smartcase)",
91
+ " n/N Next/prev match",
92
+ " Esc Clear search",
82
93
  " ]t/[t Next/prev thread",
83
- " ]r/[r Next/prev unread thread",
94
+ " ]r/[r Next/prev unread",
84
95
  ]);
85
96
 
86
97
  addHelpSection(dialog.content, renderer, "Review", [
87
- " c Comment / view thread / reply",
88
- " r Resolve thread",
98
+ " c Comment / view thread",
99
+ " r Resolve thread (toggle)",
89
100
  " R Resolve all pending",
90
- " dd Delete thread (with confirm)",
91
- " l List threads",
92
- " a Approve spec",
101
+ " dd Delete thread",
102
+ " t List threads",
103
+ " S Submit for rewrite",
104
+ " A Approve spec",
93
105
  ]);
94
106
 
95
107
  addHelpSection(dialog.content, renderer, "Commands", [
96
- " :w Show save status",
97
- " :q Quit (blocks if unsaved)",
98
- " :wq Save and quit",
99
- " :q! Quit without saving",
108
+ " :q/:wq Quit (warns if unresolved)",
109
+ " :q! Force quit",
110
+ " :{N} Jump to line N",
111
+ " Ctrl+C Force quit",
100
112
  ]);
101
113
 
102
114
  // Trailing blank line
103
115
  dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
104
116
 
117
+ let pendingG: ReturnType<typeof setTimeout> | null = null;
118
+
105
119
  const extraKeyHandler = (key: KeyEvent) => {
106
120
  if (key.name === "q" || key.sequence === "?") {
107
121
  key.preventDefault();
108
122
  key.stopPropagation();
123
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
109
124
  onClose();
110
125
  return;
111
126
  }
@@ -123,6 +138,47 @@ export function createHelp(opts: {
123
138
  renderer.requestRender();
124
139
  return;
125
140
  }
141
+ // G = goto bottom
142
+ if (key.name === "g" && key.shift) {
143
+ key.preventDefault();
144
+ key.stopPropagation();
145
+ if (pendingG) { clearTimeout(pendingG); pendingG = null; }
146
+ dialog.content.scrollTo(dialog.content.scrollHeight);
147
+ renderer.requestRender();
148
+ return;
149
+ }
150
+ // gg = goto top
151
+ if (key.name === "g" && !key.shift && !key.ctrl) {
152
+ key.preventDefault();
153
+ key.stopPropagation();
154
+ if (pendingG) {
155
+ clearTimeout(pendingG);
156
+ pendingG = null;
157
+ dialog.content.scrollTo(0);
158
+ renderer.requestRender();
159
+ } else {
160
+ pendingG = setTimeout(() => { pendingG = null; }, 300);
161
+ }
162
+ return;
163
+ }
164
+ // Ctrl+D = half page down
165
+ if (key.ctrl && key.name === "d") {
166
+ key.preventDefault();
167
+ key.stopPropagation();
168
+ const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
169
+ dialog.content.scrollTo(Math.min(dialog.content.scrollTop + half, dialog.content.scrollHeight));
170
+ renderer.requestRender();
171
+ return;
172
+ }
173
+ // Ctrl+U = half page up
174
+ if (key.ctrl && key.name === "u") {
175
+ key.preventDefault();
176
+ key.stopPropagation();
177
+ const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
178
+ dialog.content.scrollTo(Math.max(dialog.content.scrollTop - half, 0));
179
+ renderer.requestRender();
180
+ return;
181
+ }
126
182
  };
127
183
 
128
184
  renderer.keyInput.on("keypress", extraKeyHandler);
@@ -130,6 +186,7 @@ export function createHelp(opts: {
130
186
  return {
131
187
  container: dialog.container,
132
188
  cleanup() {
189
+ if (pendingG) clearTimeout(pendingG);
133
190
  dialog.cleanup();
134
191
  renderer.keyInput.off("keypress", extraKeyHandler);
135
192
  },
package/src/tui/pager.ts CHANGED
@@ -35,7 +35,8 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
35
35
  const prefix = isCursor ? ">" : " ";
36
36
  let specText = state.specLines[i];
37
37
  if (searchQuery) {
38
- const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
38
+ const csSensitive = searchQuery !== searchQuery.toLowerCase();
39
+ const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), csSensitive ? "g" : "gi");
39
40
  specText = specText.replace(regex, (match) => `>>${match}<<`);
40
41
  }
41
42
  let indicator = " ";
@@ -173,7 +174,8 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
173
174
  } else if (searchQuery) {
174
175
  // When searching, show colored match segments (no markdown styling)
175
176
  const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
176
- const searchRegex = new RegExp(`(${escaped})`, "gi");
177
+ const caseSensitive = searchQuery !== searchQuery.toLowerCase();
178
+ const searchRegex = new RegExp(`(${escaped})`, caseSensitive ? "g" : "gi");
177
179
  const parts = specText.split(searchRegex);
178
180
  for (let p = 0; p < parts.length; p++) {
179
181
  const part = parts[p];
package/src/tui/search.ts CHANGED
@@ -83,18 +83,23 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
83
83
  if (key.name === "return") {
84
84
  key.preventDefault();
85
85
  key.stopPropagation();
86
- const query = input.value.trim().toLowerCase();
87
- if (query.length === 0) {
86
+ const raw = input.value.trim();
87
+ if (raw.length === 0) {
88
88
  onCancel();
89
89
  return;
90
90
  }
91
91
 
92
+ // Smartcase: if query has any uppercase, case-sensitive; otherwise case-insensitive
93
+ const caseSensitive = raw !== raw.toLowerCase();
94
+ const query = caseSensitive ? raw : raw.toLowerCase();
95
+
92
96
  // Search forward from cursor, wrapping around
93
97
  const total = specLines.length;
94
98
  for (let offset = 1; offset <= total; offset++) {
95
99
  const i = (cursorLine - 1 + offset) % total;
96
- if (specLines[i].toLowerCase().includes(query)) {
97
- onResult(i + 1, query); // 1-based line number + query
100
+ const line = caseSensitive ? specLines[i] : specLines[i].toLowerCase();
101
+ if (line.includes(query)) {
102
+ onResult(i + 1, raw); // 1-based line number + original query (preserves case for n/N)
98
103
  return;
99
104
  }
100
105
  }
@@ -0,0 +1,81 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ type KeyEvent,
6
+ } from "@opentui/core";
7
+ import { theme } from "./ui/theme";
8
+
9
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
+
11
+ export interface SpinnerOverlay {
12
+ container: BoxRenderable;
13
+ cleanup: () => void;
14
+ }
15
+
16
+ export function createSpinner(opts: {
17
+ renderer: CliRenderer;
18
+ message: string;
19
+ timeoutMs?: number;
20
+ onCancel: () => void;
21
+ onTimeout: () => void;
22
+ }): SpinnerOverlay {
23
+ const { renderer, message, onCancel, onTimeout, timeoutMs = 120_000 } = opts;
24
+
25
+ const container = new BoxRenderable(renderer, {
26
+ position: "absolute",
27
+ top: "40%",
28
+ left: "25%",
29
+ width: "50%",
30
+ height: 5,
31
+ zIndex: 100,
32
+ backgroundColor: theme.backgroundPanel,
33
+ border: true,
34
+ borderStyle: "single",
35
+ borderColor: theme.blue,
36
+ title: " Submitting ",
37
+ flexDirection: "column",
38
+ paddingLeft: 2,
39
+ paddingRight: 2,
40
+ paddingTop: 1,
41
+ alignItems: "center",
42
+ });
43
+
44
+ const text = new TextRenderable(renderer, {
45
+ content: `${SPINNER_FRAMES[0]} ${message}`,
46
+ width: "100%",
47
+ height: 1,
48
+ fg: theme.text,
49
+ wrapMode: "none",
50
+ });
51
+ container.add(text);
52
+
53
+ let frame = 0;
54
+ const spinInterval = setInterval(() => {
55
+ frame = (frame + 1) % SPINNER_FRAMES.length;
56
+ text.content = `${SPINNER_FRAMES[frame]} ${message}`;
57
+ renderer.requestRender();
58
+ }, 80);
59
+
60
+ const timeout = setTimeout(() => {
61
+ onTimeout();
62
+ }, timeoutMs);
63
+
64
+ const keyHandler = (key: KeyEvent) => {
65
+ if (key.ctrl && key.name === "c") {
66
+ key.preventDefault();
67
+ key.stopPropagation();
68
+ onCancel();
69
+ }
70
+ };
71
+ renderer.keyInput.on("keypress", keyHandler);
72
+
73
+ return {
74
+ container,
75
+ cleanup() {
76
+ clearInterval(spinInterval);
77
+ clearTimeout(timeout);
78
+ renderer.keyInput.off("keypress", keyHandler);
79
+ },
80
+ };
81
+ }
@@ -2,7 +2,8 @@ import { BoxRenderable, TextRenderable, TextNodeRenderable, TextAttributes, type
2
2
  import type { ReviewState } from "../state/review-state";
3
3
  import { basename } from "path";
4
4
  import { theme } from "./ui/theme";
5
- import { buildHints, type Hint } from "./ui/hint-bar";
5
+ import { buildHints } from "./ui/hint-bar";
6
+ import { PAGER_HINTS } from "./ui/keymap";
6
7
 
7
8
  export interface TopBarComponents {
8
9
  box: BoxRenderable;
@@ -83,16 +84,16 @@ export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string |
83
84
  t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
84
85
  return;
85
86
  }
86
- const hints: Hint[] = [
87
- { key: "j/k", action: "move" },
88
- { key: "c", action: "comment" },
87
+ const hints = [
88
+ PAGER_HINTS.navigate,
89
+ PAGER_HINTS.comment,
89
90
  ];
90
91
  if (hasThread) {
91
- hints.push({ key: "r", action: "resolve" });
92
- hints.push({ key: "dd", action: "delete thread" });
92
+ hints.push(PAGER_HINTS.resolve);
93
93
  }
94
- hints.push({ key: "/", action: "search" });
95
- hints.push({ key: "?", action: "help" });
94
+ hints.push(PAGER_HINTS.submit);
95
+ hints.push(PAGER_HINTS.approve);
96
+ hints.push(PAGER_HINTS.help);
96
97
  buildHints(t, hints);
97
98
  }
98
99
 
@@ -3,10 +3,12 @@ import {
3
3
  SelectRenderable,
4
4
  SelectRenderableEvents,
5
5
  type CliRenderer,
6
+ type KeyEvent,
6
7
  } from "@opentui/core";
7
8
  import type { Thread } from "../protocol/types";
8
9
  import { theme, STATUS_ICONS } from "./ui/theme";
9
10
  import { createDialog } from "./ui/dialog";
11
+ import { THREAD_LIST_HINTS } from "./ui/keymap";
10
12
 
11
13
  export interface ThreadListOptions {
12
14
  renderer: CliRenderer;
@@ -24,46 +26,44 @@ const MAX_PREVIEW_LENGTH = 50;
24
26
 
25
27
  function previewText(thread: Thread): string {
26
28
  if (thread.messages.length === 0) return "(empty)";
27
- const last = thread.messages[0];
28
- const text = last.text.replace(/\n/g, " ");
29
+ const first = thread.messages[0];
30
+ const text = first.text.replace(/\n/g, " ");
29
31
  if (text.length <= MAX_PREVIEW_LENGTH) return text;
30
32
  return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
31
33
  }
32
34
 
33
35
  /**
34
- * Create a thread list overlay showing open/pending threads.
36
+ * Create a thread list overlay showing all threads.
35
37
  * Select + Enter: jump to that thread's line.
36
38
  * Escape: cancel.
37
39
  */
38
40
  export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
39
41
  const { renderer, threads, onSelect, onCancel } = opts;
40
42
 
41
- // Filter to active threads (open/pending)
42
- const activeThreads = threads.filter(
43
- (t) => t.status === "open" || t.status === "pending"
43
+ const allThreads = threads.filter(
44
+ (t) => t.status === "open" || t.status === "pending" || t.status === "resolved"
44
45
  );
45
-
46
- const count = activeThreads.length;
46
+ const activeCount = threads.filter(
47
+ (t) => t.status === "open" || t.status === "pending"
48
+ ).length;
47
49
 
48
50
  const dialog = createDialog({
49
51
  renderer,
50
- title: `Threads (${count} active)`,
52
+ title: `Threads (${activeCount} active, ${allThreads.length} total)`,
51
53
  width: "70%",
52
54
  height: "60%",
53
55
  top: "15%",
54
56
  left: "15%",
55
57
  borderColor: theme.mauve,
56
58
  onDismiss: onCancel,
57
- hints: [
58
- { key: "j/k", action: "navigate" },
59
- { key: "Enter", action: "jump" },
60
- { key: "Esc", action: "close" },
61
- ],
59
+ hints: THREAD_LIST_HINTS,
62
60
  });
63
61
 
64
- if (activeThreads.length === 0) {
62
+ let keyHandler: ((key: KeyEvent) => void) | null = null;
63
+
64
+ if (allThreads.length === 0) {
65
65
  const emptyMsg = new TextRenderable(renderer, {
66
- content: "No active threads. Press [Esc] to close.",
66
+ content: "No threads. Press [Esc] to close.",
67
67
  width: "100%",
68
68
  height: 1,
69
69
  fg: theme.textDim,
@@ -71,8 +71,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
71
71
  });
72
72
  dialog.content.add(emptyMsg);
73
73
  } else {
74
- // Build select options from threads
75
- const selectOptions = activeThreads.map((t) => {
74
+ const selectOptions = allThreads.map((t) => {
76
75
  const icon = STATUS_ICONS[t.status];
77
76
  return {
78
77
  name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
@@ -100,20 +99,61 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
100
99
 
101
100
  dialog.content.add(select);
102
101
 
103
- // Focus the select so it handles j/k navigation
104
- renderer.focusRenderable(select);
102
+ setTimeout(() => {
103
+ renderer.focusRenderable(select);
104
+ renderer.requestRender();
105
+ }, 0);
105
106
 
106
- // Listen for item selection (Enter key)
107
+ // SelectRenderable ITEM_SELECTED event
107
108
  select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
108
109
  const selected = select.getSelectedOption();
109
110
  if (selected && selected.value != null) {
110
111
  onSelect(selected.value as number);
111
112
  }
112
113
  });
114
+
115
+ // Manual key handler — SelectRenderable focus is unreliable
116
+ keyHandler = (key: KeyEvent) => {
117
+ if (key.name === "q") {
118
+ key.preventDefault();
119
+ key.stopPropagation();
120
+ onCancel();
121
+ return;
122
+ }
123
+ if (key.name === "return" || key.name === "y") {
124
+ key.preventDefault();
125
+ key.stopPropagation();
126
+ const selected = select.getSelectedOption();
127
+ if (selected && selected.value != null) {
128
+ onSelect(selected.value as number);
129
+ }
130
+ return;
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);
113
148
  }
114
149
 
115
150
  return {
116
151
  container: dialog.container,
117
- cleanup: dialog.cleanup,
152
+ cleanup() {
153
+ dialog.cleanup();
154
+ if (keyHandler) {
155
+ renderer.keyInput.off("keypress", keyHandler);
156
+ }
157
+ },
118
158
  };
119
159
  }
@@ -0,0 +1,55 @@
1
+ import type { Hint } from "./hint-bar";
2
+
3
+ /**
4
+ * Central keymap definitions.
5
+ * All keybinding display labels live here — components import hints from this file.
6
+ * To remap a key, change it here and in the bindings array in app.ts.
7
+ */
8
+
9
+ // --- Main pager ---
10
+
11
+ export const PAGER_HINTS = {
12
+ navigate: { key: "j/k", action: "navigate" } as Hint,
13
+ comment: { key: "c", action: "comment" } as Hint,
14
+ resolve: { key: "r", action: "resolve" } as Hint,
15
+ submit: { key: "S", action: "submit" } as Hint,
16
+ approve: { key: "A", action: "approve" } as Hint,
17
+ help: { key: "?", action: "help" } as Hint,
18
+ };
19
+
20
+ // --- Thread popup ---
21
+
22
+ export const THREAD_NORMAL_HINTS: Hint[] = [
23
+ { key: "NORMAL", action: "" },
24
+ { key: "c", action: "reply" },
25
+ { key: "r", action: "resolve" },
26
+ { key: "q/Esc", action: "close" },
27
+ ];
28
+
29
+ export const THREAD_INSERT_HINTS: Hint[] = [
30
+ { key: "INSERT", action: "" },
31
+ { key: "Tab", action: "send" },
32
+ { key: "Esc", action: "normal" },
33
+ ];
34
+
35
+ // --- Thread list ---
36
+
37
+ export const THREAD_LIST_HINTS: Hint[] = [
38
+ { key: "j/k", action: "navigate" },
39
+ { key: "y/Enter", action: "jump" },
40
+ { key: "q/Esc", action: "close" },
41
+ ];
42
+
43
+ // --- Help overlay ---
44
+
45
+ export const HELP_HINTS: Hint[] = [
46
+ { key: "j/k", action: "navigate" },
47
+ { key: "q/?/Esc", action: "close" },
48
+ ];
49
+
50
+ // --- Confirm dialog ---
51
+
52
+ export const CONFIRM_HINTS: Hint[] = [
53
+ { key: "y/Enter", action: "yes" },
54
+ { key: "q/Esc", action: "no" },
55
+ ];
@@ -1,18 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches:
6
- - main
7
- pull_request:
8
-
9
- jobs:
10
- test:
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: actions/checkout@v4
14
- - uses: oven-sh/setup-bun@v2
15
- with:
16
- bun-version: latest
17
- - run: bun install
18
- - run: bun test
package/CLAUDE.md DELETED
@@ -1,27 +0,0 @@
1
- # Revspec
2
-
3
- - Tech: Bun + TypeScript + @opentui/core
4
- - npm: `revspec` | GitHub: icyrainz/revspec
5
- - Run: `bun run bin/revspec.ts <file.md>`
6
- - Test: `bun test`
7
- - Release: `./scripts/release.sh` (version is set manually in package.json)
8
- - Dev: `bun link` to symlink local build to global `revspec` command
9
-
10
- ## OpenTUI Gotchas
11
- - Don't use StyledText at all — BigInt FFI crash happens even on small content
12
- - Don't use ANSI escape codes in TextRenderable content (renders as literal text)
13
- - MarkdownRenderable needs `syntaxStyle` + `conceal: true` for proper rendering
14
- - Use `visible: false` to hide renderables, not removal/re-addition
15
- - ScrollBox: don't use `stickyScroll` with manual scrolling (fights scroll position)
16
- - ScrollBox: `scrollBy` overshoots silently on large deltas — use `scrollTo` with clamped position
17
- - Textarea consumes Ctrl+D/U (emacs bindings) — blur textarea in normal mode for vim-style scroll
18
-
19
- ## Conventions
20
- - Line mode is default, markdown mode via `m` toggle
21
- - Tab to submit in all text inputs (works through tmux)
22
- - Destructive actions need confirmation (dd double-tap, approve confirm dialog)
23
- - All review actions auto-switch to line mode
24
- - Thread popup uses vim-style normal/insert modes (blur textarea in normal)
25
- - Hint bars use `[key] action` bracket format consistently
26
- - No inline comment previews in pager — gutter indicators only (▌/█/✓)
27
- - Live integration: JSONL for communication, `revspec watch`/`reply` CLI subcommands