wave-code 1.0.0 → 1.0.1

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 (70) hide show
  1. package/dist/cli.js +20 -1
  2. package/dist/components/App.js +7 -0
  3. package/dist/components/BtwDisplay.js +13 -3
  4. package/dist/components/ChatInterface.js +21 -6
  5. package/dist/components/InputBox.d.ts +0 -3
  6. package/dist/components/InputBox.js +11 -9
  7. package/dist/components/LoadingIndicator.d.ts +1 -2
  8. package/dist/components/LoadingIndicator.js +2 -2
  9. package/dist/components/Markdown.js +13 -16
  10. package/dist/components/MessageList.d.ts +2 -1
  11. package/dist/components/MessageList.js +2 -2
  12. package/dist/components/StatusLine.d.ts +0 -2
  13. package/dist/components/StatusLine.js +6 -6
  14. package/dist/components/TaskList.js +2 -1
  15. package/dist/components/ToolDisplay.d.ts +1 -0
  16. package/dist/components/ToolDisplay.js +17 -9
  17. package/dist/constants/commands.js +0 -6
  18. package/dist/contexts/useChat.d.ts +3 -5
  19. package/dist/contexts/useChat.js +242 -82
  20. package/dist/daemon-cli.d.ts +10 -0
  21. package/dist/daemon-cli.js +15 -0
  22. package/dist/hooks/useInputManager.js +99 -22
  23. package/dist/index.js +10 -0
  24. package/dist/managers/inputHandlers.js +50 -22
  25. package/dist/managers/inputReducer.d.ts +12 -2
  26. package/dist/managers/inputReducer.js +57 -9
  27. package/dist/stdio/agentBridge.d.ts +23 -0
  28. package/dist/stdio/agentBridge.js +126 -16
  29. package/dist/stdio/daemonServer.d.ts +67 -0
  30. package/dist/stdio/daemonServer.js +191 -0
  31. package/dist/stdio/index.d.ts +2 -0
  32. package/dist/stdio/index.js +2 -0
  33. package/dist/stdio/jsonRpcConnection.d.ts +30 -0
  34. package/dist/stdio/jsonRpcConnection.js +127 -0
  35. package/dist/stdio/protocol.d.ts +2 -2
  36. package/dist/stdio/stdioServer.d.ts +2 -7
  37. package/dist/stdio/stdioServer.js +9 -100
  38. package/dist/utils/bracketedPaste.d.ts +39 -0
  39. package/dist/utils/bracketedPaste.js +122 -0
  40. package/dist/utils/markdownTable.d.ts +34 -0
  41. package/dist/utils/markdownTable.js +302 -0
  42. package/dist/utils/throttle.d.ts +3 -3
  43. package/package.json +4 -2
  44. package/src/cli.tsx +20 -1
  45. package/src/components/App.tsx +5 -0
  46. package/src/components/BtwDisplay.tsx +36 -12
  47. package/src/components/ChatInterface.tsx +25 -12
  48. package/src/components/InputBox.tsx +10 -18
  49. package/src/components/LoadingIndicator.tsx +1 -4
  50. package/src/components/Markdown.tsx +15 -18
  51. package/src/components/MessageList.tsx +6 -0
  52. package/src/components/StatusLine.tsx +0 -10
  53. package/src/components/TaskList.tsx +2 -1
  54. package/src/components/ToolDisplay.tsx +17 -6
  55. package/src/constants/commands.ts +0 -6
  56. package/src/contexts/useChat.tsx +310 -95
  57. package/src/daemon-cli.ts +17 -0
  58. package/src/hooks/useInputManager.ts +108 -22
  59. package/src/index.ts +12 -0
  60. package/src/managers/inputHandlers.ts +49 -22
  61. package/src/managers/inputReducer.ts +66 -11
  62. package/src/stdio/agentBridge.ts +188 -17
  63. package/src/stdio/daemonServer.ts +212 -0
  64. package/src/stdio/index.ts +2 -0
  65. package/src/stdio/jsonRpcConnection.ts +160 -0
  66. package/src/stdio/protocol.ts +5 -2
  67. package/src/stdio/stdioServer.ts +14 -120
  68. package/src/utils/bracketedPaste.ts +170 -0
  69. package/src/utils/markdownTable.ts +359 -0
  70. package/src/utils/throttle.ts +8 -8
@@ -0,0 +1,122 @@
1
+ const ESC = "\u001b";
2
+ const START_STRIPPED = "[200~";
3
+ const START_RAW = `${ESC}[200~`;
4
+ const END_STRIPPED = "[201~";
5
+ const END_RAW = `${ESC}[201~`;
6
+ const START_FORMS = [START_RAW, START_STRIPPED];
7
+ const END_FORMS = [END_RAW, END_STRIPPED];
8
+ /**
9
+ * Suffixes that may be the beginning of a marker split across chunks.
10
+ * Longest-first so the longest matching suffix is deferred (e.g. `[200`
11
+ * rather than `[20` for `...x[200`). Deferral is flush-equivalent: if the
12
+ * next chunk does not complete a marker, the deferred suffix is delivered
13
+ * as regular input unchanged.
14
+ */
15
+ const PARTIAL_SUFFIXES = [
16
+ `${ESC}[201`,
17
+ `${ESC}[200`,
18
+ `${ESC}[20`,
19
+ `${ESC}[2`,
20
+ "[201",
21
+ "[200",
22
+ "[20",
23
+ "[2",
24
+ ].sort((a, b) => b.length - a.length);
25
+ export function createBracketedPasteDetector() {
26
+ let inPaste = false;
27
+ let buffer = ""; // paste content collected since the start marker
28
+ let pending = ""; // deferred partial marker suffix from a previous chunk
29
+ const findFirst = (text, forms) => {
30
+ let best = null;
31
+ for (const form of forms) {
32
+ const index = text.indexOf(form);
33
+ if (index !== -1 && (best === null || index < best.index)) {
34
+ best = { index, length: form.length };
35
+ }
36
+ }
37
+ return best;
38
+ };
39
+ const endsWithPartial = (text) => {
40
+ for (const prefix of PARTIAL_SUFFIXES) {
41
+ if (text.endsWith(prefix)) {
42
+ return prefix;
43
+ }
44
+ }
45
+ return null;
46
+ };
47
+ const process = (chunk) => {
48
+ let remaining = pending + chunk;
49
+ pending = "";
50
+ let leadingInput = "";
51
+ let pasteText = null;
52
+ while (remaining.length > 0) {
53
+ if (!inPaste) {
54
+ const start = findFirst(remaining, START_FORMS);
55
+ if (start) {
56
+ leadingInput += remaining.slice(0, start.index);
57
+ remaining = remaining.slice(start.index + start.length);
58
+ inPaste = true;
59
+ continue;
60
+ }
61
+ // Orphan end marker (start was missed, e.g. consumed by another
62
+ // input handler): treat the preceding text as paste so a trailing
63
+ // `\r` cannot trigger the coalesced-Enter submit heuristic.
64
+ const end = findFirst(remaining, END_FORMS);
65
+ if (end) {
66
+ pasteText =
67
+ (pasteText ?? "") + leadingInput + remaining.slice(0, end.index);
68
+ leadingInput = "";
69
+ remaining = remaining.slice(end.index + end.length);
70
+ continue;
71
+ }
72
+ const partial = endsWithPartial(remaining);
73
+ if (partial) {
74
+ pending = remaining.slice(remaining.length - partial.length);
75
+ remaining = remaining.slice(0, remaining.length - partial.length);
76
+ }
77
+ if (remaining.length > 0) {
78
+ leadingInput += remaining;
79
+ remaining = "";
80
+ }
81
+ break;
82
+ }
83
+ const end = findFirst(remaining, END_FORMS);
84
+ if (end) {
85
+ pasteText = (pasteText ?? "") + buffer + remaining.slice(0, end.index);
86
+ buffer = "";
87
+ inPaste = false;
88
+ remaining = remaining.slice(end.index + end.length);
89
+ continue;
90
+ }
91
+ const partial = endsWithPartial(remaining);
92
+ if (partial) {
93
+ pending = remaining.slice(remaining.length - partial.length);
94
+ remaining = remaining.slice(0, remaining.length - partial.length);
95
+ }
96
+ buffer += remaining;
97
+ remaining = "";
98
+ break;
99
+ }
100
+ if (inPaste) {
101
+ // Paste still in flight: hold the content, deliver nothing yet.
102
+ return { kind: "consume" };
103
+ }
104
+ if (pasteText !== null) {
105
+ const result = {
106
+ kind: "paste",
107
+ text: pasteText,
108
+ };
109
+ if (leadingInput !== "") {
110
+ result.leadingInput = leadingInput;
111
+ }
112
+ return result;
113
+ }
114
+ return { kind: "input", input: leadingInput };
115
+ };
116
+ const reset = () => {
117
+ inPaste = false;
118
+ buffer = "";
119
+ pending = "";
120
+ };
121
+ return { process, reset };
122
+ }
@@ -0,0 +1,34 @@
1
+ import type { Token, Tokens } from "marked";
2
+ export type CellAlign = "left" | "center" | "right" | null;
3
+ /**
4
+ * Wrap text to fit within a given width, returning array of lines.
5
+ * ANSI-aware: preserves styling across line breaks.
6
+ *
7
+ * @param hard - If true, break words that exceed width (needed when columns
8
+ * are narrower than the longest word). Default false.
9
+ */
10
+ export declare function wrapText(text: string, width: number, options?: {
11
+ hard?: boolean;
12
+ }): string[];
13
+ /**
14
+ * Pad `content` to `targetWidth` according to alignment. `displayWidth` is the
15
+ * visible width of `content` (caller computes this, e.g. via stringWidth on
16
+ * ANSI-stripped text, so ANSI codes in `content` don't affect padding).
17
+ */
18
+ export declare function padAligned(content: string, displayWidth: number, targetWidth: number, align: CellAlign | undefined): string;
19
+ /**
20
+ * Render a markdown table to a box-drawing ANSI string for the terminal.
21
+ * Handles terminal width by:
22
+ * 1. Calculating minimum column widths based on longest word
23
+ * 2. Distributing available space proportionally
24
+ * 3. Wrapping text within cells (no truncation)
25
+ * 4. Falling back to a vertical key-value format when rows would be too
26
+ * tall or the table still overflows the terminal width
27
+ *
28
+ * @param token - marked table token
29
+ * @param columns - terminal width
30
+ * @param formatInline - formats inline tokens to an ANSI string (the caller
31
+ * binds its marked parser so this module stays
32
+ * parser-agnostic and directly testable)
33
+ */
34
+ export declare function renderMarkdownTable(token: Tokens.Table, columns: number, formatInline: (tokens: Token[]) => string): string;
@@ -0,0 +1,302 @@
1
+ import stringWidth from "string-width";
2
+ import wrapAnsi from "wrap-ansi";
3
+ import { stripAnsiColors } from "wave-agent-sdk";
4
+ /**
5
+ * Accounts for parent indentation (e.g. message prefix) and terminal
6
+ * resize races. Without enough margin the table overflows its layout box
7
+ * and Ink's clip truncates differently on alternating frames.
8
+ */
9
+ const SAFETY_MARGIN = 4;
10
+ /** Minimum column width to prevent degenerate layouts */
11
+ const MIN_COLUMN_WIDTH = 3;
12
+ /**
13
+ * Maximum number of lines per row before switching to vertical format.
14
+ * When wrapping would make rows taller than this, vertical (key-value)
15
+ * format provides better readability.
16
+ */
17
+ const MAX_ROW_LINES = 4;
18
+ /** ANSI escape codes for text formatting */
19
+ const ANSI_BOLD_START = "\x1b[1m";
20
+ const ANSI_BOLD_END = "\x1b[22m";
21
+ /**
22
+ * Wrap text to fit within a given width, returning array of lines.
23
+ * ANSI-aware: preserves styling across line breaks.
24
+ *
25
+ * @param hard - If true, break words that exceed width (needed when columns
26
+ * are narrower than the longest word). Default false.
27
+ */
28
+ export function wrapText(text, width, options) {
29
+ if (width <= 0)
30
+ return [text];
31
+ // Strip trailing whitespace/newlines before wrapping, otherwise extra
32
+ // blank lines would appear in table cells.
33
+ const trimmedText = text.trimEnd();
34
+ const wrapped = wrapAnsi(trimmedText, width, {
35
+ hard: options?.hard ?? false,
36
+ trim: false,
37
+ wordWrap: true,
38
+ });
39
+ // Filter out empty lines that result from trailing newlines or
40
+ // multiple consecutive newlines in the source content.
41
+ const lines = wrapped.split("\n").filter((line) => line.length > 0);
42
+ // Ensure we always return at least one line (empty string for empty cells)
43
+ return lines.length > 0 ? lines : [""];
44
+ }
45
+ /**
46
+ * Pad `content` to `targetWidth` according to alignment. `displayWidth` is the
47
+ * visible width of `content` (caller computes this, e.g. via stringWidth on
48
+ * ANSI-stripped text, so ANSI codes in `content` don't affect padding).
49
+ */
50
+ export function padAligned(content, displayWidth, targetWidth, align) {
51
+ const padding = Math.max(0, targetWidth - displayWidth);
52
+ if (align === "center") {
53
+ const leftPad = Math.floor(padding / 2);
54
+ return " ".repeat(leftPad) + content + " ".repeat(padding - leftPad);
55
+ }
56
+ if (align === "right") {
57
+ return " ".repeat(padding) + content;
58
+ }
59
+ return content + " ".repeat(padding);
60
+ }
61
+ /**
62
+ * Render a markdown table to a box-drawing ANSI string for the terminal.
63
+ * Handles terminal width by:
64
+ * 1. Calculating minimum column widths based on longest word
65
+ * 2. Distributing available space proportionally
66
+ * 3. Wrapping text within cells (no truncation)
67
+ * 4. Falling back to a vertical key-value format when rows would be too
68
+ * tall or the table still overflows the terminal width
69
+ *
70
+ * @param token - marked table token
71
+ * @param columns - terminal width
72
+ * @param formatInline - formats inline tokens to an ANSI string (the caller
73
+ * binds its marked parser so this module stays
74
+ * parser-agnostic and directly testable)
75
+ */
76
+ export function renderMarkdownTable(token, columns, formatInline) {
77
+ // Format cell content to ANSI string
78
+ function formatCell(tokens) {
79
+ return tokens && tokens.length > 0 ? formatInline(tokens) : "";
80
+ }
81
+ // Get plain text (stripped of ANSI codes)
82
+ function getPlainText(tokens) {
83
+ return stripAnsiColors(formatCell(tokens));
84
+ }
85
+ // Get the longest word width in a cell (minimum width to avoid breaking words)
86
+ function getMinWidth(tokens) {
87
+ const text = getPlainText(tokens);
88
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
89
+ if (words.length === 0)
90
+ return MIN_COLUMN_WIDTH;
91
+ return Math.max(...words.map((w) => stringWidth(w)), MIN_COLUMN_WIDTH);
92
+ }
93
+ // Get ideal width (full content without wrapping)
94
+ function getIdealWidth(tokens) {
95
+ return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH);
96
+ }
97
+ // Step 1: Get minimum (longest word) and ideal (full content) widths
98
+ const minWidths = token.header.map((header, colIndex) => {
99
+ let maxMinWidth = getMinWidth(header.tokens);
100
+ for (const row of token.rows) {
101
+ maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens));
102
+ }
103
+ return maxMinWidth;
104
+ });
105
+ const idealWidths = token.header.map((header, colIndex) => {
106
+ let maxIdeal = getIdealWidth(header.tokens);
107
+ for (const row of token.rows) {
108
+ maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens));
109
+ }
110
+ return maxIdeal;
111
+ });
112
+ // Step 2: Calculate available space
113
+ // Border overhead: │ content │ content │ = 1 + (width + 3) per column
114
+ const numCols = token.header.length;
115
+ const borderOverhead = 1 + numCols * 3; // │ + (2 padding + 1 border) per col
116
+ // Account for SAFETY_MARGIN to avoid triggering the fallback safety check
117
+ const availableWidth = Math.max(columns - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH);
118
+ // Step 3: Calculate column widths that fit available space
119
+ const totalMin = minWidths.reduce((sum, w) => sum + w, 0);
120
+ const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0);
121
+ // Track whether columns are narrower than longest words (needs hard wrap)
122
+ let needsHardWrap = false;
123
+ let columnWidths;
124
+ if (totalIdeal <= availableWidth) {
125
+ // Everything fits - use ideal widths
126
+ columnWidths = idealWidths;
127
+ }
128
+ else if (totalMin <= availableWidth) {
129
+ // Need to shrink - give each column its min, distribute remaining space
130
+ const extraSpace = availableWidth - totalMin;
131
+ const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]);
132
+ const totalOverflow = overflows.reduce((sum, o) => sum + o, 0);
133
+ columnWidths = minWidths.map((min, i) => {
134
+ if (totalOverflow === 0)
135
+ return min;
136
+ const extra = Math.floor((overflows[i] / totalOverflow) * extraSpace);
137
+ return min + extra;
138
+ });
139
+ }
140
+ else {
141
+ // Table wider than terminal at minimum widths
142
+ // Shrink columns proportionally to fit allowing word breaks
143
+ needsHardWrap = true;
144
+ const scaleFactor = availableWidth / totalMin;
145
+ columnWidths = minWidths.map((w) => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH));
146
+ }
147
+ // Step 4: Calculate max row lines to determine if vertical format is needed
148
+ function calculateMaxRowLines() {
149
+ let maxLines = 1;
150
+ // Check header
151
+ for (let i = 0; i < token.header.length; i++) {
152
+ const content = formatCell(token.header[i].tokens);
153
+ const wrapped = wrapText(content, columnWidths[i], {
154
+ hard: needsHardWrap,
155
+ });
156
+ maxLines = Math.max(maxLines, wrapped.length);
157
+ }
158
+ // Check rows
159
+ for (const row of token.rows) {
160
+ for (let i = 0; i < row.length; i++) {
161
+ const content = formatCell(row[i]?.tokens);
162
+ const wrapped = wrapText(content, columnWidths[i], {
163
+ hard: needsHardWrap,
164
+ });
165
+ maxLines = Math.max(maxLines, wrapped.length);
166
+ }
167
+ }
168
+ return maxLines;
169
+ }
170
+ // Use vertical format if wrapping would make rows too tall
171
+ const maxRowLines = calculateMaxRowLines();
172
+ const useVerticalFormat = maxRowLines > MAX_ROW_LINES;
173
+ // Render a single row with potential multi-line cells
174
+ // Returns an array of strings, one per line of the row
175
+ function renderRowLines(cells, isHeader) {
176
+ // Get wrapped lines for each cell (preserving ANSI formatting)
177
+ const cellLines = cells.map((cell, colIndex) => {
178
+ const formattedText = formatCell(cell.tokens);
179
+ const width = columnWidths[colIndex];
180
+ return wrapText(formattedText, width, { hard: needsHardWrap });
181
+ });
182
+ // Find max number of lines in this row
183
+ const maxLines = Math.max(...cellLines.map((lines) => lines.length), 1);
184
+ // Calculate vertical offset for each cell (to center vertically)
185
+ const verticalOffsets = cellLines.map((lines) => Math.floor((maxLines - lines.length) / 2));
186
+ // Build each line of the row as a single string
187
+ const result = [];
188
+ for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) {
189
+ let line = "│";
190
+ for (let colIndex = 0; colIndex < cells.length; colIndex++) {
191
+ const lines = cellLines[colIndex];
192
+ const offset = verticalOffsets[colIndex];
193
+ const contentLineIdx = lineIdx - offset;
194
+ const lineText = contentLineIdx >= 0 && contentLineIdx < lines.length
195
+ ? lines[contentLineIdx]
196
+ : "";
197
+ const width = columnWidths[colIndex];
198
+ // Headers always centered; data uses table alignment
199
+ const align = isHeader
200
+ ? "center"
201
+ : (token.align?.[colIndex] ?? "left");
202
+ line +=
203
+ " " +
204
+ padAligned(lineText, stringWidth(lineText), width, align) +
205
+ " │";
206
+ }
207
+ result.push(line);
208
+ }
209
+ return result;
210
+ }
211
+ // Render horizontal border as a single string
212
+ function renderBorderLine(type) {
213
+ const [left, mid, cross, right] = {
214
+ top: ["┌", "─", "┬", "┐"],
215
+ middle: ["├", "─", "┼", "┤"],
216
+ bottom: ["└", "─", "┴", "┘"],
217
+ }[type];
218
+ let line = left;
219
+ columnWidths.forEach((width, colIndex) => {
220
+ line += mid.repeat(width + 2);
221
+ line += colIndex < columnWidths.length - 1 ? cross : right;
222
+ });
223
+ return line;
224
+ }
225
+ // Render vertical format (key-value pairs) for extra-narrow terminals
226
+ function renderVerticalFormat() {
227
+ const lines = [];
228
+ const headers = token.header.map((h) => getPlainText(h.tokens));
229
+ const separatorWidth = Math.min(columns - 1, 40);
230
+ const separator = "─".repeat(separatorWidth);
231
+ // Small indent for wrapped lines (just 2 spaces)
232
+ const wrapIndent = " ";
233
+ token.rows.forEach((row, rowIndex) => {
234
+ if (rowIndex > 0) {
235
+ lines.push(separator);
236
+ }
237
+ row.forEach((cell, colIndex) => {
238
+ const label = headers[colIndex] || `Column ${colIndex + 1}`;
239
+ // Clean value: trim, remove extra internal whitespace/newlines
240
+ const rawValue = formatCell(cell.tokens).trimEnd();
241
+ const value = rawValue.replace(/\n+/g, " ").replace(/\s+/g, " ").trim();
242
+ // Wrap value to fit terminal, accounting for label on first line
243
+ const firstLineWidth = columns - stringWidth(label) - 3;
244
+ const subsequentLineWidth = columns - wrapIndent.length - 1;
245
+ // Two-pass wrap: first line is narrower (label takes space),
246
+ // continuation lines get the full width minus indent.
247
+ const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10));
248
+ const firstLine = firstPassLines[0] || "";
249
+ let wrappedValue;
250
+ if (firstPassLines.length <= 1 ||
251
+ subsequentLineWidth <= firstLineWidth) {
252
+ wrappedValue = firstPassLines;
253
+ }
254
+ else {
255
+ // Re-join remaining text and re-wrap to the wider continuation width
256
+ const remainingText = firstPassLines
257
+ .slice(1)
258
+ .map((l) => l.trim())
259
+ .join(" ");
260
+ const rewrapped = wrapText(remainingText, subsequentLineWidth);
261
+ wrappedValue = [firstLine, ...rewrapped];
262
+ }
263
+ // First line: bold label + value
264
+ lines.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ""}`);
265
+ // Subsequent lines with small indent (skip empty lines)
266
+ for (let i = 1; i < wrappedValue.length; i++) {
267
+ const line = wrappedValue[i];
268
+ if (!line.trim())
269
+ continue;
270
+ lines.push(`${wrapIndent}${line}`);
271
+ }
272
+ });
273
+ });
274
+ return lines.join("\n");
275
+ }
276
+ // Choose format based on available width
277
+ if (useVerticalFormat) {
278
+ return renderVerticalFormat();
279
+ }
280
+ // Build the complete horizontal table as an array of strings
281
+ const tableLines = [];
282
+ tableLines.push(renderBorderLine("top"));
283
+ tableLines.push(...renderRowLines(token.header, true));
284
+ tableLines.push(renderBorderLine("middle"));
285
+ token.rows.forEach((row, rowIndex) => {
286
+ tableLines.push(...renderRowLines(row, false));
287
+ if (rowIndex < token.rows.length - 1) {
288
+ tableLines.push(renderBorderLine("middle"));
289
+ }
290
+ });
291
+ tableLines.push(renderBorderLine("bottom"));
292
+ // Safety check: verify no line exceeds terminal width.
293
+ // This catches edge cases during terminal resize where calculations
294
+ // were based on a different width than the current render target.
295
+ const maxLineWidth = Math.max(...tableLines.map((line) => stringWidth(stripAnsiColors(line))));
296
+ // If we're within SAFETY_MARGIN characters of the edge, use vertical format
297
+ // to account for terminal resize race conditions.
298
+ if (maxLineWidth > columns - SAFETY_MARGIN) {
299
+ return renderVerticalFormat();
300
+ }
301
+ return tableLines.join("\n");
302
+ }
@@ -2,8 +2,8 @@ export interface ThrottleOptions {
2
2
  leading?: boolean;
3
3
  trailing?: boolean;
4
4
  }
5
- export interface ThrottledFunction<T extends (...args: unknown[]) => void> {
6
- (...args: Parameters<T>): void;
5
+ export interface ThrottledFunction<A extends unknown[]> {
6
+ (...args: A): void;
7
7
  cancel: () => void;
8
8
  flush: () => void;
9
9
  }
@@ -11,4 +11,4 @@ export interface ThrottledFunction<T extends (...args: unknown[]) => void> {
11
11
  * Creates a throttled function that only invokes `func` at most once per
12
12
  * every `wait` milliseconds.
13
13
  */
14
- export declare function throttle<T extends (...args: unknown[]) => void>(func: T, wait: number, options?: ThrottleOptions): ThrottledFunction<T>;
14
+ export declare function throttle<A extends unknown[]>(func: (...args: A) => void, wait: number, options?: ThrottleOptions): ThrottledFunction<A>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,9 +39,11 @@
39
39
  "react": "^19.2.4",
40
40
  "react-dom": "19.2.4",
41
41
  "semver": "^7.7.4",
42
+ "string-width": "^8.2.2",
43
+ "wrap-ansi": "^10.0.0",
42
44
  "yargs": "^17.7.2",
43
45
  "zod": "^3.23.8",
44
- "wave-agent-sdk": "1.0.0"
46
+ "wave-agent-sdk": "1.0.1"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@types/react": "^19.1.8",
package/src/cli.tsx CHANGED
@@ -31,6 +31,17 @@ export async function startCli(options: CliOptions): Promise<void> {
31
31
  // Continue with ink-based UI for normal mode
32
32
  let shouldRemoveWorktree = false;
33
33
 
34
+ // Enable bracketed paste (DECSET 2004) so terminals wrap pasted text in
35
+ // \x1b[200~ ... \x1b[201~ markers. The input pipeline uses these to insert
36
+ // pasted text without submitting (a pasted trailing \r is not an Enter).
37
+ // Terminals without bracketed-paste support ignore the sequence and paste
38
+ // without markers, falling back to legacy behavior. No-op when stdout is
39
+ // not a TTY (piped input, stdio mode).
40
+ const stdoutIsTTY = process.stdout.isTTY === true;
41
+ if (stdoutIsTTY) {
42
+ process.stdout.write("\x1b[?2004h");
43
+ }
44
+
34
45
  const handleExit = (shouldRemove: boolean) => {
35
46
  shouldRemoveWorktree = shouldRemove;
36
47
  unmount();
@@ -59,7 +70,15 @@ export async function startCli(options: CliOptions): Promise<void> {
59
70
  );
60
71
 
61
72
  // Wait for the app to finish unmounting
62
- await waitUntilExit();
73
+ try {
74
+ await waitUntilExit();
75
+ } finally {
76
+ // Disable bracketed paste (DECSET 2004) so the terminal returns to its
77
+ // previous paste handling.
78
+ if (stdoutIsTTY) {
79
+ process.stdout.write("\x1b[?2004l");
80
+ }
81
+ }
63
82
 
64
83
  try {
65
84
  // Clean up old log files
@@ -10,6 +10,7 @@ import {
10
10
  getDefaultRemoteBranch,
11
11
  } from "wave-agent-sdk";
12
12
  import { BaseAppProps } from "../types.js";
13
+ import { btwOverlayActiveRef } from "../managers/inputReducer.js";
13
14
 
14
15
  interface AppProps extends BaseAppProps {
15
16
  restoreSessionId?: string;
@@ -51,6 +52,8 @@ const ChatWithExitPrompt: React.FC<{
51
52
  }, [worktreeSession, onExit]);
52
53
 
53
54
  useInput((input, key) => {
55
+ // While the /btw overlay is up it owns the keys; Ctrl+C must not quit
56
+ if (btwOverlayActiveRef.current) return;
54
57
  if (input === "c" && key.ctrl) {
55
58
  handleSignal();
56
59
  }
@@ -107,6 +110,8 @@ const AppWithProviders: React.FC<AppWithProvidersProps> = ({
107
110
  // Handle Ctrl-C for non-worktree sessions (immediate exit)
108
111
  // Ink runs terminal in raw mode, so Ctrl+C arrives as useInput event, not SIGINT
109
112
  useInput((input, key) => {
113
+ // While the /btw overlay is up it owns the keys; Ctrl+C must not quit
114
+ if (btwOverlayActiveRef.current) return;
110
115
  if (!worktreeSession && input === "c" && key.ctrl) {
111
116
  onExit(false);
112
117
  return true;
@@ -8,29 +8,53 @@ interface BtwDisplayProps {
8
8
  }
9
9
 
10
10
  export const BtwDisplay: React.FC<BtwDisplayProps> = ({ btwState }) => {
11
- if (!btwState.question) {
11
+ // Rendered for a real question (loading or answered) and for the bare
12
+ // `/btw` usage message (question === "", answer set).
13
+ if (!btwState.question && !btwState.answer) {
12
14
  return null;
13
15
  }
14
16
 
17
+ const answer = btwState.answer ?? "";
18
+
19
+ // The SDK surfaces failure as the answer string; classify by prefix so it
20
+ // renders in error color (aligned with Claude Code's error display).
21
+ const isError =
22
+ !btwState.isLoading &&
23
+ (answer.startsWith("(API error") ||
24
+ answer.startsWith("(The model tried to call") ||
25
+ answer === "No response received");
26
+
15
27
  return (
16
- <Box flexDirection="column" marginTop={0} marginBottom={0}>
28
+ <Box flexDirection="column" marginTop={1}>
17
29
  {btwState.question && (
18
30
  <Box>
19
- <Text color={btwState.isLoading ? "yellow" : "green"}>/ </Text>
20
- <Text italic color="gray">
21
- btw {btwState.question}
31
+ <Text color="warning" bold>
32
+ /btw{" "}
22
33
  </Text>
34
+ <Text dimColor>{btwState.question}</Text>
23
35
  </Box>
24
36
  )}
25
-
26
- {btwState.answer && (
27
- <Box flexDirection="column">
28
- <Markdown>{btwState.answer}</Markdown>
29
- <Text color="gray" dimColor>
30
- ESC to dismiss
31
- </Text>
37
+ <Box marginTop={1} flexDirection="column">
38
+ {btwState.isLoading ? (
39
+ <Text color="gray">✻ Answering...</Text>
40
+ ) : isError ? (
41
+ <Text color="error">{answer}</Text>
42
+ ) : (
43
+ <Markdown>{answer}</Markdown>
44
+ )}
45
+ </Box>
46
+ {btwState.question && btwState.answer && (
47
+ <Box marginTop={1}>
48
+ <Text dimColor>Escape to dismiss</Text>
49
+ </Box>
50
+ )}
51
+ {!btwState.question && btwState.answer && (
52
+ <Box marginTop={1}>
53
+ <Text dimColor>Escape to dismiss</Text>
32
54
  </Box>
33
55
  )}
34
56
  </Box>
35
57
  );
36
58
  };
59
+
60
+ BtwDisplay.displayName = "BtwDisplay";