revspec 0.2.2 → 0.4.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,139 @@ 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
+ // --- Scrollable conversation history ---
177
+ const scrollBox = dialog.content;
69
178
 
70
179
  function renderMessage(msg: Message): BoxRenderable {
71
180
  const isReviewer = msg.author === "reviewer";
@@ -87,7 +196,7 @@ function createThreadView(
87
196
  content: tsStr ? `${label} ${tsStr}` : label,
88
197
  width: "100%",
89
198
  height: 1,
90
- fg: theme.subtext,
199
+ fg: theme.textMuted,
91
200
  wrapMode: "none",
92
201
  });
93
202
  msgBox.add(header);
@@ -107,64 +216,32 @@ function createThreadView(
107
216
  for (const msg of thread.messages) {
108
217
  scrollBox.add(renderMessage(msg));
109
218
  }
110
- container.add(scrollBox);
111
219
 
112
220
  // --- Separator ---
113
221
  const sep = new TextRenderable(renderer, {
114
222
  content: "\u2500".repeat(40),
115
223
  width: "100%",
116
224
  height: 1,
117
- fg: theme.surface1,
118
- wrapMode: "none",
119
- });
120
- container.add(sep);
121
-
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,
225
+ fg: theme.backgroundElement,
149
226
  wrapMode: "none",
150
- truncate: true,
151
227
  });
152
- container.add(hint);
228
+ dialog.container.add(sep);
153
229
 
154
- // --- State ---
155
- let mode: "normal" | "insert" = "insert";
230
+ // --- Textarea (visible in both modes, focused only in insert) ---
231
+ dialog.container.add(textarea);
156
232
 
233
+ // --- Mode helpers (need dialog.setHints available) ---
157
234
  function enterInsert(): void {
158
235
  mode = "insert";
159
236
  textarea.focus();
160
- hint.content = hintInsert;
237
+ dialog.setHints(insertHints);
161
238
  renderer.requestRender();
162
239
  }
163
240
 
164
241
  function enterNormal(): void {
165
242
  mode = "normal";
166
243
  textarea.blur();
167
- hint.content = hintNormal;
244
+ dialog.setHints(normalHints);
168
245
  renderer.requestRender();
169
246
  }
170
247
 
@@ -192,89 +269,13 @@ function createThreadView(
192
269
  }, 50);
193
270
  }
194
271
 
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
272
  return {
276
- container,
277
- cleanup() { renderer.keyInput.off("keypress", keyHandler); textarea.destroy(); },
273
+ container: dialog.container,
274
+ cleanup() {
275
+ renderer.keyInput.off("keypress", keyHandler);
276
+ dialog.cleanup();
277
+ textarea.destroy();
278
+ },
278
279
  threadId: thread.id,
279
280
  addMessage(msg: Message) { appendToConversation(msg); },
280
281
  };
@@ -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,28 +1,30 @@
1
1
  import {
2
- BoxRenderable,
3
- ScrollBoxRenderable,
4
2
  TextRenderable,
5
3
  type CliRenderer,
6
4
  type KeyEvent,
7
5
  } from "@opentui/core";
8
- import { theme } from "./theme";
6
+ import { theme } from "./ui/theme";
7
+ import { createDialog } from "./ui/dialog";
9
8
 
10
9
  export interface HelpOverlay {
11
- container: BoxRenderable;
10
+ container: import("@opentui/core").BoxRenderable;
12
11
  cleanup: () => void;
13
12
  }
14
13
 
15
14
  /**
16
15
  * Create a help overlay popup showing all keybindings.
17
- * Dismissable with `?` or `Esc`.
16
+ * Dismissable with `?`, `q`, or `Esc`.
18
17
  */
19
18
  export function createHelp(opts: {
20
19
  renderer: CliRenderer;
20
+ version: string;
21
21
  onClose: () => void;
22
22
  }): HelpOverlay {
23
- const { renderer, onClose } = opts;
23
+ const { renderer, version, onClose } = opts;
24
24
 
25
25
  const helpText = [
26
+ "",
27
+ ` revspec v${version}`,
26
28
  "",
27
29
  " Navigation",
28
30
  " j/k Down/up",
@@ -35,10 +37,7 @@ export function createHelp(opts: {
35
37
  " ]t/[t Next/prev thread",
36
38
  " ]r/[r Next/prev unread thread",
37
39
  "",
38
- " View",
39
- " m Toggle markdown / line mode",
40
- "",
41
- " Review (switches to line mode)",
40
+ " Review",
42
41
  " c Comment / view thread / reply",
43
42
  " r Resolve thread",
44
43
  " R Resolve all pending",
@@ -54,30 +53,19 @@ export function createHelp(opts: {
54
53
  "",
55
54
  ].join("\n");
56
55
 
57
- // Overlay container - centered popup
58
- const container = new BoxRenderable(renderer, {
59
- position: "absolute",
60
- top: "10%",
61
- left: "20%",
56
+ const dialog = createDialog({
57
+ renderer,
58
+ title: "Help",
62
59
  width: "60%",
63
60
  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,
61
+ top: "10%",
62
+ left: "20%",
63
+ borderColor: theme.info,
64
+ onDismiss: onClose,
65
+ hints: [
66
+ { key: "q/?/Esc", action: "close" },
67
+ { key: "j/k", action: "scroll" },
68
+ ],
81
69
  });
82
70
 
83
71
  const content = new TextRenderable(renderer, {
@@ -87,24 +75,10 @@ export function createHelp(opts: {
87
75
  wrapMode: "none",
88
76
  });
89
77
 
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
- });
101
-
102
- container.add(scrollBox);
103
- container.add(hint);
78
+ dialog.content.add(content);
104
79
 
105
- // Key handler
106
- const keyHandler = (key: KeyEvent) => {
107
- if (key.name === "escape" || key.name === "q" || key.sequence === "?") {
80
+ const extraKeyHandler = (key: KeyEvent) => {
81
+ if (key.name === "q" || key.sequence === "?") {
108
82
  key.preventDefault();
109
83
  key.stopPropagation();
110
84
  onClose();
@@ -113,24 +87,26 @@ export function createHelp(opts: {
113
87
  if (key.name === "j" || key.name === "down") {
114
88
  key.preventDefault();
115
89
  key.stopPropagation();
116
- scrollBox.scrollBy(1);
90
+ dialog.content.scrollTo(Math.min(dialog.content.scrollTop + 1, dialog.content.scrollHeight));
117
91
  renderer.requestRender();
118
92
  return;
119
93
  }
120
94
  if (key.name === "k" || key.name === "up") {
121
95
  key.preventDefault();
122
96
  key.stopPropagation();
123
- scrollBox.scrollBy(-1);
97
+ dialog.content.scrollTo(Math.max(dialog.content.scrollTop - 1, 0));
124
98
  renderer.requestRender();
125
99
  return;
126
100
  }
127
101
  };
128
102
 
129
- renderer.keyInput.on("keypress", keyHandler);
103
+ renderer.keyInput.on("keypress", extraKeyHandler);
130
104
 
131
- function cleanup(): void {
132
- renderer.keyInput.off("keypress", keyHandler);
133
- }
134
-
135
- return { container, cleanup };
105
+ return {
106
+ container: dialog.container,
107
+ cleanup() {
108
+ dialog.cleanup();
109
+ renderer.keyInput.off("keypress", extraKeyHandler);
110
+ },
111
+ };
136
112
  }