@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.
- package/README.md +31 -22
- package/dist/adapter.d.ts +1 -1
- package/dist/adapter.js +68 -56
- package/dist/adapter.test.js +34 -21
- package/dist/adapters/cli-proxy.d.ts +11 -4
- package/dist/adapters/cli-proxy.js +176 -162
- package/dist/adapters/copilot.d.ts +50 -0
- package/dist/adapters/copilot.js +210 -0
- package/dist/adapters/echo.d.ts +2 -2
- package/dist/adapters/echo.js +2 -1
- package/dist/adapters/echo.test.js +4 -2
- package/dist/cli-utils.d.ts +21 -0
- package/dist/cli-utils.js +74 -0
- package/dist/cli-utils.test.d.ts +1 -0
- package/dist/cli-utils.test.js +179 -0
- package/dist/cli.js +3160 -961
- package/dist/compact.d.ts +39 -0
- package/dist/compact.js +269 -0
- package/dist/compact.test.d.ts +1 -0
- package/dist/compact.test.js +198 -0
- package/dist/console/ansi.d.ts +18 -0
- package/dist/console/ansi.js +20 -0
- package/dist/console/ansi.test.d.ts +1 -0
- package/dist/console/ansi.test.js +50 -0
- package/dist/console/dropdown.d.ts +23 -0
- package/dist/console/dropdown.js +63 -0
- package/dist/console/file-drop.d.ts +59 -0
- package/dist/console/file-drop.js +186 -0
- package/dist/console/file-drop.test.d.ts +1 -0
- package/dist/console/file-drop.test.js +145 -0
- package/dist/console/index.d.ts +22 -0
- package/dist/console/index.js +23 -0
- package/dist/console/interactive-readline.d.ts +65 -0
- package/dist/console/interactive-readline.js +132 -0
- package/dist/console/markdown-table.d.ts +17 -0
- package/dist/console/markdown-table.js +270 -0
- package/dist/console/markdown-table.test.d.ts +1 -0
- package/dist/console/markdown-table.test.js +130 -0
- package/dist/console/mutable-output.d.ts +21 -0
- package/dist/console/mutable-output.js +51 -0
- package/dist/console/paste-handler.d.ts +63 -0
- package/dist/console/paste-handler.js +177 -0
- package/dist/console/prompt-box.d.ts +55 -0
- package/dist/console/prompt-box.js +120 -0
- package/dist/console/prompt-input.d.ts +136 -0
- package/dist/console/prompt-input.js +618 -0
- package/dist/console/startup.d.ts +20 -0
- package/dist/console/startup.js +138 -0
- package/dist/console/startup.test.d.ts +1 -0
- package/dist/console/startup.test.js +41 -0
- package/dist/console/wordwheel.d.ts +75 -0
- package/dist/console/wordwheel.js +123 -0
- package/dist/dropdown.js +4 -21
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/onboard.d.ts +24 -0
- package/dist/onboard.js +174 -11
- package/dist/orchestrator.d.ts +8 -11
- package/dist/orchestrator.js +33 -81
- package/dist/orchestrator.test.js +59 -79
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +56 -12
- package/dist/registry.test.js +57 -13
- package/dist/theme.d.ts +56 -0
- package/dist/theme.js +54 -0
- package/dist/types.d.ts +18 -13
- package/package.json +8 -3
- package/template/CROSS-TEAM.md +2 -2
- package/template/PROTOCOL.md +72 -15
- package/template/README.md +2 -2
- package/template/TEMPLATE.md +118 -15
- package/template/example/SOUL.md +2 -1
- package/template/example/WISDOM.md +9 -0
- package/dist/adapters/codex.d.ts +0 -50
- package/dist/adapters/codex.js +0 -213
- 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
|
+
}
|