@rind-ai/cli 0.4.1 → 0.6.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 (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 +1309 -1060
  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 +0 -11
  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 +675 -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,350 @@
1
+ import { REASONING_EFFORTS } from "./runtime-protocol.js";
2
+ import { modelListErrorText, commandResultText, goalCommandText, sessionSwitchedText } from "./rendering.js";
3
+
4
+ export function createCliRuntimeController({
5
+ client,
6
+ methods,
7
+ sessionScopedMethods,
8
+ turnScopedMethods,
9
+ requireInitialization,
10
+ state,
11
+ getCommands,
12
+ getTurnController,
13
+ getTaskMonitor,
14
+ getCompactContextState,
15
+ askModelMenu,
16
+ askEffortMenu = null,
17
+ askSessionMenu,
18
+ restoreLiveTurn,
19
+ renderHistory = () => {},
20
+ clearPendingInputs,
21
+ closeAssistant,
22
+ refreshInputState,
23
+ updateGoalState,
24
+ log,
25
+ writeError,
26
+ redraw,
27
+ }) {
28
+ let switchGeneration = 0;
29
+
30
+ async function request(method, params = {}) {
31
+ await ensureRuntime();
32
+ const requestParams = { ...params };
33
+ const sessionScoped = sessionScopedMethods.has(method) || method === methods.modelList;
34
+ if (sessionScoped && state.session.info.session_id && !requestParams.session_id) {
35
+ requestParams.session_id = state.session.info.session_id;
36
+ }
37
+ if (turnScopedMethods.has(method) && state.turn.id && !requestParams.turn_id) {
38
+ requestParams.turn_id = state.turn.id;
39
+ }
40
+ return client.request(method, requestParams);
41
+ }
42
+
43
+ async function ensureRuntime() {
44
+ if (state.runtime.status === "ready") {
45
+ return state.session.info;
46
+ }
47
+ if (state.runtime.failure) {
48
+ throw state.runtime.failure;
49
+ }
50
+ if (state.runtime.initialization) {
51
+ return state.runtime.initialization;
52
+ }
53
+ state.runtime.initialization = (async () => {
54
+ client.start();
55
+ state.runtime.status = "starting";
56
+ const info = requireInitialization(await client.request(methods.initialize));
57
+ state.session.info = { ...state.session.info, ...(info || {}) };
58
+ state.display.lastEventSequence = 0;
59
+ state.runtime.status = "ready";
60
+ const commandController = getCommands();
61
+ state.session.commands = mergeSlashCommands(
62
+ commandController.normalizeCommands(info?.commands),
63
+ commandController.localCommands(),
64
+ );
65
+ redraw(true);
66
+ void getTaskMonitor()?.refresh().catch(() => {});
67
+ return state.session.info;
68
+ })().catch((error) => {
69
+ state.runtime.status = client.child ? "starting" : "failed";
70
+ state.runtime.initialization = null;
71
+ throw error;
72
+ });
73
+ return state.runtime.initialization;
74
+ }
75
+
76
+ async function runGoalCommand(command) {
77
+ const turnController = getTurnController();
78
+ if (command.action === "set" && state.turn.active) {
79
+ log(() => commandResultText("Goal not started", "pause or finish the active turn first"));
80
+ return;
81
+ }
82
+ try {
83
+ if (command.action === "set") {
84
+ const result = await request(methods.goalSet, { objective: command.objective });
85
+ updateGoalState(result?.goal);
86
+ log(() => goalCommandText(result?.goal, "set"));
87
+ turnController.submit(command.objective);
88
+ return;
89
+ }
90
+ if (command.action === "clear") {
91
+ const result = await request(methods.goalClear);
92
+ updateGoalState(result?.goal || null);
93
+ log(() => goalCommandText(null, "clear"));
94
+ return;
95
+ }
96
+ if (command.action === "pause" || command.action === "resume") {
97
+ const result = await request(methods.goalStatus, { status: command.action === "resume" ? "active" : "paused" });
98
+ updateGoalState(result?.goal);
99
+ log(() => goalCommandText(result?.goal, command.action));
100
+ if (command.action === "resume" && !state.turn.active) {
101
+ turnController.submit("", { goal_continuation: true });
102
+ }
103
+ return;
104
+ }
105
+ const result = await request(methods.goalGet);
106
+ updateGoalState(result?.goal || null);
107
+ log(() => goalCommandText(result?.goal || null));
108
+ } catch (error) {
109
+ log(`Goal command failed: ${error instanceof Error ? error.message : String(error)}`);
110
+ }
111
+ }
112
+
113
+ async function refreshGoalState() {
114
+ if (state.runtime.status !== "ready" || !Array.isArray(state.session.info.capabilities) || !state.session.info.capabilities.includes("rind/goals")) {
115
+ return;
116
+ }
117
+ try {
118
+ const result = await request(methods.goalGet);
119
+ updateGoalState(result?.goal || null);
120
+ } catch {
121
+ // A late refresh is not allowed to invalidate a completed turn.
122
+ }
123
+ }
124
+
125
+ async function restoreSession(sessionId = state.session.info.session_id, options = {}) {
126
+ const targetId = String(sessionId || "").trim();
127
+ if (!targetId) {
128
+ throw new Error("Session ID is required.");
129
+ }
130
+ const switching = options.switchSession === true;
131
+ const switchToken = switching ? ++switchGeneration : 0;
132
+ const update = switching
133
+ ? await request(methods.sessionSwitch, { session_id: targetId })
134
+ : state.session.info;
135
+ if (switching && (switchToken !== switchGeneration || state.runtime.status === "closing")) {
136
+ return false;
137
+ }
138
+ const switchedId = String(update?.session_id || targetId);
139
+ if (switchedId !== targetId) {
140
+ throw new Error("Runtime returned a different session.");
141
+ }
142
+ const replay = await request(methods.sessionReplay, { session_id: switchedId });
143
+ if (switching && (switchToken !== switchGeneration || state.runtime.status === "closing")) {
144
+ return false;
145
+ }
146
+ if (replay?.session_id && String(replay.session_id) !== switchedId) {
147
+ throw new Error("Runtime replay returned a different session.");
148
+ }
149
+
150
+ const liveTurn = replay?.live_turn || update?.live_turn || null;
151
+ const turnState = replay?.turn_state || update?.turn_state || null;
152
+ const usage = update?.usage && typeof update.usage === "object" ? update.usage : {};
153
+ const workspaceRoot = String(update?.workspace_root || options.workspaceRoot || "").trim();
154
+ closeAssistant();
155
+ getTaskMonitor()?.clear();
156
+ clearPendingInputs();
157
+ state.session.info = {
158
+ ...state.session.info,
159
+ session_id: switchedId,
160
+ cwd: workspaceRoot || state.session.info.cwd,
161
+ workspace_root: workspaceRoot || state.session.info.workspace_root,
162
+ model: replay?.model || update?.model || state.session.info.model,
163
+ reasoning_effort: replay?.reasoning_effort || update?.reasoning_effort || currentReasoningEffort(),
164
+ resume_preview: "",
165
+ goal: update?.goal || null,
166
+ live_turn: liveTurn,
167
+ turn_state: turnState,
168
+ usage,
169
+ background_count: 0,
170
+ delegate_count: 0,
171
+ };
172
+ state.turn.id = "";
173
+ state.turn.active = false;
174
+ state.turn.interruptRequested = false;
175
+ options.announce?.(state.session.info);
176
+ renderHistory(replay?.messages);
177
+ restoreLiveTurn(liveTurn);
178
+ state.display.stats = usage;
179
+ getCompactContextState().clear();
180
+ refreshInputState();
181
+ redraw();
182
+ void getTaskMonitor()?.refresh().catch(() => {});
183
+ return true;
184
+ }
185
+
186
+ async function runSessionsSelector() {
187
+ if (state.turn.active || state.display.activeCompact) {
188
+ log("Cannot switch sessions while a turn is running.");
189
+ return;
190
+ }
191
+ let result;
192
+ try {
193
+ result = await request(methods.commandExecute, { input: "/sessions 100" });
194
+ } catch (error) {
195
+ log(`Command failed: ${error instanceof Error ? error.message : String(error)}`);
196
+ return;
197
+ }
198
+ const sessions = Array.isArray(result?.display?.sessions) ? result.display.sessions : [];
199
+ if (!sessions.length) {
200
+ await getCommands().applyResult(result);
201
+ return;
202
+ }
203
+ const currentId = String(result?.display?.current_session_id || state.session.info.session_id || "");
204
+ const options = sessions.map(sessionMenuOption);
205
+ const currentIndex = sessions.findIndex((session) => String(session?.id || "") === currentId);
206
+ const selected = await askSessionMenu(options, sessions, currentIndex);
207
+ const selectedId = String(selected?.id || "");
208
+ if (!selectedId || state.runtime.status === "closing" || selectedId === currentId) {
209
+ return;
210
+ }
211
+ try {
212
+ await restoreSession(selectedId, {
213
+ switchSession: true,
214
+ workspaceRoot: selected?.workspace_root,
215
+ announce: (info) => log(() => sessionSwitchedText(info)),
216
+ });
217
+ } catch (error) {
218
+ log(`Session switch failed: ${error instanceof Error ? error.message : String(error)}`);
219
+ }
220
+ }
221
+
222
+ function startCompactCommand() {
223
+ if (state.display.activeCompact) {
224
+ log("Compact is already running.");
225
+ return;
226
+ }
227
+ state.display.activeCompact = true;
228
+ state.turn.interruptRequested = false;
229
+ refreshInputState();
230
+ void runCompactCommand().catch((error) => {
231
+ if (state.runtime.status !== "closing") {
232
+ writeError(`${error instanceof Error ? error.message : String(error)}\n`);
233
+ }
234
+ });
235
+ }
236
+
237
+ async function runCompactCommand() {
238
+ try {
239
+ const result = await request(methods.commandExecute, { input: "/compact" });
240
+ await getCommands().applyResult(result);
241
+ } finally {
242
+ state.display.activeCompact = false;
243
+ state.turn.interruptRequested = false;
244
+ refreshInputState();
245
+ }
246
+ }
247
+
248
+ async function runModelSelector() {
249
+ let result;
250
+ try {
251
+ result = await request(methods.modelList);
252
+ } catch (error) {
253
+ log(() => modelListErrorText(error instanceof Error ? error.message : String(error), state.session.info.model));
254
+ return;
255
+ }
256
+ const currentModel = result?.current_model || state.session.info.model || result?.default_model || "";
257
+ const selected = await askModelMenu(result?.models || [], currentModel);
258
+ if (!selected || state.runtime.status === "closing") {
259
+ return;
260
+ }
261
+ try {
262
+ const update = await request(methods.modelSet, { model: selected });
263
+ state.session.info = { ...state.session.info, model: update?.model || selected };
264
+ log(() => modelSetResultText(update, selected));
265
+ } catch (error) {
266
+ log(`Command failed: ${error instanceof Error ? error.message : String(error)}`);
267
+ }
268
+ }
269
+
270
+ async function runEffortCommand(value = "") {
271
+ const requested = String(value || "").trim().toLowerCase();
272
+ if (!requested) {
273
+ if (!askEffortMenu) {
274
+ log("Reasoning effort menu requires a TTY. Use /effort <low|medium|high|xhigh|max>.");
275
+ return;
276
+ }
277
+ const selected = await askEffortMenu(currentReasoningEffort());
278
+ if (!selected || state.runtime.status === "closing") {
279
+ return;
280
+ }
281
+ await applyReasoningEffort(selected);
282
+ return;
283
+ }
284
+ if (!REASONING_EFFORTS.includes(requested)) {
285
+ log(`Unknown reasoning effort "${requested}". Available: ${REASONING_EFFORTS.join(", ")}.`);
286
+ return;
287
+ }
288
+ await applyReasoningEffort(requested);
289
+ }
290
+
291
+ function currentReasoningEffort() {
292
+ return String(state.session.info.reasoning_effort || "").toLowerCase();
293
+ }
294
+
295
+ async function applyReasoningEffort(effort) {
296
+ try {
297
+ await request(methods.modelEffortSet, { reasoning_effort: effort });
298
+ state.session.info = { ...state.session.info, reasoning_effort: effort };
299
+ log(() => commandResultText("Reasoning effort updated.", `- session effort: ${effort}`));
300
+ } catch (error) {
301
+ log(`Command failed: ${error instanceof Error ? error.message : String(error)}`);
302
+ }
303
+ }
304
+
305
+ return {
306
+ request,
307
+ ensureRuntime,
308
+ restoreSession,
309
+ runGoalCommand,
310
+ refreshGoalState,
311
+ runSessionsSelector,
312
+ startCompactCommand,
313
+ runModelSelector,
314
+ runEffortCommand,
315
+ };
316
+ }
317
+
318
+ function mergeSlashCommands(...groups) {
319
+ const byName = new Map();
320
+ for (const group of groups) {
321
+ for (const command of group || []) {
322
+ if (command?.name && !byName.has(command.name)) {
323
+ byName.set(command.name, command);
324
+ }
325
+ }
326
+ }
327
+ return [...byName.values()].sort((left, right) => left.name.localeCompare(right.name));
328
+ }
329
+
330
+ function sessionMenuOption(session) {
331
+ const values = [singleLineText(session?.id), singleLineText(session?.title), singleLineText(session?.updated_at)];
332
+ if (session?.current) values.push("current");
333
+ return values.filter(Boolean).join(" · ");
334
+ }
335
+
336
+ function modelSetResultText(result, model) {
337
+ const sessionModel = singleLineText(result?.session_model || result?.model || model);
338
+ const defaultModel = singleLineText(result?.default_model);
339
+ const lines = ["Session model updated."];
340
+ if (sessionModel) lines.push(`- session model: ${sessionModel}`);
341
+ if (defaultModel) lines.push(`- default model: ${defaultModel} (unchanged)`);
342
+ lines.push(result?.active_updated || result?.runtime || result?.session
343
+ ? "- active session: updated"
344
+ : "- active session: unchanged; start a new session to use this model");
345
+ return commandResultText(lines[0], lines.slice(1).join(" · "));
346
+ }
347
+
348
+ function singleLineText(value) {
349
+ return String(value || "").replace(/\s+/g, " ").trim();
350
+ }
@@ -0,0 +1,32 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ // CLI-owned runtime preferences, separate from the shared ~/.rind/settings.json
6
+ // that the Python runtime manages (apiKey/model/baseUrl).
7
+ export function cliStatePath(rindHome = process.env.RIND_HOME || path.join(homedir(), ".rind")) {
8
+ return path.join(String(rindHome), "cli-state.json");
9
+ }
10
+
11
+ export function loadCliState(rindHome) {
12
+ try {
13
+ const parsed = JSON.parse(readFileSync(cliStatePath(rindHome), "utf8"));
14
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ export function saveCliState(patch, rindHome) {
21
+ const file = cliStatePath(rindHome);
22
+ try {
23
+ const next = { ...loadCliState(rindHome), ...patch };
24
+ mkdirSync(path.dirname(file), { recursive: true });
25
+ const temp = `${file}.${process.pid}.tmp`;
26
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
27
+ renameSync(temp, file);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
@@ -0,0 +1,41 @@
1
+ export function createCliState() {
2
+ return {
3
+ runtime: {
4
+ status: "idle",
5
+ initialization: null,
6
+ failure: null,
7
+ },
8
+ session: {
9
+ info: {},
10
+ settings: {},
11
+ commands: [],
12
+ },
13
+ turn: {
14
+ active: false,
15
+ id: "",
16
+ interruptRequested: false,
17
+ },
18
+ input: {
19
+ active: false,
20
+ paused: false,
21
+ prefill: "",
22
+ session: null,
23
+ pending: [],
24
+ retrievingModes: new Set(),
25
+ },
26
+ display: {
27
+ activeCompact: false,
28
+ goalChasing: false,
29
+ stats: {},
30
+ lastEventSequence: 0,
31
+ activityFrame: 0,
32
+ activityTimer: null,
33
+ activityStartedAt: 0,
34
+ assistantOutputLineOpen: false,
35
+ assistantHeaderShown: false,
36
+ outputStarted: false,
37
+ toolDetailsExpanded: false,
38
+ processExitTimer: null,
39
+ },
40
+ };
41
+ }