@rind-ai/cli 0.6.1 → 0.7.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.
@@ -4,11 +4,11 @@ const HISTORY_LIMIT = 100;
4
4
  const UNDO_LIMIT = 100;
5
5
  const INPUT_PREFIX_WIDTH = 4;
6
6
 
7
- export function createLineEditor(initialValue = "") {
7
+ export function createLineEditor(initialValue = "", options = {}) {
8
8
  let lines = splitLines(initialValue);
9
9
  let cursorLine = lines.length - 1;
10
10
  let cursorColumn = graphemes(lines[cursorLine]).length;
11
- const history = [];
11
+ const history = normalizeHistory(options.history);
12
12
  let historyIndex = -1;
13
13
  let historyDraft = null;
14
14
  const undoStack = [];
@@ -142,13 +142,17 @@ export function createLineEditor(initialValue = "") {
142
142
  const text = String(value || "").trim();
143
143
  if (!text || history[0] === text) {
144
144
  resetHistory();
145
- return;
145
+ return false;
146
146
  }
147
147
  history.unshift(text);
148
148
  if (history.length > HISTORY_LIMIT) {
149
149
  history.length = HISTORY_LIMIT;
150
150
  }
151
151
  resetHistory();
152
+ return true;
153
+ },
154
+ getHistory() {
155
+ return history.slice();
152
156
  },
153
157
  };
154
158
  return editor;
@@ -537,5 +541,26 @@ export function createLineEditor(initialValue = "") {
537
541
  }
538
542
 
539
543
  function isPrintable(chunk, key) {
540
- return Boolean(chunk && !key.ctrl && !key.alt && String(chunk) >= " ");
544
+ if (!chunk || key.ctrl || key.alt || key.shift) {
545
+ return false;
546
+ }
547
+ return [...String(chunk)].every((character) => {
548
+ const code = character.codePointAt(0);
549
+ return code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f);
550
+ });
551
+ }
552
+
553
+ function normalizeHistory(values) {
554
+ const history = [];
555
+ for (const value of Array.isArray(values) ? values : []) {
556
+ const text = String(value || "").trim();
557
+ if (!text || history.includes(text)) {
558
+ continue;
559
+ }
560
+ history.push(text);
561
+ if (history.length >= HISTORY_LIMIT) {
562
+ break;
563
+ }
564
+ }
565
+ return history;
541
566
  }
@@ -6,8 +6,10 @@ import { currentTheme, setTheme, themeNames, themeOptions } from "./theme.js";
6
6
  export const LOCAL_SLASH_COMMANDS = Object.freeze([
7
7
  { name: "compact", description: "Compact current session context", usage: "/compact" },
8
8
  { name: "config", description: "Show config guidance", usage: "/config" },
9
+ { name: "context", description: "Show context composition and token usage", usage: "/context" },
9
10
  { name: "doctor", description: "Run local setup diagnostics", usage: "/doctor" },
10
11
  { name: "effort", description: "Show or change reasoning effort", usage: "/effort [low | medium | high | xhigh | max]" },
12
+ { name: "fork", description: "Fork the current session", usage: "/fork" },
11
13
  { name: "goal", description: "View or control the active goal", usage: "/goal [pause | resume | clear | objective]" },
12
14
  { name: "help", description: "Show commands", usage: "/help [command]" },
13
15
  { name: "init", description: "Draft RIND.md", usage: "/init [project|user]" },
@@ -1,5 +1,8 @@
1
+ import { textWidth, wrapTextWithAnsi } from "./text-width.js";
2
+
1
3
  const INLINE_TOKEN_RE = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*)/g;
2
4
  const PLAIN_TEXT_RE = /[`*#>|\[]/;
5
+ const TABLE_SEPARATOR_CELL_RE = /^:?-{3,}:?$/;
3
6
 
4
7
  export function renderMarkdownishLine(line, color) {
5
8
  const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
@@ -63,6 +66,14 @@ export function isTableLine(line, inCodeBlock) {
63
66
  return !inCodeBlock && stripped.includes("|") && stripped.split("|").length > 2;
64
67
  }
65
68
 
69
+ export function isTableSeparator(line, inCodeBlock = false) {
70
+ if (!isTableLine(line, inCodeBlock)) {
71
+ return false;
72
+ }
73
+ const cells = parseTableRow(line);
74
+ return cells.length > 0 && cells.every((cell) => TABLE_SEPARATOR_CELL_RE.test(cell));
75
+ }
76
+
66
77
  export function parseTableRow(line) {
67
78
  let stripped = line.trim();
68
79
  if (stripped.startsWith("|")) {
@@ -74,6 +85,137 @@ export function parseTableRow(line) {
74
85
  return stripped.split("|").map((cell) => cell.trim());
75
86
  }
76
87
 
88
+ export function createTableState() {
89
+ return { candidate: [], rows: null };
90
+ }
91
+
92
+ export function consumeTableLine(state, line, inCodeBlock = false) {
93
+ if (state.rows) {
94
+ if (isTableLine(line, inCodeBlock)) {
95
+ state.rows.push(parseTableRow(line));
96
+ return { type: "append" };
97
+ }
98
+ const rows = state.rows;
99
+ state.rows = null;
100
+ return { type: "flush", rows, line };
101
+ }
102
+ if (state.candidate.length) {
103
+ if (isTableSeparator(line, inCodeBlock)) {
104
+ const header = state.candidate.at(-1);
105
+ const lines = state.candidate.slice(0, -1);
106
+ state.rows = [parseTableRow(header)];
107
+ state.candidate = [];
108
+ return { type: "start", lines };
109
+ }
110
+ const lines = state.candidate;
111
+ state.candidate = [];
112
+ return { type: "flush_candidate", lines, line };
113
+ }
114
+ if (isTableLine(line, inCodeBlock)) {
115
+ state.candidate.push(line);
116
+ return { type: "hold" };
117
+ }
118
+ return { type: "line", line };
119
+ }
120
+
121
+ export function finishTableState(state) {
122
+ if (state.rows) {
123
+ const rows = state.rows;
124
+ state.rows = null;
125
+ return { type: "flush", rows };
126
+ }
127
+ if (state.candidate.length) {
128
+ const lines = state.candidate;
129
+ state.candidate = [];
130
+ return { type: "flush_candidate", lines };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ export function renderTableBlock(rows, color, width) {
136
+ if (!Array.isArray(rows) || rows.length === 0) {
137
+ return "";
138
+ }
139
+ const columnCount = Math.max(...rows.map((row) => Array.isArray(row) ? row.length : 0), 0);
140
+ if (!columnCount) {
141
+ return "";
142
+ }
143
+ const normalizedRows = rows.map((row) => Array.from({ length: columnCount }, (_, index) => String(row?.[index] || "")));
144
+ const header = normalizedRows[0];
145
+ const bodyRows = normalizedRows.slice(1).filter((row) => !row.every((cell) => TABLE_SEPARATOR_CELL_RE.test(cell)));
146
+ const displayRows = [header, ...bodyRows];
147
+ const availableWidth = Math.max(1, Number(width) || 1);
148
+ const borderOverhead = 3 * columnCount + 1;
149
+ const availableCells = availableWidth - borderOverhead;
150
+ if (availableCells < columnCount) {
151
+ return normalizedRows.map((row) => row.map((cell) => renderInline(cell, color)).join(dim(" | ", color))).join("\n");
152
+ }
153
+
154
+ const naturalWidths = Array(columnCount).fill(1);
155
+ const minimumWidths = Array(columnCount).fill(1);
156
+ displayRows.forEach((row, rowIndex) => {
157
+ row.forEach((cell, columnIndex) => {
158
+ const plain = renderInline(cell, false);
159
+ naturalWidths[columnIndex] = Math.max(naturalWidths[columnIndex], textWidth(plain));
160
+ minimumWidths[columnIndex] = Math.max(minimumWidths[columnIndex], longestWordWidth(plain, 30));
161
+ if (rowIndex === 0) {
162
+ minimumWidths[columnIndex] = Math.max(minimumWidths[columnIndex], 1);
163
+ }
164
+ });
165
+ });
166
+
167
+ const minimumTotal = minimumWidths.reduce((sum, value) => sum + value, 0);
168
+ if (minimumTotal > availableCells) {
169
+ minimumWidths.fill(1);
170
+ }
171
+ const columnWidths = minimumWidths.slice();
172
+ let remaining = availableCells - columnWidths.reduce((sum, value) => sum + value, 0);
173
+ while (remaining > 0) {
174
+ let grew = false;
175
+ for (let index = 0; index < columnCount && remaining > 0; index += 1) {
176
+ if (columnWidths[index] < naturalWidths[index]) {
177
+ columnWidths[index] += 1;
178
+ remaining -= 1;
179
+ grew = true;
180
+ }
181
+ }
182
+ if (!grew) {
183
+ break;
184
+ }
185
+ }
186
+
187
+ const border = (left, joiner, right) => `${left}─${columnWidths.map((value) => "─".repeat(value)).join(`─${joiner}─`)}─${right}`;
188
+ const lines = [border("┌", "┬", "┐")];
189
+ displayRows.forEach((row, rowIndex) => {
190
+ const cells = row.map((cell, columnIndex) => {
191
+ const rendered = renderInline(cell, color, rowIndex === 0 ? "tableHeader" : "");
192
+ return wrapTextWithAnsi(rendered, columnWidths[columnIndex], columnWidths[columnIndex]);
193
+ });
194
+ const height = Math.max(...cells.map((cell) => cell.length));
195
+ for (let lineIndex = 0; lineIndex < height; lineIndex += 1) {
196
+ const renderedCells = cells.map((cell, columnIndex) => {
197
+ const value = cell[lineIndex] || "";
198
+ return `${value}${" ".repeat(Math.max(0, columnWidths[columnIndex] - textWidth(value)))}`;
199
+ });
200
+ lines.push(`│ ${renderedCells.join(" │ ")} │`);
201
+ }
202
+ if (rowIndex === 0) {
203
+ lines.push(border("├", "┼", "┤"));
204
+ } else if (rowIndex < displayRows.length - 1) {
205
+ lines.push(border("├", "┼", "┤"));
206
+ }
207
+ });
208
+ lines.push(border("└", "┴", "┘"));
209
+ return lines.join("\n");
210
+ }
211
+
212
+ function longestWordWidth(value, limit) {
213
+ return Math.min(
214
+ limit,
215
+ Math.max(...String(value || "").split(/\s+/).map((word) => textWidth(word)), 1),
216
+ );
217
+ }
218
+
77
219
  export function codeOpenLabel(label) {
78
220
  return label ? `┌ code ${label}` : "┌ code";
79
221
  }
@@ -1,145 +1,145 @@
1
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
- const SPINNER_INTERVAL_MS = 120;
3
- const NAME_COLUMN_MAX = 26;
4
-
5
- export function formatDuration(ms) {
6
- const value = Math.max(0, Number(ms) || 0);
7
- if (value < 1000) return `${Math.round(value)}ms`;
8
- if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
9
- return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
10
- }
11
-
12
- export function createOneShotProgress({ stderr, stream = null } = {}) {
13
- const tty = stream ?? { isTTY: false };
14
- const isTTY = Boolean(tty.isTTY);
15
- const useColor = isTTY && process.env.NO_COLOR === undefined;
16
- const c = useColor
17
- ? { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", reset: "\x1b[0m" }
18
- : { dim: "", red: "", green: "", reset: "" };
19
-
20
- const tools = new Map();
21
- let toolCounter = 0;
22
- let printedToolLines = 0;
23
- let lastPendingToolId = null;
24
- let spinnerTimer = null;
25
- let spinnerFrame = 0;
26
- let spinnerLabel = "";
27
- let active = false;
28
-
29
- function emit(text) {
30
- stopSpinner();
31
- stderr(text);
32
- }
33
-
34
- function startSpinner(label) {
35
- spinnerLabel = label;
36
- if (!isTTY || spinnerTimer) return;
37
- spinnerFrame = 0;
38
- renderSpinner();
39
- spinnerTimer = setInterval(() => {
40
- spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
41
- renderSpinner();
42
- }, SPINNER_INTERVAL_MS);
43
- spinnerTimer.unref?.();
44
- }
45
-
46
- function renderSpinner() {
47
- stderr(`\r${c.dim}${SPINNER_FRAMES[spinnerFrame]} ${spinnerLabel}${c.reset}\x1b[K`);
48
- }
49
-
50
- function stopSpinner() {
51
- if (!spinnerTimer) return;
52
- clearInterval(spinnerTimer);
53
- spinnerTimer = null;
54
- if (isTTY) stderr("\r\x1b[K");
55
- }
56
-
57
- function resumeSpinner() {
58
- if (active && isTTY && spinnerLabel) startSpinner(spinnerLabel);
59
- }
60
-
61
- function toolLine(entry) {
62
- const index = String(entry.index).padStart(2, " ");
63
- const truncated = entry.name.length > NAME_COLUMN_MAX
64
- ? `${entry.name.slice(0, NAME_COLUMN_MAX - 1)}…`
65
- : entry.name;
66
- if (entry.finishedAt === null) {
67
- const pendingMark = isTTY ? ` ${c.dim}…${c.reset}` : "";
68
- return ` ${c.dim}${index}${c.reset} ${truncated}${pendingMark}`;
69
- }
70
- const duration = c.dim + formatDuration(entry.durationMs) + c.reset;
71
- const mark = entry.ok ? "" : ` ${c.red}✗${c.reset}`;
72
- return ` ${c.dim}${index}${c.reset} ${truncated} ${duration}${mark}`;
73
- }
74
-
75
- return {
76
- begin() {
77
- active = true;
78
- startSpinner("starting runtime");
79
- },
80
-
81
- hasTool(toolCallId) {
82
- return tools.has(toolCallId);
83
- },
84
-
85
- get toolCount() {
86
- return tools.size;
87
- },
88
-
89
- session({ sessionId, model, baseUrl }) {
90
- const parts = [`session ${sessionId}`];
91
- if (model) parts.push(`model ${model}`);
92
- if (baseUrl) parts.push(`api ${baseUrl}`);
93
- emit(`${c.dim}·${c.reset} ${parts.join(`${c.dim} · ${c.reset}`)}\n`);
94
- startSpinner("working");
95
- },
96
-
97
- note(text) {
98
- emit(`${c.dim}· ${text}${c.reset}\n`);
99
- resumeSpinner();
100
- },
101
-
102
- toolStarted(toolCallId, toolName) {
103
- const name = toolName || "tool";
104
- toolCounter += 1;
105
- const entry = { index: toolCounter, name, finishedAt: null, durationMs: 0, ok: true };
106
- tools.set(toolCallId, entry);
107
- emit(`${toolLine(entry)}\n`);
108
- printedToolLines += 1;
109
- lastPendingToolId = toolCallId;
110
- resumeSpinner();
111
- },
112
-
113
- toolFinished(toolCallId, { ok, durationMs }) {
114
- const entry = tools.get(toolCallId);
115
- if (!entry || entry.finishedAt !== null) return;
116
- entry.finishedAt = Date.now();
117
- entry.durationMs = Number(durationMs) || 0;
118
- entry.ok = Boolean(ok);
119
- const canRewrite = isTTY && toolCallId === lastPendingToolId;
120
- if (canRewrite) {
121
- stopSpinner();
122
- stderr(`\x1b[1A\r\x1b[K${toolLine(entry)}\n`);
123
- lastPendingToolId = null;
124
- resumeSpinner();
125
- return;
126
- }
127
- if (!entry.ok) {
128
- emit(` ${c.red}↳ ${entry.name} failed${c.reset} ${c.dim}${formatDuration(entry.durationMs)}${c.reset}\n`);
129
- resumeSpinner();
130
- }
131
- if (toolCallId === lastPendingToolId) lastPendingToolId = null;
132
- },
133
-
134
- done(elapsedMs) {
135
- active = false;
136
- if (printedToolLines > 0) emit("\n");
137
- emit(`${c.green}✓${c.reset} done in ${formatDuration(elapsedMs)}\n`);
138
- },
139
-
140
- fail(message) {
141
- active = false;
142
- emit(`${c.red}✗ ${message}${c.reset}\n`);
143
- },
144
- };
145
- }
1
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
+ const SPINNER_INTERVAL_MS = 120;
3
+ const NAME_COLUMN_MAX = 26;
4
+
5
+ export function formatDuration(ms) {
6
+ const value = Math.max(0, Number(ms) || 0);
7
+ if (value < 1000) return `${Math.round(value)}ms`;
8
+ if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
9
+ return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
10
+ }
11
+
12
+ export function createOneShotProgress({ stderr, stream = null } = {}) {
13
+ const tty = stream ?? { isTTY: false };
14
+ const isTTY = Boolean(tty.isTTY);
15
+ const useColor = isTTY && process.env.NO_COLOR === undefined;
16
+ const c = useColor
17
+ ? { dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", reset: "\x1b[0m" }
18
+ : { dim: "", red: "", green: "", reset: "" };
19
+
20
+ const tools = new Map();
21
+ let toolCounter = 0;
22
+ let printedToolLines = 0;
23
+ let lastPendingToolId = null;
24
+ let spinnerTimer = null;
25
+ let spinnerFrame = 0;
26
+ let spinnerLabel = "";
27
+ let active = false;
28
+
29
+ function emit(text) {
30
+ stopSpinner();
31
+ stderr(text);
32
+ }
33
+
34
+ function startSpinner(label) {
35
+ spinnerLabel = label;
36
+ if (!isTTY || spinnerTimer) return;
37
+ spinnerFrame = 0;
38
+ renderSpinner();
39
+ spinnerTimer = setInterval(() => {
40
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
41
+ renderSpinner();
42
+ }, SPINNER_INTERVAL_MS);
43
+ spinnerTimer.unref?.();
44
+ }
45
+
46
+ function renderSpinner() {
47
+ stderr(`\r${c.dim}${SPINNER_FRAMES[spinnerFrame]} ${spinnerLabel}${c.reset}\x1b[K`);
48
+ }
49
+
50
+ function stopSpinner() {
51
+ if (!spinnerTimer) return;
52
+ clearInterval(spinnerTimer);
53
+ spinnerTimer = null;
54
+ if (isTTY) stderr("\r\x1b[K");
55
+ }
56
+
57
+ function resumeSpinner() {
58
+ if (active && isTTY && spinnerLabel) startSpinner(spinnerLabel);
59
+ }
60
+
61
+ function toolLine(entry) {
62
+ const index = String(entry.index).padStart(2, " ");
63
+ const truncated = entry.name.length > NAME_COLUMN_MAX
64
+ ? `${entry.name.slice(0, NAME_COLUMN_MAX - 1)}…`
65
+ : entry.name;
66
+ if (entry.finishedAt === null) {
67
+ const pendingMark = isTTY ? ` ${c.dim}…${c.reset}` : "";
68
+ return ` ${c.dim}${index}${c.reset} ${truncated}${pendingMark}`;
69
+ }
70
+ const duration = c.dim + formatDuration(entry.durationMs) + c.reset;
71
+ const mark = entry.ok ? "" : ` ${c.red}✗${c.reset}`;
72
+ return ` ${c.dim}${index}${c.reset} ${truncated} ${duration}${mark}`;
73
+ }
74
+
75
+ return {
76
+ begin() {
77
+ active = true;
78
+ startSpinner("starting runtime");
79
+ },
80
+
81
+ hasTool(toolCallId) {
82
+ return tools.has(toolCallId);
83
+ },
84
+
85
+ get toolCount() {
86
+ return tools.size;
87
+ },
88
+
89
+ session({ sessionId, model, baseUrl }) {
90
+ const parts = [`session ${sessionId}`];
91
+ if (model) parts.push(`model ${model}`);
92
+ if (baseUrl) parts.push(`api ${baseUrl}`);
93
+ emit(`${c.dim}·${c.reset} ${parts.join(`${c.dim} · ${c.reset}`)}\n`);
94
+ startSpinner("working");
95
+ },
96
+
97
+ note(text) {
98
+ emit(`${c.dim}· ${text}${c.reset}\n`);
99
+ resumeSpinner();
100
+ },
101
+
102
+ toolStarted(toolCallId, toolName) {
103
+ const name = toolName || "tool";
104
+ toolCounter += 1;
105
+ const entry = { index: toolCounter, name, finishedAt: null, durationMs: 0, ok: true };
106
+ tools.set(toolCallId, entry);
107
+ emit(`${toolLine(entry)}\n`);
108
+ printedToolLines += 1;
109
+ lastPendingToolId = toolCallId;
110
+ resumeSpinner();
111
+ },
112
+
113
+ toolFinished(toolCallId, { ok, durationMs }) {
114
+ const entry = tools.get(toolCallId);
115
+ if (!entry || entry.finishedAt !== null) return;
116
+ entry.finishedAt = Date.now();
117
+ entry.durationMs = Number(durationMs) || 0;
118
+ entry.ok = Boolean(ok);
119
+ const canRewrite = isTTY && toolCallId === lastPendingToolId;
120
+ if (canRewrite) {
121
+ stopSpinner();
122
+ stderr(`\x1b[1A\r\x1b[K${toolLine(entry)}\n`);
123
+ lastPendingToolId = null;
124
+ resumeSpinner();
125
+ return;
126
+ }
127
+ if (!entry.ok) {
128
+ emit(` ${c.red}↳ ${entry.name} failed${c.reset} ${c.dim}${formatDuration(entry.durationMs)}${c.reset}\n`);
129
+ resumeSpinner();
130
+ }
131
+ if (toolCallId === lastPendingToolId) lastPendingToolId = null;
132
+ },
133
+
134
+ done(elapsedMs) {
135
+ active = false;
136
+ if (printedToolLines > 0) emit("\n");
137
+ emit(`${c.green}✓${c.reset} done in ${formatDuration(elapsedMs)}\n`);
138
+ },
139
+
140
+ fail(message) {
141
+ active = false;
142
+ emit(`${c.red}✗ ${message}${c.reset}\n`);
143
+ },
144
+ };
145
+ }
package/lib/one-shot.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { createRuntimeClient } from "./runtime-client.js";
5
5
  import { requireRuntimeInitialization, runtimeMethods } from "./runtime-protocol.js";
6
6
  import { createOneShotProgress } from "./one-shot-progress.js";
7
+ import { sendHelp } from "./send.js";
7
8
 
8
9
  export const oneShotHelp = [
9
10
  "Usage: rind run --prompt <text> [--dir <absolute-path>] [--session <id>]",
@@ -20,6 +21,8 @@ export const cliHelp = [
20
21
  "Start the interactive CLI.",
21
22
  "",
22
23
  oneShotHelp,
24
+ "",
25
+ sendHelp,
23
26
  ].join("\n");
24
27
 
25
28
  export function parseOneShotArgs(args) {
@@ -143,9 +146,6 @@ export async function runOneShot({ args, python, repoRoot, runtimePath, cwd = pr
143
146
  if (message?.turn_id) turnId = String(message.turn_id);
144
147
  if (type === "assistant_delta") assistant += String(event.text || "");
145
148
  if (type === "assistant_message_completed") completed = String(event.content || "");
146
- if (type === "goal_continued") {
147
- progress.note(`goal check · round ${Number(event.round) || 0}`);
148
- }
149
149
  const toolCallId = String(event?.tool_call_id || "");
150
150
  const trackedId = toolCallId || (type === "tool_requested" ? `anon:${(anonymousToolCounter += 1)}` : "");
151
151
  if (trackedId && (type === "tool_requested" || type === "tool_input_started") && !progress.hasTool(trackedId)) {
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ const HISTORY_LIMIT = 100;
6
+
7
+ export function promptHistoryPath(rindHome = process.env.RIND_HOME || path.join(homedir(), ".rind")) {
8
+ return path.join(String(rindHome), "cli-history.jsonl");
9
+ }
10
+
11
+ export function loadPromptHistory(rindHome) {
12
+ try {
13
+ const lines = readFileSync(promptHistoryPath(rindHome), "utf8").split(/\r?\n/);
14
+ const history = [];
15
+ for (let index = lines.length - 1; index >= 0 && history.length < HISTORY_LIMIT; index -= 1) {
16
+ try {
17
+ const value = JSON.parse(lines[index]);
18
+ const text = typeof value === "string" ? value.trim() : "";
19
+ if (text && !history.includes(text)) {
20
+ history.push(text);
21
+ }
22
+ } catch {
23
+ continue;
24
+ }
25
+ }
26
+ return history;
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ export function savePromptHistory(history, rindHome) {
33
+ const values = [];
34
+ for (const value of Array.isArray(history) ? history : []) {
35
+ const text = String(value || "").trim();
36
+ if (!text || values.includes(text)) {
37
+ continue;
38
+ }
39
+ values.push(text);
40
+ if (values.length >= HISTORY_LIMIT) {
41
+ break;
42
+ }
43
+ }
44
+ const file = promptHistoryPath(rindHome);
45
+ try {
46
+ mkdirSync(path.dirname(file), { recursive: true });
47
+ const temp = `${file}.${process.pid}.tmp`;
48
+ writeFileSync(temp, `${values.reverse().map((value) => JSON.stringify(value)).join("\n")}\n`, "utf8");
49
+ renameSync(temp, file);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
@@ -26,7 +26,17 @@ export function createQuestionMenuState(options) {
26
26
  editing = true;
27
27
  return true;
28
28
  },
29
+ leaveEditing() {
30
+ if (!editing) {
31
+ return false;
32
+ }
33
+ editing = false;
34
+ return true;
35
+ },
29
36
  handleNavigation(key = {}) {
37
+ if (editing) {
38
+ return false;
39
+ }
30
40
  const vimNavigation = !editing && (key.text === "j" || key.text === "k");
31
41
  if (key.name !== "up" && key.name !== "down" && !vimNavigation) {
32
42
  return false;