@teammates/cli 0.1.0 → 0.2.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 (76) hide show
  1. package/README.md +31 -22
  2. package/dist/adapter.d.ts +1 -1
  3. package/dist/adapter.js +68 -56
  4. package/dist/adapter.test.js +34 -21
  5. package/dist/adapters/cli-proxy.d.ts +11 -4
  6. package/dist/adapters/cli-proxy.js +176 -162
  7. package/dist/adapters/copilot.d.ts +50 -0
  8. package/dist/adapters/copilot.js +210 -0
  9. package/dist/adapters/echo.d.ts +2 -2
  10. package/dist/adapters/echo.js +2 -1
  11. package/dist/adapters/echo.test.js +4 -2
  12. package/dist/cli-utils.d.ts +21 -0
  13. package/dist/cli-utils.js +74 -0
  14. package/dist/cli-utils.test.d.ts +1 -0
  15. package/dist/cli-utils.test.js +179 -0
  16. package/dist/cli.js +3160 -961
  17. package/dist/compact.d.ts +39 -0
  18. package/dist/compact.js +269 -0
  19. package/dist/compact.test.d.ts +1 -0
  20. package/dist/compact.test.js +198 -0
  21. package/dist/console/ansi.d.ts +18 -0
  22. package/dist/console/ansi.js +20 -0
  23. package/dist/console/ansi.test.d.ts +1 -0
  24. package/dist/console/ansi.test.js +50 -0
  25. package/dist/console/dropdown.d.ts +23 -0
  26. package/dist/console/dropdown.js +63 -0
  27. package/dist/console/file-drop.d.ts +59 -0
  28. package/dist/console/file-drop.js +186 -0
  29. package/dist/console/file-drop.test.d.ts +1 -0
  30. package/dist/console/file-drop.test.js +145 -0
  31. package/dist/console/index.d.ts +22 -0
  32. package/dist/console/index.js +23 -0
  33. package/dist/console/interactive-readline.d.ts +65 -0
  34. package/dist/console/interactive-readline.js +132 -0
  35. package/dist/console/markdown-table.d.ts +17 -0
  36. package/dist/console/markdown-table.js +270 -0
  37. package/dist/console/markdown-table.test.d.ts +1 -0
  38. package/dist/console/markdown-table.test.js +130 -0
  39. package/dist/console/mutable-output.d.ts +21 -0
  40. package/dist/console/mutable-output.js +51 -0
  41. package/dist/console/paste-handler.d.ts +63 -0
  42. package/dist/console/paste-handler.js +177 -0
  43. package/dist/console/prompt-box.d.ts +55 -0
  44. package/dist/console/prompt-box.js +120 -0
  45. package/dist/console/prompt-input.d.ts +136 -0
  46. package/dist/console/prompt-input.js +618 -0
  47. package/dist/console/startup.d.ts +20 -0
  48. package/dist/console/startup.js +138 -0
  49. package/dist/console/startup.test.d.ts +1 -0
  50. package/dist/console/startup.test.js +41 -0
  51. package/dist/console/wordwheel.d.ts +75 -0
  52. package/dist/console/wordwheel.js +123 -0
  53. package/dist/dropdown.js +4 -21
  54. package/dist/index.d.ts +5 -5
  55. package/dist/index.js +3 -3
  56. package/dist/onboard.d.ts +24 -0
  57. package/dist/onboard.js +174 -11
  58. package/dist/orchestrator.d.ts +8 -11
  59. package/dist/orchestrator.js +33 -81
  60. package/dist/orchestrator.test.js +59 -79
  61. package/dist/registry.d.ts +1 -1
  62. package/dist/registry.js +56 -12
  63. package/dist/registry.test.js +57 -13
  64. package/dist/theme.d.ts +56 -0
  65. package/dist/theme.js +54 -0
  66. package/dist/types.d.ts +18 -13
  67. package/package.json +8 -3
  68. package/template/CROSS-TEAM.md +2 -2
  69. package/template/PROTOCOL.md +72 -15
  70. package/template/README.md +2 -2
  71. package/template/TEMPLATE.md +118 -15
  72. package/template/example/SOUL.md +2 -1
  73. package/template/example/WISDOM.md +9 -0
  74. package/dist/adapters/codex.d.ts +0 -50
  75. package/dist/adapters/codex.js +0 -213
  76. package/template/example/MEMORIES.md +0 -26
@@ -0,0 +1,132 @@
1
+ /**
2
+ * InteractiveReadline — a batteries-included readline wrapper for CLI REPLs.
3
+ *
4
+ * Composes MutableOutput, PasteHandler, Dropdown, and Wordwheel into a
5
+ * single cohesive readline experience with:
6
+ *
7
+ * - Paste detection (multi-line collapse, long single-line truncation)
8
+ * - Autocomplete dropdown with keyboard navigation
9
+ * - Mutable output for suppressing echo
10
+ * - Cross-platform (Windows + macOS)
11
+ *
12
+ * Usage:
13
+ * const irl = new InteractiveReadline({
14
+ * prompt: "my-app> ",
15
+ * getItems: (line, cursor) => [...],
16
+ * onLine: async (input) => { ... },
17
+ * });
18
+ * await irl.start();
19
+ */
20
+ import { createInterface, } from "node:readline";
21
+ import { esc } from "@teammates/consolonia";
22
+ import { Dropdown } from "./dropdown.js";
23
+ import { MutableOutput } from "./mutable-output.js";
24
+ import { PasteHandler } from "./paste-handler.js";
25
+ import { Wordwheel } from "./wordwheel.js";
26
+ export class InteractiveReadline {
27
+ rl;
28
+ output;
29
+ dropdown;
30
+ wordwheel;
31
+ pasteHandler;
32
+ dispatching = false;
33
+ prompt;
34
+ onLine;
35
+ constructor(options) {
36
+ this.prompt = options.prompt;
37
+ this.onLine = options.onLine;
38
+ // 1. Create mutable output
39
+ this.output = new MutableOutput();
40
+ // 2. Create readline
41
+ this.rl = createInterface({
42
+ input: process.stdin,
43
+ output: this.output,
44
+ prompt: options.prompt,
45
+ terminal: true,
46
+ });
47
+ // 3. Create dropdown (hooks _refreshLine)
48
+ this.dropdown = new Dropdown(this.rl);
49
+ // 4. Create wordwheel
50
+ this.wordwheel = new Wordwheel({
51
+ rl: this.rl,
52
+ dropdown: this.dropdown,
53
+ getItems: options.getItems ?? (() => []),
54
+ formatHighlighted: options.formatHighlighted,
55
+ formatNormal: options.formatNormal,
56
+ });
57
+ // 5. Create paste handler
58
+ this.pasteHandler = new PasteHandler({
59
+ rl: this.rl,
60
+ output: this.output,
61
+ debounceMs: options.pasteDebounceMs,
62
+ longPasteThreshold: options.longPasteThreshold,
63
+ formatPrompt: () => this.prompt,
64
+ onLine: async (result) => {
65
+ if (!result.input || this.dispatching) {
66
+ this.rl.prompt();
67
+ return;
68
+ }
69
+ this.dispatching = true;
70
+ try {
71
+ await this.onLine(result.input, result.attachments);
72
+ }
73
+ catch (err) {
74
+ console.log(`Error: ${err.message}`);
75
+ }
76
+ finally {
77
+ this.dispatching = false;
78
+ }
79
+ this.rl.prompt();
80
+ },
81
+ });
82
+ // 6. Install keyboard interceptor
83
+ this.installKeyHandler();
84
+ // 7. Handle close
85
+ this.rl.on("close", () => {
86
+ options.onClose?.();
87
+ });
88
+ }
89
+ /** Start the REPL — shows the prompt. */
90
+ start() {
91
+ this.rl.prompt();
92
+ }
93
+ /** Clear the terminal and re-show the prompt. */
94
+ clearScreen() {
95
+ process.stdout.write(esc.clearScreen + esc.moveTo(0, 0));
96
+ }
97
+ /** Reset all state (paste buffers, wordwheel, etc.). */
98
+ reset() {
99
+ this.pasteHandler.reset();
100
+ this.wordwheel.clear();
101
+ }
102
+ /** Access the current line text. */
103
+ get line() {
104
+ return this.rl.line ?? "";
105
+ }
106
+ /** Set the current line text and cursor. */
107
+ setLine(text) {
108
+ this.rl.line = text;
109
+ this.rl.cursor = text.length;
110
+ this.rl._refreshLine();
111
+ }
112
+ installKeyHandler() {
113
+ const origTtyWrite = this.rl._ttyWrite.bind(this.rl);
114
+ this.rl._ttyWrite = (s, key) => {
115
+ // Track keystroke timing for paste detection
116
+ this.pasteHandler.onKeystroke();
117
+ // Let wordwheel handle navigation keys
118
+ if (this.wordwheel.handleKey(key))
119
+ return;
120
+ // Enter: accept wordwheel selection first, then process normally
121
+ if (key && key.name === "return") {
122
+ this.wordwheel.handleEnter();
123
+ origTtyWrite(s, key);
124
+ return;
125
+ }
126
+ // All other keys: pass to readline, then update wordwheel
127
+ this.wordwheel.clear();
128
+ origTtyWrite(s, key);
129
+ this.wordwheel.update();
130
+ };
131
+ }
132
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Markdown table → box-drawing renderer.
3
+ *
4
+ * Parses markdown pipe tables from text and replaces them with
5
+ * Unicode box-drawing equivalents:
6
+ *
7
+ * ┌──────┬───────┐
8
+ * │ Name │ Role │
9
+ * ├──────┼───────┤
10
+ * │ alice│ dev │
11
+ * └──────┴───────┘
12
+ */
13
+ /**
14
+ * Find and replace all markdown tables in a block of text with
15
+ * box-drawing rendered versions.
16
+ */
17
+ export declare function renderMarkdownTables(text: string, maxWidth?: number): string;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Markdown table → box-drawing renderer.
3
+ *
4
+ * Parses markdown pipe tables from text and replaces them with
5
+ * Unicode box-drawing equivalents:
6
+ *
7
+ * ┌──────┬───────┐
8
+ * │ Name │ Role │
9
+ * ├──────┼───────┤
10
+ * │ alice│ dev │
11
+ * └──────┴───────┘
12
+ */
13
+ import chalk from "chalk";
14
+ // ── Box-drawing characters ──────────────────────────────────────
15
+ const BOX = {
16
+ topLeft: "┌",
17
+ topRight: "┐",
18
+ bottomLeft: "└",
19
+ bottomRight: "┘",
20
+ horizontal: "─",
21
+ vertical: "│",
22
+ teeDown: "┬",
23
+ teeUp: "┴",
24
+ teeRight: "├",
25
+ teeLeft: "┤",
26
+ cross: "┼",
27
+ };
28
+ function parseAlignment(sep) {
29
+ const trimmed = sep.trim();
30
+ const left = trimmed.startsWith(":");
31
+ const right = trimmed.endsWith(":");
32
+ if (left && right)
33
+ return "center";
34
+ if (right)
35
+ return "right";
36
+ return "left";
37
+ }
38
+ function padCell(text, width, align) {
39
+ const len = text.length;
40
+ const diff = width - len;
41
+ if (diff <= 0)
42
+ return text;
43
+ switch (align) {
44
+ case "right":
45
+ return " ".repeat(diff) + text;
46
+ case "center": {
47
+ const left = Math.floor(diff / 2);
48
+ return " ".repeat(left) + text + " ".repeat(diff - left);
49
+ }
50
+ default:
51
+ return text + " ".repeat(diff);
52
+ }
53
+ }
54
+ // ── Parsing ─────────────────────────────────────────────────────
55
+ /** Parse a pipe-delimited row into trimmed cell strings. */
56
+ function parseRow(line) {
57
+ // Strip leading/trailing pipe and split
58
+ let s = line.trim();
59
+ if (s.startsWith("|"))
60
+ s = s.slice(1);
61
+ if (s.endsWith("|"))
62
+ s = s.slice(0, -1);
63
+ return s.split("|").map((c) => c.trim());
64
+ }
65
+ /** Check if a line is a separator row (e.g. |---|---:|:---:|). */
66
+ function isSeparatorRow(line) {
67
+ return /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line.trim());
68
+ }
69
+ /** Check if a line looks like a table row (has pipes). */
70
+ function isTableRow(line) {
71
+ const trimmed = line.trim();
72
+ return trimmed.includes("|") && !trimmed.startsWith("```");
73
+ }
74
+ function parseTable(lines) {
75
+ if (lines.length < 2)
76
+ return null;
77
+ const headerLine = lines[0];
78
+ const sepLine = lines[1];
79
+ if (!isTableRow(headerLine) || !isSeparatorRow(sepLine))
80
+ return null;
81
+ const headers = parseRow(headerLine);
82
+ const seps = parseRow(sepLine);
83
+ const alignments = seps.map(parseAlignment);
84
+ // Pad alignments to match header count
85
+ while (alignments.length < headers.length)
86
+ alignments.push("left");
87
+ const rows = [];
88
+ for (let i = 2; i < lines.length; i++) {
89
+ if (!isTableRow(lines[i]))
90
+ break;
91
+ if (isSeparatorRow(lines[i]))
92
+ continue;
93
+ const cells = parseRow(lines[i]);
94
+ // Pad to header count
95
+ while (cells.length < headers.length)
96
+ cells.push("");
97
+ rows.push(cells.slice(0, headers.length));
98
+ }
99
+ return { headers, alignments, rows };
100
+ }
101
+ /** Wrap text to fit within a given width, breaking at word boundaries. */
102
+ function wrapText(text, width) {
103
+ if (width <= 0)
104
+ return [text];
105
+ if (text.length <= width)
106
+ return [text];
107
+ const words = text.split(/\s+/);
108
+ const lines = [];
109
+ let current = "";
110
+ for (const word of words) {
111
+ if (current.length === 0) {
112
+ // Force-break words longer than width
113
+ if (word.length > width) {
114
+ for (let i = 0; i < word.length; i += width) {
115
+ lines.push(word.slice(i, i + width));
116
+ }
117
+ current = "";
118
+ // Last chunk becomes current line if it didn't fill the width
119
+ if (lines.length > 0 && lines[lines.length - 1].length < width) {
120
+ current = lines.pop();
121
+ }
122
+ }
123
+ else {
124
+ current = word;
125
+ }
126
+ }
127
+ else if (current.length + 1 + word.length <= width) {
128
+ current += ` ${word}`;
129
+ }
130
+ else {
131
+ lines.push(current);
132
+ // Force-break words longer than width
133
+ if (word.length > width) {
134
+ for (let i = 0; i < word.length; i += width) {
135
+ lines.push(word.slice(i, i + width));
136
+ }
137
+ current = "";
138
+ if (lines.length > 0 && lines[lines.length - 1].length < width) {
139
+ current = lines.pop();
140
+ }
141
+ }
142
+ else {
143
+ current = word;
144
+ }
145
+ }
146
+ }
147
+ if (current.length > 0)
148
+ lines.push(current);
149
+ return lines.length > 0 ? lines : [""];
150
+ }
151
+ function renderTable(table, maxWidth) {
152
+ const { headers, alignments, rows } = table;
153
+ const colCount = headers.length;
154
+ const termWidth = maxWidth ?? (process.stdout.columns || 80);
155
+ // Calculate natural column widths (max of header + all rows, + 2 for padding)
156
+ const naturalWidths = [];
157
+ for (let c = 0; c < colCount; c++) {
158
+ let max = headers[c].length;
159
+ for (const row of rows) {
160
+ if (row[c] && row[c].length > max)
161
+ max = row[c].length;
162
+ }
163
+ naturalWidths.push(max + 2); // 1 space padding each side
164
+ }
165
+ // Total width = sum of column widths + (colCount + 1) border characters
166
+ const borderChars = colCount + 1;
167
+ const totalNatural = naturalWidths.reduce((a, b) => a + b, 0) + borderChars;
168
+ let widths;
169
+ if (totalNatural <= termWidth) {
170
+ widths = naturalWidths;
171
+ }
172
+ else {
173
+ // Shrink columns proportionally to fit terminal width
174
+ const available = termWidth - borderChars;
175
+ const minColWidth = 4; // minimum: 2 padding + 2 chars
176
+ // First pass: give each column at least minColWidth, then distribute remaining proportionally
177
+ const totalContent = naturalWidths.reduce((a, b) => a + b, 0);
178
+ widths = naturalWidths.map((w) => {
179
+ const share = Math.floor((w / totalContent) * available);
180
+ return Math.max(share, minColWidth);
181
+ });
182
+ // Adjust rounding: distribute any leftover space to wider columns
183
+ let used = widths.reduce((a, b) => a + b, 0);
184
+ let idx = 0;
185
+ while (used < available && idx < colCount) {
186
+ widths[idx]++;
187
+ used++;
188
+ idx++;
189
+ }
190
+ // If we overshot, trim from the widest columns
191
+ while (used > available) {
192
+ let maxIdx = 0;
193
+ for (let c = 1; c < colCount; c++) {
194
+ if (widths[c] > widths[maxIdx])
195
+ maxIdx = c;
196
+ }
197
+ if (widths[maxIdx] <= minColWidth)
198
+ break;
199
+ widths[maxIdx]--;
200
+ used--;
201
+ }
202
+ }
203
+ const hLine = (left, mid, right) => left + widths.map((w) => BOX.horizontal.repeat(w)).join(mid) + right;
204
+ /** Render a row that may have multi-line wrapped cells. */
205
+ const renderRow = (cells, bold) => {
206
+ // Wrap each cell
207
+ const wrapped = cells.map((cell, i) => wrapText(cell, widths[i] - 2));
208
+ const maxLines = Math.max(...wrapped.map((w) => w.length));
209
+ // Pad each cell's wrapped lines to have the same count
210
+ const lines = [];
211
+ for (let line = 0; line < maxLines; line++) {
212
+ const parts = cells.map((_, i) => {
213
+ const text = wrapped[i][line] || "";
214
+ const padded = padCell(text, widths[i] - 2, alignments[i]);
215
+ return bold ? ` ${chalk.bold(padded)} ` : ` ${padded} `;
216
+ });
217
+ lines.push(chalk.gray(BOX.vertical) +
218
+ parts.join(chalk.gray(BOX.vertical)) +
219
+ chalk.gray(BOX.vertical));
220
+ }
221
+ return lines;
222
+ };
223
+ const out = [];
224
+ // Top border
225
+ out.push(chalk.gray(hLine(BOX.topLeft, BOX.teeDown, BOX.topRight)));
226
+ // Header row (with wrapping)
227
+ out.push(...renderRow(headers, true));
228
+ // Header separator
229
+ out.push(chalk.gray(hLine(BOX.teeRight, BOX.cross, BOX.teeLeft)));
230
+ // Data rows (with wrapping)
231
+ for (const row of rows) {
232
+ out.push(...renderRow(row, false));
233
+ }
234
+ // Bottom border
235
+ out.push(chalk.gray(hLine(BOX.bottomLeft, BOX.teeUp, BOX.bottomRight)));
236
+ return out.join("\n");
237
+ }
238
+ // ── Public API ──────────────────────────────────────────────────
239
+ /**
240
+ * Find and replace all markdown tables in a block of text with
241
+ * box-drawing rendered versions.
242
+ */
243
+ export function renderMarkdownTables(text, maxWidth) {
244
+ const lines = text.split("\n");
245
+ const result = [];
246
+ let i = 0;
247
+ while (i < lines.length) {
248
+ // Look for a table start: a pipe row followed by a separator row
249
+ if (i + 1 < lines.length &&
250
+ isTableRow(lines[i]) &&
251
+ isSeparatorRow(lines[i + 1])) {
252
+ // Collect all contiguous table lines
253
+ const tableLines = [lines[i], lines[i + 1]];
254
+ let j = i + 2;
255
+ while (j < lines.length && isTableRow(lines[j])) {
256
+ tableLines.push(lines[j]);
257
+ j++;
258
+ }
259
+ const table = parseTable(tableLines);
260
+ if (table) {
261
+ result.push(renderTable(table, maxWidth));
262
+ i = j;
263
+ continue;
264
+ }
265
+ }
266
+ result.push(lines[i]);
267
+ i++;
268
+ }
269
+ return result.join("\n");
270
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,130 @@
1
+ import chalk from "chalk";
2
+ import { describe, expect, it } from "vitest";
3
+ import { renderMarkdownTables } from "./markdown-table.js";
4
+ // Force chalk color output off so we can compare raw box-drawing chars
5
+ chalk.level = 0;
6
+ describe("renderMarkdownTables", () => {
7
+ it("renders a simple 2-column table", () => {
8
+ const input = [
9
+ "| Name | Role |",
10
+ "| ----- | ---- |",
11
+ "| Alice | Dev |",
12
+ "| Bob | PM |",
13
+ ].join("\n");
14
+ const result = renderMarkdownTables(input);
15
+ // Should contain box-drawing characters
16
+ expect(result).toContain("┌");
17
+ expect(result).toContain("┐");
18
+ expect(result).toContain("└");
19
+ expect(result).toContain("┘");
20
+ expect(result).toContain("│");
21
+ expect(result).toContain("─");
22
+ expect(result).toContain("├");
23
+ expect(result).toContain("┤");
24
+ expect(result).toContain("┼");
25
+ // Should contain the cell values
26
+ expect(result).toContain("Name");
27
+ expect(result).toContain("Role");
28
+ expect(result).toContain("Alice");
29
+ expect(result).toContain("Bob");
30
+ expect(result).toContain("Dev");
31
+ expect(result).toContain("PM");
32
+ });
33
+ it("renders a table with alignment markers", () => {
34
+ const input = [
35
+ "| Left | Center | Right |",
36
+ "|:-----|:------:|------:|",
37
+ "| a | b | c |",
38
+ ].join("\n");
39
+ const result = renderMarkdownTables(input);
40
+ // Should still produce a box-drawn table
41
+ expect(result).toContain("┌");
42
+ expect(result).toContain("Left");
43
+ expect(result).toContain("Center");
44
+ expect(result).toContain("Right");
45
+ expect(result).toContain("a");
46
+ expect(result).toContain("b");
47
+ expect(result).toContain("c");
48
+ });
49
+ it("renders a table with no alignment markers (default left)", () => {
50
+ const input = [
51
+ "| Col1 | Col2 |",
52
+ "| ---- | ---- |",
53
+ "| x | y |",
54
+ ].join("\n");
55
+ const result = renderMarkdownTables(input);
56
+ expect(result).toContain("┌");
57
+ expect(result).toContain("Col1");
58
+ expect(result).toContain("x");
59
+ });
60
+ it("passes through text with no tables unchanged", () => {
61
+ const input = "Hello world\nThis is plain text\nNo tables here";
62
+ const result = renderMarkdownTables(input);
63
+ expect(result).toBe(input);
64
+ });
65
+ it("handles mixed text and tables", () => {
66
+ const input = [
67
+ "Some intro text",
68
+ "",
69
+ "| A | B |",
70
+ "|---|---|",
71
+ "| 1 | 2 |",
72
+ "",
73
+ "Some outro text",
74
+ ].join("\n");
75
+ const result = renderMarkdownTables(input);
76
+ // Non-table text should be preserved
77
+ expect(result).toContain("Some intro text");
78
+ expect(result).toContain("Some outro text");
79
+ // Table should be rendered with box drawing
80
+ expect(result).toContain("┌");
81
+ expect(result).toContain("A");
82
+ expect(result).toContain("1");
83
+ });
84
+ it("handles empty table cells", () => {
85
+ const input = ["| H1 | H2 |", "|----|-----|", "| | val |"].join("\n");
86
+ const result = renderMarkdownTables(input);
87
+ expect(result).toContain("┌");
88
+ expect(result).toContain("H1");
89
+ expect(result).toContain("val");
90
+ });
91
+ it("wraps text in columns when table exceeds maxWidth", () => {
92
+ const input = [
93
+ "| File | What changed |",
94
+ "|------|-------------|",
95
+ "| ci.yml | Added concurrency controls and security audit step and coverage reporting |",
96
+ "| release.yml | Added validation job with lint typecheck build test |",
97
+ ].join("\n");
98
+ // Force narrow width
99
+ const result = renderMarkdownTables(input, 50);
100
+ // Should contain data (some cells may be split across lines)
101
+ expect(result).toContain("What changed");
102
+ expect(result).toContain("concurrency");
103
+ // The text should be wrapped, so the table should have more visual lines than rows
104
+ const lines = result.split("\n");
105
+ // 2 data rows + 1 header + 3 borders = 6 minimum; wrapping adds more
106
+ expect(lines.length).toBeGreaterThan(6);
107
+ // No line should exceed maxWidth
108
+ for (const line of lines) {
109
+ expect(line.length).toBeLessThanOrEqual(50);
110
+ }
111
+ });
112
+ it("does not wrap when table fits within maxWidth", () => {
113
+ const input = ["| A | B |", "|---|---|", "| 1 | 2 |"].join("\n");
114
+ const result = renderMarkdownTables(input, 120);
115
+ const lines = result.split("\n");
116
+ // Should be exactly 5 lines: top border, header, separator, 1 data row, bottom border
117
+ expect(lines.length).toBe(5);
118
+ });
119
+ it("handles a single column table", () => {
120
+ const input = ["| Only |", "| ---- |", "| data |"].join("\n");
121
+ const result = renderMarkdownTables(input);
122
+ expect(result).toContain("┌");
123
+ expect(result).toContain("Only");
124
+ expect(result).toContain("data");
125
+ // Single column should not have cross or tee-down connectors
126
+ expect(result).not.toContain("┬");
127
+ expect(result).not.toContain("┴");
128
+ expect(result).not.toContain("┼");
129
+ });
130
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * MutableOutput — a Writable stream wrapper around stdout that can be muted.
3
+ *
4
+ * Readline requires an output stream for echoing characters. By wrapping
5
+ * stdout in a mutable stream, we can suppress echo during paste detection
6
+ * or any other time we need to control what appears on screen.
7
+ *
8
+ * Also proxies TTY methods (cursorTo, clearLine, etc.) so readline works
9
+ * correctly on both Windows and macOS.
10
+ */
11
+ import { Writable } from "node:stream";
12
+ export declare class MutableOutput extends Writable {
13
+ private _muted;
14
+ constructor();
15
+ get muted(): boolean;
16
+ /** Mute all output — nothing written to stdout. */
17
+ mute(): void;
18
+ /** Unmute — resume writing to stdout. */
19
+ unmute(): void;
20
+ _write(chunk: Buffer | string, _encoding: string, callback: () => void): void;
21
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * MutableOutput — a Writable stream wrapper around stdout that can be muted.
3
+ *
4
+ * Readline requires an output stream for echoing characters. By wrapping
5
+ * stdout in a mutable stream, we can suppress echo during paste detection
6
+ * or any other time we need to control what appears on screen.
7
+ *
8
+ * Also proxies TTY methods (cursorTo, clearLine, etc.) so readline works
9
+ * correctly on both Windows and macOS.
10
+ */
11
+ import { Writable } from "node:stream";
12
+ export class MutableOutput extends Writable {
13
+ _muted = false;
14
+ constructor() {
15
+ super();
16
+ // Proxy TTY properties from real stdout
17
+ const self = this;
18
+ const out = process.stdout;
19
+ self.columns = out.columns;
20
+ self.rows = out.rows;
21
+ self.isTTY = out.isTTY;
22
+ // Proxy methods readline needs
23
+ self.cursorTo = out.cursorTo?.bind(out);
24
+ self.clearLine = out.clearLine?.bind(out);
25
+ self.moveCursor = out.moveCursor?.bind(out);
26
+ self.getWindowSize = () => [out.columns || 80, out.rows || 24];
27
+ // Forward resize events
28
+ process.stdout.on("resize", () => {
29
+ self.columns = out.columns;
30
+ self.rows = out.rows;
31
+ this.emit("resize");
32
+ });
33
+ }
34
+ get muted() {
35
+ return this._muted;
36
+ }
37
+ /** Mute all output — nothing written to stdout. */
38
+ mute() {
39
+ this._muted = true;
40
+ }
41
+ /** Unmute — resume writing to stdout. */
42
+ unmute() {
43
+ this._muted = false;
44
+ }
45
+ _write(chunk, _encoding, callback) {
46
+ if (!this._muted) {
47
+ process.stdout.write(chunk);
48
+ }
49
+ callback();
50
+ }
51
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * PasteHandler — detects and manages pasted text in a readline REPL.
3
+ *
4
+ * Handles:
5
+ * - Multi-line pastes: collapses into a numbered placeholder, expands on Enter
6
+ * - Long single-line pastes: dispatches directly with truncated preview
7
+ * - File drag & drop: detects pasted file paths, converts to [Image #N] / [File #N] tags
8
+ *
9
+ * Works on both Windows and macOS terminals.
10
+ */
11
+ import type { Interface as ReadlineInterface } from "node:readline";
12
+ import { type FileAttachment, FileDropHandler } from "./file-drop.js";
13
+ import type { MutableOutput } from "./mutable-output.js";
14
+ export interface PasteResult {
15
+ /** The final input text (with placeholders expanded). */
16
+ input: string;
17
+ /** Whether the input contained expanded paste placeholders. */
18
+ hadPaste: boolean;
19
+ /** File attachments referenced in the input. */
20
+ attachments: FileAttachment[];
21
+ }
22
+ export interface PasteHandlerOptions {
23
+ /** Readline interface */
24
+ rl: ReadlineInterface;
25
+ /** Mutable output stream for suppressing echo */
26
+ output: MutableOutput;
27
+ /** Debounce timeout in ms (default: 30) */
28
+ debounceMs?: number;
29
+ /** Minimum chunk size to consider a single-line paste (default: 100) */
30
+ longPasteThreshold?: number;
31
+ /** Callback when a line (or expanded paste) is ready to dispatch. */
32
+ onLine: (result: PasteResult) => void;
33
+ /** Optional: format the prompt string for re-rendering. */
34
+ formatPrompt?: () => string;
35
+ /** Optional: format a file tag for display (receives attachment, returns styled string). */
36
+ formatFileTag?: (attachment: FileAttachment) => string;
37
+ /** Optional: format the "file attached" hint shown after a drop. */
38
+ formatFileHint?: (attachment: FileAttachment) => string;
39
+ }
40
+ export declare class PasteHandler {
41
+ private buffer;
42
+ private timer;
43
+ private count;
44
+ private storedTexts;
45
+ private prePastePrefix;
46
+ private lastKeystrokeTime;
47
+ readonly fileDrop: FileDropHandler;
48
+ private rl;
49
+ private output;
50
+ private debounceMs;
51
+ private longPasteThreshold;
52
+ private onLine;
53
+ private formatPrompt;
54
+ private formatFileTag;
55
+ private formatFileHint;
56
+ constructor(options: PasteHandlerOptions);
57
+ /** Call from _ttyWrite override to track keystroke timing. */
58
+ onKeystroke(): void;
59
+ /** Clear all stored paste data (e.g. on session reset). */
60
+ reset(): void;
61
+ private installHooks;
62
+ private processPaste;
63
+ }