revspec 0.3.0 → 0.5.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.
@@ -2,12 +2,12 @@ import {
2
2
  BoxRenderable,
3
3
  TextRenderable,
4
4
  TextareaRenderable,
5
- ScrollBoxRenderable,
6
5
  type CliRenderer,
7
6
  type KeyEvent,
8
7
  } from "@opentui/core";
9
8
  import type { Thread, Message } from "../protocol/types";
10
- import { theme } from "./theme";
9
+ import { theme } from "./ui/theme";
10
+ import { createDialog } from "./ui/dialog";
11
11
 
12
12
  export interface CommentInputOptions {
13
13
  renderer: CliRenderer;
@@ -42,30 +42,140 @@ function createThreadView(
42
42
  onResolve: () => void,
43
43
  onCancel: () => void,
44
44
  ): CommentInputOverlay {
45
- const container = new BoxRenderable(renderer, {
46
- position: "absolute",
47
- top: "5%",
48
- left: "10%",
45
+ const title = thread.id
46
+ ? `Thread #${thread.id} (line ${line})`
47
+ : `New comment on line ${line}`;
48
+
49
+ const normalHints = [
50
+ { key: "NORMAL", action: "" },
51
+ { key: "c", action: "reply" },
52
+ { key: "r", action: "resolve" },
53
+ { key: "Esc/q", action: "close" },
54
+ ];
55
+ const insertHints = [
56
+ { key: "INSERT", action: "" },
57
+ { key: "Tab", action: "send" },
58
+ { key: "Esc", action: "back" },
59
+ ];
60
+
61
+ // --- State ---
62
+ let mode: "normal" | "insert" = "insert";
63
+
64
+ // Build the textarea now (we need it in the key handler closure)
65
+ const textarea = new TextareaRenderable(renderer, {
66
+ width: "100%",
67
+ height: 4,
68
+ flexGrow: 0,
69
+ flexShrink: 0,
70
+ backgroundColor: theme.backgroundElement,
71
+ textColor: theme.textDim,
72
+ focusedBackgroundColor: theme.backgroundPanel,
73
+ focusedTextColor: theme.text,
74
+ wrapMode: "word",
75
+ placeholder: "Press c to reply...",
76
+ placeholderColor: theme.textDim,
77
+ initialValue: "",
78
+ });
79
+
80
+ // Register our comprehensive key handler FIRST (before createDialog) so it
81
+ // fires before the dialog's Esc handler and can stopPropagation in insert mode.
82
+ const keyHandler = (key: KeyEvent) => {
83
+ if (mode === "insert") {
84
+ // --- INSERT MODE ---
85
+ if (key.name === "escape") {
86
+ key.preventDefault();
87
+ key.stopPropagation();
88
+ enterNormal();
89
+ return;
90
+ }
91
+ if (key.name === "tab") {
92
+ key.preventDefault();
93
+ key.stopPropagation();
94
+ const text = textarea.plainText.trim();
95
+ if (text.length === 0) return;
96
+ onSubmit(text);
97
+ appendToConversation({ author: "reviewer", text, ts: Date.now() });
98
+ textarea.selectAll();
99
+ textarea.deleteChar();
100
+ enterNormal();
101
+ return;
102
+ }
103
+ // All other keys: let textarea handle them (don't preventDefault)
104
+ return;
105
+ }
106
+
107
+ // --- NORMAL MODE (textarea blurred, all keys are ours) ---
108
+ key.preventDefault();
109
+ key.stopPropagation();
110
+
111
+ switch (key.name) {
112
+ case "escape":
113
+ case "q":
114
+ onCancel();
115
+ return;
116
+
117
+ case "c":
118
+ enterInsert();
119
+ return;
120
+
121
+ case "r":
122
+ onResolve();
123
+ return;
124
+
125
+ case "j":
126
+ case "down":
127
+ scrollBox.scrollBy({ x: 0, y: 1 });
128
+ renderer.requestRender();
129
+ return;
130
+
131
+ case "k":
132
+ case "up":
133
+ scrollBox.scrollBy({ x: 0, y: -1 });
134
+ renderer.requestRender();
135
+ return;
136
+
137
+ case "d":
138
+ if (key.ctrl) {
139
+ scrollBox.scrollBy({ x: 0, y: 5 });
140
+ renderer.requestRender();
141
+ }
142
+ return;
143
+
144
+ case "u":
145
+ if (key.ctrl) {
146
+ scrollBox.scrollBy({ x: 0, y: -5 });
147
+ renderer.requestRender();
148
+ }
149
+ return;
150
+
151
+ case "g":
152
+ if (key.shift) {
153
+ // G = go to bottom
154
+ scrollBox.scrollTo(scrollBox.scrollHeight);
155
+ renderer.requestRender();
156
+ }
157
+ // TODO: gg = go to top (needs double-tap tracking)
158
+ return;
159
+ }
160
+ };
161
+
162
+ renderer.keyInput.on("keypress", keyHandler);
163
+
164
+ // Now create the dialog (its Esc handler registers after ours)
165
+ // Pass no-op onDismiss — we handle all keys ourselves above.
166
+ const dialog = createDialog({
167
+ renderer,
168
+ title,
49
169
  width: "80%",
50
170
  height: "85%",
51
- zIndex: 100,
52
- backgroundColor: theme.base,
53
- border: true,
54
- borderStyle: "single",
55
- borderColor: theme.borderComment,
56
- title: thread.id ? ` Thread #${thread.id} (line ${line}) ` : ` New comment on line ${line} `,
57
- flexDirection: "column",
58
- padding: 1,
171
+ borderColor: theme.blue,
172
+ onDismiss: onCancel,
173
+ hints: insertHints,
59
174
  });
60
175
 
61
- // --- Top: scrollable conversation history ---
62
- const scrollBox = new ScrollBoxRenderable(renderer, {
63
- width: "100%",
64
- flexGrow: 1,
65
- flexShrink: 1,
66
- scrollY: true,
67
- scrollX: false,
68
- });
176
+
177
+ // --- Scrollable conversation history ---
178
+ const scrollBox = dialog.content;
69
179
 
70
180
  function renderMessage(msg: Message): BoxRenderable {
71
181
  const isReviewer = msg.author === "reviewer";
@@ -87,7 +197,7 @@ function createThreadView(
87
197
  content: tsStr ? `${label} ${tsStr}` : label,
88
198
  width: "100%",
89
199
  height: 1,
90
- fg: theme.subtext,
200
+ fg: theme.textMuted,
91
201
  wrapMode: "none",
92
202
  });
93
203
  msgBox.add(header);
@@ -107,64 +217,32 @@ function createThreadView(
107
217
  for (const msg of thread.messages) {
108
218
  scrollBox.add(renderMessage(msg));
109
219
  }
110
- container.add(scrollBox);
111
220
 
112
221
  // --- Separator ---
113
222
  const sep = new TextRenderable(renderer, {
114
223
  content: "\u2500".repeat(40),
115
224
  width: "100%",
116
225
  height: 1,
117
- fg: theme.surface1,
226
+ fg: theme.backgroundElement,
118
227
  wrapMode: "none",
119
228
  });
120
- container.add(sep);
229
+ dialog.container.add(sep);
121
230
 
122
- // --- Bottom: textarea (visible in both modes, focused only in insert) ---
123
- const textarea = new TextareaRenderable(renderer, {
124
- width: "100%",
125
- height: 4,
126
- flexGrow: 0,
127
- flexShrink: 0,
128
- backgroundColor: theme.surface1,
129
- textColor: theme.overlay,
130
- focusedBackgroundColor: theme.surface0,
131
- focusedTextColor: theme.text,
132
- wrapMode: "word",
133
- placeholder: "Press c to reply...",
134
- placeholderColor: theme.overlay,
135
- initialValue: "",
136
- });
137
- container.add(textarea);
138
-
139
- // --- Hint bar (changes with mode) ---
140
- const hintNormal = " [c] reply [r] resolve [Esc] close";
141
- const hintInsert = " [Tab] send [Esc] back";
142
-
143
- const hint = new TextRenderable(renderer, {
144
- content: hintInsert,
145
- width: "100%",
146
- height: 1,
147
- fg: theme.hintFg,
148
- bg: theme.hintBg,
149
- wrapMode: "none",
150
- truncate: true,
151
- });
152
- container.add(hint);
153
-
154
- // --- State ---
155
- let mode: "normal" | "insert" = "insert";
231
+ // --- Textarea (visible in both modes, focused only in insert) ---
232
+ dialog.container.add(textarea);
156
233
 
234
+ // --- Mode helpers (need dialog.setHints available) ---
157
235
  function enterInsert(): void {
158
236
  mode = "insert";
159
237
  textarea.focus();
160
- hint.content = hintInsert;
238
+ dialog.setHints(insertHints);
161
239
  renderer.requestRender();
162
240
  }
163
241
 
164
242
  function enterNormal(): void {
165
243
  mode = "normal";
166
244
  textarea.blur();
167
- hint.content = hintNormal;
245
+ dialog.setHints(normalHints);
168
246
  renderer.requestRender();
169
247
  }
170
248
 
@@ -192,89 +270,13 @@ function createThreadView(
192
270
  }, 50);
193
271
  }
194
272
 
195
- const keyHandler = (key: KeyEvent) => {
196
- if (mode === "insert") {
197
- // --- INSERT MODE ---
198
- if (key.name === "escape") {
199
- key.preventDefault(); key.stopPropagation();
200
- enterNormal();
201
- return;
202
- }
203
- if (key.name === "tab") {
204
- key.preventDefault(); key.stopPropagation();
205
- const text = textarea.plainText.trim();
206
- if (text.length === 0) return;
207
- onSubmit(text);
208
- appendToConversation({ author: "reviewer", text, ts: Date.now() });
209
- textarea.selectAll();
210
- textarea.deleteChar();
211
- enterNormal();
212
- return;
213
- }
214
- // All other keys: let textarea handle them (don't preventDefault)
215
- return;
216
- }
217
-
218
- // --- NORMAL MODE (textarea blurred, all keys are ours) ---
219
- key.preventDefault(); key.stopPropagation();
220
-
221
- switch (key.name) {
222
- case "escape":
223
- case "q":
224
- onCancel();
225
- return;
226
-
227
- case "c":
228
- enterInsert();
229
- return;
230
-
231
- case "r":
232
- onResolve();
233
- return;
234
-
235
- case "j":
236
- case "down":
237
- scrollBox.scrollBy({ x: 0, y: 1 });
238
- renderer.requestRender();
239
- return;
240
-
241
- case "k":
242
- case "up":
243
- scrollBox.scrollBy({ x: 0, y: -1 });
244
- renderer.requestRender();
245
- return;
246
-
247
- case "d":
248
- if (key.ctrl) {
249
- // Use same scrollBy as j/k, just more lines
250
- scrollBox.scrollBy({ x: 0, y: 5 });
251
- renderer.requestRender();
252
- }
253
- return;
254
-
255
- case "u":
256
- if (key.ctrl) {
257
- scrollBox.scrollBy({ x: 0, y: -5 });
258
- renderer.requestRender();
259
- }
260
- return;
261
-
262
- case "g":
263
- if (key.shift) {
264
- // G = go to bottom
265
- scrollBox.scrollTo(scrollBox.scrollHeight);
266
- renderer.requestRender();
267
- }
268
- // TODO: gg = go to top (needs double-tap tracking)
269
- return;
270
- }
271
- };
272
-
273
- renderer.keyInput.on("keypress", keyHandler);
274
-
275
273
  return {
276
- container,
277
- cleanup() { renderer.keyInput.off("keypress", keyHandler); textarea.destroy(); },
274
+ container: dialog.container,
275
+ cleanup() {
276
+ renderer.keyInput.off("keypress", keyHandler);
277
+ dialog.cleanup();
278
+ textarea.destroy();
279
+ },
278
280
  threadId: thread.id,
279
281
  addMessage(msg: Message) { appendToConversation(msg); },
280
282
  };
@@ -1,20 +1,21 @@
1
1
  import {
2
- BoxRenderable,
3
2
  TextRenderable,
4
3
  type CliRenderer,
5
4
  type KeyEvent,
6
5
  } from "@opentui/core";
7
- import { theme } from "./theme";
6
+ import { theme } from "./ui/theme";
7
+ import { createDialog } from "./ui/dialog";
8
8
 
9
9
  export interface ConfirmOptions {
10
10
  renderer: CliRenderer;
11
11
  message: string;
12
+ title?: string;
12
13
  onConfirm: () => void;
13
14
  onCancel: () => void;
14
15
  }
15
16
 
16
17
  export interface ConfirmOverlay {
17
- container: BoxRenderable;
18
+ container: import("@opentui/core").BoxRenderable;
18
19
  cleanup: () => void;
19
20
  }
20
21
 
@@ -24,70 +25,55 @@ export interface ConfirmOverlay {
24
25
  * y → confirm, n/Esc → cancel
25
26
  */
26
27
  export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
27
- const { renderer, message, onConfirm, onCancel } = opts;
28
+ const { renderer, message, title = "Confirm", onConfirm, onCancel } = opts;
28
29
 
29
- // Centered dialog
30
- const container = new BoxRenderable(renderer, {
31
- position: "absolute",
30
+ const dialog = createDialog({
31
+ renderer,
32
+ title,
33
+ width: "50%",
34
+ height: 9,
32
35
  top: "35%",
33
36
  left: "25%",
34
- width: "50%",
35
- height: 7,
36
- zIndex: 100,
37
- backgroundColor: theme.base,
38
- border: true,
39
- borderStyle: "single",
40
- borderColor: theme.borderConfirm,
41
- title: " Confirm ",
42
- flexDirection: "column",
43
- padding: 1,
44
- alignItems: "center",
45
- justifyContent: "center",
37
+ borderColor: theme.warning,
38
+ onDismiss: onCancel,
39
+ hints: [
40
+ { key: "y", action: "yes" },
41
+ { key: "n/Esc", action: "no" },
42
+ ],
46
43
  });
47
44
 
48
45
  const msgText = new TextRenderable(renderer, {
49
46
  content: message,
50
47
  width: "100%",
51
- height: 1,
52
48
  fg: theme.text,
53
- wrapMode: "none",
54
- truncate: true,
49
+ wrapMode: "word",
55
50
  });
56
51
 
57
- const hint = new TextRenderable(renderer, {
58
- content: " [y] yes [n/Esc] no",
59
- width: "100%",
60
- height: 1,
61
- fg: theme.hintFg,
62
- bg: theme.hintBg,
63
- wrapMode: "none",
64
- truncate: true,
65
- });
52
+ dialog.content.add(msgText);
66
53
 
67
- container.add(msgText);
68
- container.add(hint);
69
-
70
- // Key handler
71
- const keyHandler = (key: KeyEvent) => {
54
+ const extraKeyHandler = (key: KeyEvent) => {
72
55
  if (key.name === "y") {
73
56
  key.preventDefault();
74
57
  key.stopPropagation();
75
58
  onConfirm();
76
59
  return;
77
60
  }
78
- if (key.name === "n" || key.name === "escape") {
61
+ if (key.name === "n") {
79
62
  key.preventDefault();
80
63
  key.stopPropagation();
81
64
  onCancel();
82
65
  return;
83
66
  }
67
+ // Esc is handled by dialog's built-in handler (calls onDismiss = onCancel)
84
68
  };
85
69
 
86
- renderer.keyInput.on("keypress", keyHandler);
87
-
88
- function cleanup(): void {
89
- renderer.keyInput.off("keypress", keyHandler);
90
- }
70
+ renderer.keyInput.on("keypress", extraKeyHandler);
91
71
 
92
- return { container, cleanup };
72
+ return {
73
+ container: dialog.container,
74
+ cleanup() {
75
+ dialog.cleanup();
76
+ renderer.keyInput.off("keypress", extraKeyHandler);
77
+ },
78
+ };
93
79
  }
package/src/tui/help.ts CHANGED
@@ -1,20 +1,43 @@
1
1
  import {
2
- BoxRenderable,
3
- ScrollBoxRenderable,
4
2
  TextRenderable,
5
3
  type CliRenderer,
6
4
  type KeyEvent,
5
+ type ScrollBoxRenderable,
7
6
  } from "@opentui/core";
8
- import { theme } from "./theme";
7
+ import { theme } from "./ui/theme";
8
+ import { createDialog } from "./ui/dialog";
9
9
 
10
10
  export interface HelpOverlay {
11
- container: BoxRenderable;
11
+ container: import("@opentui/core").BoxRenderable;
12
12
  cleanup: () => void;
13
13
  }
14
14
 
15
+ function addHelpSection(container: ScrollBoxRenderable, renderer: CliRenderer, title: string, lines: string[]): void {
16
+ // Blank line before section
17
+ container.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
18
+ // Section header in blue
19
+ container.add(new TextRenderable(renderer, {
20
+ content: ` ${title}`,
21
+ width: "100%",
22
+ height: 1,
23
+ fg: theme.blue,
24
+ wrapMode: "none",
25
+ }));
26
+ // Content lines
27
+ for (const line of lines) {
28
+ container.add(new TextRenderable(renderer, {
29
+ content: line,
30
+ width: "100%",
31
+ height: 1,
32
+ fg: theme.text,
33
+ wrapMode: "none",
34
+ }));
35
+ }
36
+ }
37
+
15
38
  /**
16
39
  * Create a help overlay popup showing all keybindings.
17
- * Dismissable with `?` or `Esc`.
40
+ * Dismissable with `?`, `q`, or `Esc`.
18
41
  */
19
42
  export function createHelp(opts: {
20
43
  renderer: CliRenderer;
@@ -23,11 +46,32 @@ export function createHelp(opts: {
23
46
  }): HelpOverlay {
24
47
  const { renderer, version, onClose } = opts;
25
48
 
26
- const helpText = [
27
- "",
28
- ` revspec v${version}`,
29
- "",
30
- " Navigation",
49
+ const dialog = createDialog({
50
+ renderer,
51
+ title: "Help",
52
+ width: "60%",
53
+ height: Math.min(26, renderer.height - 2),
54
+ top: "10%",
55
+ left: "20%",
56
+ borderColor: theme.info,
57
+ onDismiss: onClose,
58
+ hints: [
59
+ { key: "q/?/Esc", action: "close" },
60
+ { key: "j/k", action: "scroll" },
61
+ ],
62
+ });
63
+
64
+ // Version header
65
+ dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
66
+ dialog.content.add(new TextRenderable(renderer, {
67
+ content: ` revspec v${version}`,
68
+ width: "100%",
69
+ height: 1,
70
+ fg: theme.textMuted,
71
+ wrapMode: "none",
72
+ }));
73
+
74
+ addHelpSection(dialog.content, renderer, "Navigation", [
31
75
  " j/k Down/up",
32
76
  " gg Go to first line / scroll to top",
33
77
  " G Go to last line / scroll to bottom",
@@ -37,74 +81,29 @@ export function createHelp(opts: {
37
81
  " Esc Clear search highlights",
38
82
  " ]t/[t Next/prev thread",
39
83
  " ]r/[r Next/prev unread thread",
40
- "",
41
- " Review",
84
+ ]);
85
+
86
+ addHelpSection(dialog.content, renderer, "Review", [
42
87
  " c Comment / view thread / reply",
43
88
  " r Resolve thread",
44
89
  " R Resolve all pending",
45
- " dd Delete draft comment (double-tap)",
90
+ " dd Delete thread (with confirm)",
46
91
  " l List threads",
47
92
  " a Approve spec",
48
- "",
49
- " Commands",
93
+ ]);
94
+
95
+ addHelpSection(dialog.content, renderer, "Commands", [
50
96
  " :w Show save status",
51
- " :q Save and quit",
97
+ " :q Quit (blocks if unsaved)",
52
98
  " :wq Save and quit",
53
99
  " :q! Quit without saving",
54
- "",
55
- ].join("\n");
56
-
57
- // Overlay container - centered popup
58
- const container = new BoxRenderable(renderer, {
59
- position: "absolute",
60
- top: "10%",
61
- left: "20%",
62
- width: "60%",
63
- height: Math.min(26, renderer.height - 2),
64
- zIndex: 100,
65
- backgroundColor: theme.base,
66
- border: true,
67
- borderStyle: "single",
68
- borderColor: theme.borderThread,
69
- title: " Help ",
70
- flexDirection: "column",
71
- padding: 0,
72
- });
73
-
74
- const scrollBox = new ScrollBoxRenderable(renderer, {
75
- width: "100%",
76
- flexGrow: 1,
77
- flexShrink: 1,
78
- scrollY: true,
79
- scrollX: false,
80
- backgroundColor: theme.base,
81
- });
82
-
83
- const content = new TextRenderable(renderer, {
84
- content: helpText,
85
- width: "100%",
86
- fg: theme.text,
87
- wrapMode: "none",
88
- });
89
-
90
- scrollBox.add(content);
91
-
92
- const hint = new TextRenderable(renderer, {
93
- content: " [q/?/Esc] close [j/k] scroll",
94
- width: "100%",
95
- height: 1,
96
- fg: theme.hintFg,
97
- bg: theme.hintBg,
98
- wrapMode: "none",
99
- truncate: true,
100
- });
100
+ ]);
101
101
 
102
- container.add(scrollBox);
103
- container.add(hint);
102
+ // Trailing blank line
103
+ dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
104
104
 
105
- // Key handler
106
- const keyHandler = (key: KeyEvent) => {
107
- if (key.name === "escape" || key.name === "q" || key.sequence === "?") {
105
+ const extraKeyHandler = (key: KeyEvent) => {
106
+ if (key.name === "q" || key.sequence === "?") {
108
107
  key.preventDefault();
109
108
  key.stopPropagation();
110
109
  onClose();
@@ -113,24 +112,26 @@ export function createHelp(opts: {
113
112
  if (key.name === "j" || key.name === "down") {
114
113
  key.preventDefault();
115
114
  key.stopPropagation();
116
- scrollBox.scrollBy(1);
115
+ dialog.content.scrollTo(Math.min(dialog.content.scrollTop + 1, dialog.content.scrollHeight));
117
116
  renderer.requestRender();
118
117
  return;
119
118
  }
120
119
  if (key.name === "k" || key.name === "up") {
121
120
  key.preventDefault();
122
121
  key.stopPropagation();
123
- scrollBox.scrollBy(-1);
122
+ dialog.content.scrollTo(Math.max(dialog.content.scrollTop - 1, 0));
124
123
  renderer.requestRender();
125
124
  return;
126
125
  }
127
126
  };
128
127
 
129
- renderer.keyInput.on("keypress", keyHandler);
128
+ renderer.keyInput.on("keypress", extraKeyHandler);
130
129
 
131
- function cleanup(): void {
132
- renderer.keyInput.off("keypress", keyHandler);
133
- }
134
-
135
- return { container, cleanup };
130
+ return {
131
+ container: dialog.container,
132
+ cleanup() {
133
+ dialog.cleanup();
134
+ renderer.keyInput.off("keypress", extraKeyHandler);
135
+ },
136
+ };
136
137
  }