@rind-ai/cli 0.4.1 → 0.6.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 (49) hide show
  1. package/bin/rind.js +5 -5
  2. package/lib/assistant-renderer.js +179 -265
  3. package/lib/choice-menu-state.js +46 -46
  4. package/lib/cli-input-actions.js +548 -0
  5. package/lib/cli-output-controller.js +460 -0
  6. package/lib/cli-runtime-controller.js +350 -0
  7. package/lib/cli-state-store.js +32 -0
  8. package/lib/cli-state.js +41 -0
  9. package/lib/command-controller.js +159 -126
  10. package/lib/compact-context-state.js +22 -22
  11. package/lib/components/assistant-message.js +169 -0
  12. package/lib/components/composer-area.js +25 -0
  13. package/lib/components/dynamic-block.js +20 -0
  14. package/lib/components/monitor-stack.js +35 -0
  15. package/lib/components/text-block.js +47 -0
  16. package/lib/components/tool-block.js +122 -0
  17. package/lib/composer-terminal.js +224 -203
  18. package/lib/event-controller.js +243 -242
  19. package/lib/frontend-cli-implementation.js +656 -1111
  20. package/lib/input-controller.js +75 -94
  21. package/lib/input-errors.js +3 -3
  22. package/lib/interrupt-state.js +9 -9
  23. package/lib/line-editor.js +541 -541
  24. package/lib/local-slash-commands.js +217 -0
  25. package/lib/markdown-lines.js +103 -0
  26. package/lib/model-menu-state.js +50 -50
  27. package/lib/one-shot-progress.js +145 -0
  28. package/lib/one-shot.js +228 -0
  29. package/lib/question-menu-state.js +61 -0
  30. package/lib/rendering.js +1295 -1037
  31. package/lib/runtime-client.js +241 -193
  32. package/lib/runtime-env.js +21 -21
  33. package/lib/runtime-protocol.js +122 -15
  34. package/lib/slash-command-mode.js +16 -27
  35. package/lib/slash-menu-state.js +59 -59
  36. package/lib/{background-controller.js → task-monitor-controller.js} +411 -289
  37. package/lib/terminal-key.js +97 -97
  38. package/lib/text-width.js +335 -151
  39. package/lib/theme-menu-state.js +31 -0
  40. package/lib/theme.js +134 -0
  41. package/lib/tool-display.js +680 -0
  42. package/lib/tui/component.js +55 -0
  43. package/lib/tui/cursor.js +29 -0
  44. package/lib/tui/input-buffer.js +172 -0
  45. package/lib/tui/tui.js +591 -0
  46. package/lib/turn-controller.js +68 -78
  47. package/package.json +28 -28
  48. package/lib/assistant-stream-buffer.js +0 -25
  49. package/lib/terminal-ui.js +0 -581
@@ -0,0 +1,122 @@
1
+ import {
2
+ parseToolArguments,
3
+ renderToolFinished,
4
+ renderToolRunning,
5
+ } from "../tool-display.js";
6
+
7
+ const TICKER_TOOLS = new Set(["bash", "bash_output", "delegate", "search_web", "fetch_web_page"]);
8
+
9
+ export class ToolBlock {
10
+ constructor({ event, onRequestRender, leading = false }) {
11
+ this.name = event?.tool_name || "tool";
12
+ this.args = parseToolArguments(event);
13
+ this.phase = "running";
14
+ this.startedAt = Date.now();
15
+ this.progressMessage = "";
16
+ this.fileChange = null;
17
+ this.resultEvent = null;
18
+ this.expanded = false;
19
+ this.leading = Boolean(leading);
20
+ this.timer = null;
21
+ this.onRequestRender = onRequestRender;
22
+ if (TICKER_TOOLS.has(this.name)) {
23
+ this.timer = setInterval(() => {
24
+ this.onRequestRender?.();
25
+ }, 1000);
26
+ this.timer.unref?.();
27
+ }
28
+ }
29
+
30
+ setProgress(message) {
31
+ if (this.phase !== "running") {
32
+ return;
33
+ }
34
+ const next = String(message ?? "");
35
+ if (next === this.progressMessage) {
36
+ return;
37
+ }
38
+ this.progressMessage = next;
39
+ this.onRequestRender?.();
40
+ }
41
+
42
+ // The runtime streams tool_input_started (id+name only) before
43
+ // tool_requested (full parsed arguments); merge late-arriving args into
44
+ // an already-created block so titles gain their command/path/etc.
45
+ enrichArgs(event) {
46
+ const incoming = parseToolArguments(event);
47
+ let changed = false;
48
+ for (const [key, value] of Object.entries(incoming)) {
49
+ const current = this.args?.[key];
50
+ if ((current === undefined || current === null || current === "") && value !== undefined && value !== null && value !== "") {
51
+ if (!this.args || typeof this.args !== "object") {
52
+ this.args = {};
53
+ }
54
+ this.args[key] = value;
55
+ changed = true;
56
+ }
57
+ }
58
+ if (changed) {
59
+ this.onRequestRender?.();
60
+ }
61
+ }
62
+
63
+ finish(event, fileChange) {
64
+ this.phase = "done";
65
+ this.resultEvent = event || this.resultEvent || { status: "completed", result: "" };
66
+ this.fileChange = fileChange || null;
67
+ this.clearTimer();
68
+ this.onRequestRender?.();
69
+ }
70
+
71
+ setExpanded(expanded) {
72
+ const next = Boolean(expanded);
73
+ if (this.expanded === next) {
74
+ return;
75
+ }
76
+ this.expanded = next;
77
+ this.onRequestRender?.();
78
+ }
79
+
80
+ get isRunning() {
81
+ return this.phase === "running";
82
+ }
83
+
84
+ clearTimer() {
85
+ if (!this.timer) {
86
+ return;
87
+ }
88
+ clearInterval(this.timer);
89
+ this.timer = null;
90
+ }
91
+
92
+ invalidate() {
93
+ // No cached render state (blocks restyle every frame); keep the
94
+ // elapsed-time ticker alive across full repaints.
95
+ }
96
+
97
+ render(width) {
98
+ let lines;
99
+ if (this.phase === "running") {
100
+ lines = renderToolRunning({
101
+ name: this.name,
102
+ args: this.args,
103
+ phase: "running",
104
+ elapsedMs: Date.now() - this.startedAt,
105
+ progressMessage: this.progressMessage,
106
+ }, width);
107
+ } else {
108
+ lines = renderToolFinished({
109
+ name: this.name,
110
+ args: this.args,
111
+ phase: "done",
112
+ expanded: this.expanded,
113
+ event: this.resultEvent,
114
+ fileChange: this.fileChange,
115
+ }, width);
116
+ }
117
+ if (this.leading && lines.length) {
118
+ return ["", ...lines];
119
+ }
120
+ return lines;
121
+ }
122
+ }
@@ -1,203 +1,224 @@
1
- import { graphemes, stripAnsi, textWidth, wrapTextCells } from "./text-width.js";
2
-
3
- const DEFAULT_COLUMNS = 80;
4
- const INPUT_MARKER = "\n ▷ ";
5
- const ANSI_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
6
- const SGR_SEQUENCE = /^\x1b\[([0-9;]*)m$/;
7
-
8
- export function prepareComposerFrame(frame = {}, columns = DEFAULT_COLUMNS) {
9
- const width = terminalColumns(columns);
10
- const block = splitPromptBlock(frame.prompt);
11
- const input = String(frame.inputText || "");
12
- const display = input || String(frame.placeholder || "");
13
- const position = cursorPosition(frame, input);
14
- const leadingLines = wrapRenderedLines(block.leading, width);
15
- const inputLines = wrapInputLines(block.prefix, display, width);
16
- const cursor = visualCursor(block.prefix, input, position, width, inputLines);
17
- const menuLines = wrapRenderedLines(frame.menuText, width);
18
- const trailingLines = wrapRenderedLines(block.trailing, width);
19
- const lines = [...leadingLines, ...inputLines, ...menuLines, ...trailingLines];
20
- const cursorRow = leadingLines.length + cursor.row;
21
- const selectedMenuRow = menuLines.findIndex((line) => stripAnsi(line).startsWith(" › "));
22
-
23
- return {
24
- lines,
25
- cursorRow,
26
- cursorColumn: cursor.column,
27
- focusRow: selectedMenuRow === -1
28
- ? cursorRow
29
- : leadingLines.length + inputLines.length + selectedMenuRow,
30
- };
31
- }
32
-
33
- function wrapInputLines(prefix, text, columns) {
34
- const logicalLines = String(text || "").split("\n");
35
- const prefixWidth = textWidth(prefix);
36
- const continuationPrefix = " ".repeat(prefixWidth);
37
- const contentWidth = Math.max(1, columns - prefixWidth);
38
- const lines = [];
39
- for (const [index, logicalLine] of logicalLines.entries()) {
40
- const linePrefix = index === 0 ? prefix : continuationPrefix;
41
- if (logicalLine.includes("\x1b")) {
42
- lines.push(...wrapLine(
43
- linePrefix,
44
- continuationPrefix,
45
- logicalLine,
46
- contentWidth,
47
- ));
48
- continue;
49
- }
50
- const chunks = wrapTextCells(logicalLine, contentWidth, contentWidth);
51
- lines.push(...chunks.map((chunk, chunkIndex) => (
52
- `${chunkIndex === 0 ? linePrefix : continuationPrefix}${chunk.text}`
53
- )));
54
- }
55
- return lines.length ? lines : [String(prefix || "")];
56
- }
57
-
58
- function visualCursor(prefix, input, position, columns, inputLines) {
59
- const logicalLines = String(input).split("\n");
60
- const prefixWidth = textWidth(prefix);
61
- const continuationPrefix = " ".repeat(prefixWidth);
62
- const contentWidth = Math.max(1, columns - prefixWidth);
63
- const visualLines = [];
64
- for (const [lineIndex, logicalLine] of logicalLines.entries()) {
65
- const chunks = wrapTextCells(logicalLine, contentWidth, contentWidth);
66
- for (const chunk of chunks) {
67
- visualLines.push({
68
- startColumn: chunk.startColumn,
69
- length: chunk.length,
70
- allowsEnd: chunk.allowsEnd,
71
- line: lineIndex,
72
- prefixWidth,
73
- });
74
- }
75
- const lastChunk = chunks.at(-1);
76
- if (lineIndex === logicalLines.length - 1 && !lastChunk.allowsEnd) {
77
- visualLines.push({
78
- startColumn: lastChunk.startColumn + lastChunk.length,
79
- length: 0,
80
- allowsEnd: true,
81
- line: lineIndex,
82
- prefixWidth,
83
- });
84
- }
85
- }
86
-
87
- let row = visualLines.length - 1;
88
- for (const [index, visualLine] of visualLines.entries()) {
89
- if (visualLine.line !== position.line) {
90
- continue;
91
- }
92
- const offset = position.column - visualLine.startColumn;
93
- if (offset >= 0 && (offset < visualLine.length || (visualLine.allowsEnd && offset === visualLine.length))) {
94
- row = index;
95
- break;
96
- }
97
- }
98
- while (inputLines.length <= row) {
99
- inputLines.push(continuationPrefix);
100
- }
101
- const visualLine = visualLines[row];
102
- const text = graphemes(logicalLines[position.line])
103
- .slice(visualLine.startColumn, position.column)
104
- .join("");
105
- return {
106
- row,
107
- column: visualLine.prefixWidth + textWidth(text),
108
- };
109
- }
110
-
111
- function wrapLine(prefix, continuationPrefix, text, contentWidth) {
112
- const lines = [];
113
- let line = "";
114
- let width = 0;
115
- let activeStyle = "";
116
- for (const segment of displaySegments(text)) {
117
- const sgr = segment.match(SGR_SEQUENCE);
118
- if (sgr) {
119
- line += segment;
120
- const codes = (sgr[1] || "0").split(";").map((code) => code || "0");
121
- const resetIndex = codes.lastIndexOf("0");
122
- activeStyle = resetIndex === codes.length - 1
123
- ? ""
124
- : resetIndex >= 0
125
- ? segment
126
- : `${activeStyle}${segment}`;
127
- continue;
128
- }
129
- const segmentWidth = textWidth(segment);
130
- if (width > 0 && width + segmentWidth > contentWidth) {
131
- lines.push(`${lines.length ? continuationPrefix : prefix}${line}`);
132
- line = activeStyle;
133
- width = 0;
134
- }
135
- line += segment;
136
- width += segmentWidth;
137
- }
138
- lines.push(`${lines.length ? continuationPrefix : prefix}${line}`);
139
- return lines;
140
- }
141
-
142
- function displaySegments(value) {
143
- const text = String(value || "");
144
- const segments = [];
145
- let position = 0;
146
- for (const match of text.matchAll(ANSI_SEQUENCE)) {
147
- segments.push(...graphemes(text.slice(position, match.index)), match[0]);
148
- position = match.index + match[0].length;
149
- }
150
- segments.push(...graphemes(text.slice(position)));
151
- return segments;
152
- }
153
-
154
- function cursorPosition(frame, input) {
155
- const lines = String(input).split("\n");
156
- const line = Math.min(lines.length - 1, Math.max(0, Math.floor(Number(frame.cursor?.line) || 0)));
157
- return {
158
- line,
159
- column: Math.min(
160
- graphemes(lines[line]).length,
161
- Math.max(0, Math.floor(Number(frame.cursor?.column) || 0)),
162
- ),
163
- };
164
- }
165
-
166
- function terminalColumns(columns) {
167
- const value = Number(columns);
168
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_COLUMNS;
169
- }
170
-
171
- function splitPromptBlock(prompt) {
172
- const text = String(prompt || "");
173
- const index = text.lastIndexOf(INPUT_MARKER);
174
- if (index === -1) {
175
- return { leading: "", prefix: text, trailing: "" };
176
- }
177
- const inputStart = index + 1;
178
- const trailingStart = text.indexOf("\n", inputStart);
179
- if (trailingStart === -1) {
180
- return {
181
- leading: text.slice(0, inputStart),
182
- prefix: text.slice(inputStart),
183
- trailing: "",
184
- };
185
- }
186
- return {
187
- leading: text.slice(0, inputStart),
188
- prefix: text.slice(inputStart, trailingStart),
189
- trailing: text.slice(trailingStart + 1),
190
- };
191
- }
192
-
193
- function wrapRenderedLines(text, columns) {
194
- const value = String(text || "");
195
- if (!value) {
196
- return [];
197
- }
198
- const lines = value.split("\n");
199
- if (lines.at(-1) === "") {
200
- lines.pop();
201
- }
202
- return lines.flatMap((line) => wrapLine("", "", line, columns));
203
- }
1
+ import { graphemes, stripAnsi, textWidth, wrapTextCells } from "./text-width.js";
2
+
3
+ const DEFAULT_COLUMNS = 80;
4
+ const INPUT_MARKER = "\n ▷ ";
5
+ const ANSI_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
6
+ const SGR_SEQUENCE = /^\x1b\[([0-9;]*)m$/;
7
+
8
+ export function prepareComposerFrame(frame = {}, columns = DEFAULT_COLUMNS) {
9
+ const width = terminalColumns(columns);
10
+ const block = splitPromptBlock(frame.prompt);
11
+ const input = String(frame.inputText || "");
12
+ const display = input || String(frame.placeholder || "");
13
+ const position = cursorPosition(frame, input);
14
+ const leadingLines = wrapRenderedLines(block.leading, width);
15
+ const inputLines = wrapInputLines(block.prefix, display, width);
16
+ const menuLines = wrapRenderedLines(frame.menuText, width);
17
+ const menuCursor = frame.menuCursor
18
+ ? resolveMenuCursor(frame.menuCursor, frame.menuText, width)
19
+ : null;
20
+ const cursor = menuCursor || visualCursor(block.prefix, input, position, width, inputLines);
21
+ const trailingLines = wrapRenderedLines(block.trailing, width);
22
+ const lines = [...leadingLines, ...inputLines, ...menuLines, ...trailingLines];
23
+ const cursorRow = menuCursor
24
+ ? leadingLines.length + inputLines.length + cursor.row
25
+ : leadingLines.length + cursor.row;
26
+ const selectedMenuRow = menuLines.findIndex((line) => stripAnsi(line).startsWith(" › "));
27
+
28
+ return {
29
+ lines,
30
+ cursorRow,
31
+ cursorColumn: cursor.column,
32
+ focusRow: selectedMenuRow === -1
33
+ ? cursorRow
34
+ : leadingLines.length + inputLines.length + selectedMenuRow,
35
+ };
36
+ }
37
+
38
+ function resolveMenuCursor(cursor, menuText, columns) {
39
+ const targetLine = Math.max(0, Math.floor(Number(cursor.line) || 0));
40
+ const rawLines = String(menuText || "").split("\n");
41
+ if (rawLines.at(-1) === "") {
42
+ rawLines.pop();
43
+ }
44
+ let row = 0;
45
+ for (let index = 0; index < targetLine; index += 1) {
46
+ row += wrapRenderedLines(rawLines[index] || "", columns).length;
47
+ }
48
+ return {
49
+ row,
50
+ column: Math.max(0, Math.floor(Number(cursor.column) || 0)),
51
+ };
52
+ }
53
+
54
+ function wrapInputLines(prefix, text, columns) {
55
+ const logicalLines = String(text || "").split("\n");
56
+ const prefixWidth = textWidth(prefix);
57
+ const continuationPrefix = " ".repeat(prefixWidth);
58
+ const contentWidth = Math.max(1, columns - prefixWidth);
59
+ const lines = [];
60
+ for (const [index, logicalLine] of logicalLines.entries()) {
61
+ const linePrefix = index === 0 ? prefix : continuationPrefix;
62
+ if (logicalLine.includes("\x1b")) {
63
+ lines.push(...wrapLine(
64
+ linePrefix,
65
+ continuationPrefix,
66
+ logicalLine,
67
+ contentWidth,
68
+ ));
69
+ continue;
70
+ }
71
+ const chunks = wrapTextCells(logicalLine, contentWidth, contentWidth);
72
+ lines.push(...chunks.map((chunk, chunkIndex) => (
73
+ `${chunkIndex === 0 ? linePrefix : continuationPrefix}${chunk.text}`
74
+ )));
75
+ }
76
+ return lines.length ? lines : [String(prefix || "")];
77
+ }
78
+
79
+ function visualCursor(prefix, input, position, columns, inputLines) {
80
+ const logicalLines = String(input).split("\n");
81
+ const prefixWidth = textWidth(prefix);
82
+ const continuationPrefix = " ".repeat(prefixWidth);
83
+ const contentWidth = Math.max(1, columns - prefixWidth);
84
+ const visualLines = [];
85
+ for (const [lineIndex, logicalLine] of logicalLines.entries()) {
86
+ const chunks = wrapTextCells(logicalLine, contentWidth, contentWidth);
87
+ for (const chunk of chunks) {
88
+ visualLines.push({
89
+ startColumn: chunk.startColumn,
90
+ length: chunk.length,
91
+ allowsEnd: chunk.allowsEnd,
92
+ line: lineIndex,
93
+ prefixWidth,
94
+ });
95
+ }
96
+ const lastChunk = chunks.at(-1);
97
+ if (lineIndex === logicalLines.length - 1 && !lastChunk.allowsEnd) {
98
+ visualLines.push({
99
+ startColumn: lastChunk.startColumn + lastChunk.length,
100
+ length: 0,
101
+ allowsEnd: true,
102
+ line: lineIndex,
103
+ prefixWidth,
104
+ });
105
+ }
106
+ }
107
+
108
+ let row = visualLines.length - 1;
109
+ for (const [index, visualLine] of visualLines.entries()) {
110
+ if (visualLine.line !== position.line) {
111
+ continue;
112
+ }
113
+ const offset = position.column - visualLine.startColumn;
114
+ if (offset >= 0 && (offset < visualLine.length || (visualLine.allowsEnd && offset === visualLine.length))) {
115
+ row = index;
116
+ break;
117
+ }
118
+ }
119
+ while (inputLines.length <= row) {
120
+ inputLines.push(continuationPrefix);
121
+ }
122
+ const visualLine = visualLines[row];
123
+ const text = graphemes(logicalLines[position.line])
124
+ .slice(visualLine.startColumn, position.column)
125
+ .join("");
126
+ return {
127
+ row,
128
+ column: visualLine.prefixWidth + textWidth(text),
129
+ };
130
+ }
131
+
132
+ function wrapLine(prefix, continuationPrefix, text, contentWidth) {
133
+ const lines = [];
134
+ let line = "";
135
+ let width = 0;
136
+ let activeStyle = "";
137
+ for (const segment of displaySegments(text)) {
138
+ const sgr = segment.match(SGR_SEQUENCE);
139
+ if (sgr) {
140
+ line += segment;
141
+ const codes = (sgr[1] || "0").split(";").map((code) => code || "0");
142
+ const resetIndex = codes.lastIndexOf("0");
143
+ activeStyle = resetIndex === codes.length - 1
144
+ ? ""
145
+ : resetIndex >= 0
146
+ ? segment
147
+ : `${activeStyle}${segment}`;
148
+ continue;
149
+ }
150
+ const segmentWidth = textWidth(segment);
151
+ if (width > 0 && width + segmentWidth > contentWidth) {
152
+ lines.push(`${lines.length ? continuationPrefix : prefix}${line}`);
153
+ line = activeStyle;
154
+ width = 0;
155
+ }
156
+ line += segment;
157
+ width += segmentWidth;
158
+ }
159
+ lines.push(`${lines.length ? continuationPrefix : prefix}${line}`);
160
+ return lines;
161
+ }
162
+
163
+ function displaySegments(value) {
164
+ const text = String(value || "");
165
+ const segments = [];
166
+ let position = 0;
167
+ for (const match of text.matchAll(ANSI_SEQUENCE)) {
168
+ segments.push(...graphemes(text.slice(position, match.index)), match[0]);
169
+ position = match.index + match[0].length;
170
+ }
171
+ segments.push(...graphemes(text.slice(position)));
172
+ return segments;
173
+ }
174
+
175
+ function cursorPosition(frame, input) {
176
+ const lines = String(input).split("\n");
177
+ const line = Math.min(lines.length - 1, Math.max(0, Math.floor(Number(frame.cursor?.line) || 0)));
178
+ return {
179
+ line,
180
+ column: Math.min(
181
+ graphemes(lines[line]).length,
182
+ Math.max(0, Math.floor(Number(frame.cursor?.column) || 0)),
183
+ ),
184
+ };
185
+ }
186
+
187
+ function terminalColumns(columns) {
188
+ const value = Number(columns);
189
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_COLUMNS;
190
+ }
191
+
192
+ function splitPromptBlock(prompt) {
193
+ const text = String(prompt || "");
194
+ const index = text.lastIndexOf(INPUT_MARKER);
195
+ if (index === -1) {
196
+ return { leading: "", prefix: text, trailing: "" };
197
+ }
198
+ const inputStart = index + 1;
199
+ const trailingStart = text.indexOf("\n", inputStart);
200
+ if (trailingStart === -1) {
201
+ return {
202
+ leading: text.slice(0, inputStart),
203
+ prefix: text.slice(inputStart),
204
+ trailing: "",
205
+ };
206
+ }
207
+ return {
208
+ leading: text.slice(0, inputStart),
209
+ prefix: text.slice(inputStart, trailingStart),
210
+ trailing: text.slice(trailingStart + 1),
211
+ };
212
+ }
213
+
214
+ function wrapRenderedLines(text, columns) {
215
+ const value = String(text || "");
216
+ if (!value) {
217
+ return [];
218
+ }
219
+ const lines = value.split("\n");
220
+ if (lines.at(-1) === "") {
221
+ lines.pop();
222
+ }
223
+ return lines.flatMap((line) => wrapLine("", "", line, columns));
224
+ }