@cryer/star-cli 0.3.12 → 0.3.14

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 (33) hide show
  1. package/README.md +17 -5
  2. package/README.zh-CN.md +15 -5
  3. package/dist/{chunk-LSYGHLRU.js → chunk-2ISBRSBA.js} +479 -296
  4. package/dist/chunk-2ISBRSBA.js.map +1 -0
  5. package/dist/{chunk-F5RWZMQY.js → chunk-4HANB3DJ.js} +458 -221
  6. package/dist/chunk-4HANB3DJ.js.map +1 -0
  7. package/dist/chunk-4IRY44TG.js +0 -0
  8. package/dist/{chunk-Q5FJN4LN.js → chunk-54KH4JSO.js} +213 -125
  9. package/dist/chunk-54KH4JSO.js.map +1 -0
  10. package/dist/{chunk-MYHD3SZB.js → chunk-CECMCDZG.js} +43 -30
  11. package/dist/chunk-CECMCDZG.js.map +1 -0
  12. package/dist/{chunk-MVAMPVTA.js → chunk-KILMI3S5.js} +13 -5
  13. package/dist/{chunk-MVAMPVTA.js.map → chunk-KILMI3S5.js.map} +1 -1
  14. package/dist/{chunk-2GAUAYCU.js → chunk-NRLGGA7L.js} +507 -175
  15. package/dist/chunk-NRLGGA7L.js.map +1 -0
  16. package/dist/loop-M64D5YII.js +17 -0
  17. package/dist/main.js +177 -131
  18. package/dist/main.js.map +1 -1
  19. package/dist/{provider-EWMUPBLH.js → provider-3Z4B7RMF.js} +2 -2
  20. package/dist/{repl-SSCURTAH.js → repl-N2H7JDSH.js} +617 -327
  21. package/dist/repl-N2H7JDSH.js.map +1 -0
  22. package/dist/{tools-BH55BVIS.js → tools-5RKM7ZI3.js} +3 -3
  23. package/package.json +16 -25
  24. package/dist/chunk-2GAUAYCU.js.map +0 -1
  25. package/dist/chunk-F5RWZMQY.js.map +0 -1
  26. package/dist/chunk-LSYGHLRU.js.map +0 -1
  27. package/dist/chunk-MYHD3SZB.js.map +0 -1
  28. package/dist/chunk-Q5FJN4LN.js.map +0 -1
  29. package/dist/loop-BYXBNFFY.js +0 -17
  30. package/dist/repl-SSCURTAH.js.map +0 -1
  31. /package/dist/{loop-BYXBNFFY.js.map → loop-M64D5YII.js.map} +0 -0
  32. /package/dist/{provider-EWMUPBLH.js.map → provider-3Z4B7RMF.js.map} +0 -0
  33. /package/dist/{tools-BH55BVIS.js.map → tools-5RKM7ZI3.js.map} +0 -0
@@ -5,8 +5,8 @@ import {
5
5
  VERSION,
6
6
  buildDisplayMessages,
7
7
  clearSessions,
8
- computeCostUsd,
9
8
  deleteSession,
9
+ displayWidth,
10
10
  estimateCost,
11
11
  folderIcon,
12
12
  formatByteCount,
@@ -30,8 +30,9 @@ import {
30
30
  summarizeArgs,
31
31
  thinkingIcon,
32
32
  toTerminalSafe,
33
- toolIcon
34
- } from "./chunk-2GAUAYCU.js";
33
+ toolIcon,
34
+ truncateToWidth
35
+ } from "./chunk-NRLGGA7L.js";
35
36
  import {
36
37
  AgentLoop,
37
38
  WORKING_DIFF_MAX_LINES,
@@ -43,23 +44,24 @@ import {
43
44
  compactMessages,
44
45
  discoverSkills,
45
46
  estimateTokens,
46
- getGitSummaryCached,
47
+ getGitSummaryCachedAsync,
47
48
  isAllowedByRules,
48
49
  isDangerousCommand,
49
50
  isGitRepo,
50
51
  summarizeMessages
51
- } from "./chunk-F5RWZMQY.js";
52
+ } from "./chunk-4HANB3DJ.js";
52
53
  import {
54
+ computeCostUsd,
53
55
  contextWindowTokens,
54
56
  coreMessageText,
55
57
  diffTreeNames,
56
58
  reconcileStarMessages,
57
59
  toCoreMessages
58
- } from "./chunk-MYHD3SZB.js";
60
+ } from "./chunk-CECMCDZG.js";
59
61
  import {
60
62
  createModel,
61
63
  reasoningEffortMetadata
62
- } from "./chunk-MVAMPVTA.js";
64
+ } from "./chunk-KILMI3S5.js";
63
65
  import {
64
66
  listModels,
65
67
  resolveModelConfig
@@ -69,7 +71,7 @@ import {
69
71
  formatTaskFinished,
70
72
  formatTaskList,
71
73
  formatTaskStarted
72
- } from "./chunk-Q5FJN4LN.js";
74
+ } from "./chunk-54KH4JSO.js";
73
75
  import {
74
76
  MAX_IMAGE_DIMENSION,
75
77
  SKIP_DIRS,
@@ -98,13 +100,14 @@ import {
98
100
  starHome,
99
101
  upsertEnvContent,
100
102
  userMemoryPath
101
- } from "./chunk-LSYGHLRU.js";
103
+ } from "./chunk-2ISBRSBA.js";
102
104
 
103
105
  // src/cli/repl.tsx
104
106
  import { Box as Box13, Text as Text14, render, useApp } from "ink";
105
- import { useCallback, useEffect as useEffect4, useMemo, useRef as useRef6, useState as useState7 } from "react";
107
+ import { useCallback, useEffect as useEffect4, useMemo, useRef as useRef7, useState as useState7 } from "react";
106
108
 
107
109
  // src/config/save.ts
110
+ import { randomUUID } from "crypto";
108
111
  import fs from "fs";
109
112
  import path from "path";
110
113
  import { parse, stringify } from "smol-toml";
@@ -115,14 +118,34 @@ function tomlString(value) {
115
118
  return JSON.stringify(value);
116
119
  }
117
120
  async function writeFileAtomic(filePath, content) {
118
- const tmpPath = `${filePath}.${process.pid}.tmp`;
119
- await fs.promises.writeFile(tmpPath, content, "utf8");
120
- await fs.promises.rename(tmpPath, filePath);
121
+ const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
121
122
  try {
122
- await fs.promises.chmod(filePath, 384);
123
- } catch {
123
+ const handle = await fs.promises.open(tmpPath, "wx", 384);
124
+ try {
125
+ await handle.writeFile(content, "utf8");
126
+ await handle.chmod(384);
127
+ } finally {
128
+ await handle.close();
129
+ }
130
+ await fs.promises.rename(tmpPath, filePath);
131
+ } catch (error) {
132
+ await fs.promises.unlink(tmpPath).catch(() => {
133
+ });
134
+ throw error;
124
135
  }
125
136
  }
137
+ var configSaveQueues = /* @__PURE__ */ new Map();
138
+ function serializeConfigSave(filePath, save) {
139
+ const previous = configSaveQueues.get(filePath) ?? Promise.resolve();
140
+ const next = previous.catch(() => {
141
+ }).then(save);
142
+ configSaveQueues.set(filePath, next);
143
+ void next.finally(() => {
144
+ if (configSaveQueues.get(filePath) === next) configSaveQueues.delete(filePath);
145
+ }).catch(() => {
146
+ });
147
+ return next;
148
+ }
126
149
  function upsertPermissionRuleText(content, kind, rules, hadPermissionsTable) {
127
150
  const newLine = `${kind} = [${rules.map(tomlString).join(", ")}]`;
128
151
  const lines = content.split("\n");
@@ -161,28 +184,35 @@ ${newLine}
161
184
  }
162
185
  async function addPermissionRule(kind, rule) {
163
186
  const filePath = globalConfigPath();
164
- let content = "";
165
- let raw = {};
166
- try {
167
- content = await fs.promises.readFile(filePath, "utf8");
168
- raw = parse(content);
169
- } catch (error) {
170
- if (error.code !== "ENOENT") throw error;
171
- }
172
- const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
173
- const rules = Array.isArray(permissions[kind]) ? permissions[kind] : [];
174
- if (rules.includes(rule)) return false;
175
- const nextRules = [...rules, rule];
176
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
177
- const updated = upsertPermissionRuleText(content, kind, nextRules, raw.permissions !== void 0);
178
- if (updated !== null) {
179
- await writeFileAtomic(filePath, updated);
187
+ return serializeConfigSave(filePath, async () => {
188
+ let content = "";
189
+ let raw = {};
190
+ try {
191
+ content = await fs.promises.readFile(filePath, "utf8");
192
+ raw = parse(content);
193
+ } catch (error) {
194
+ if (error.code !== "ENOENT") throw error;
195
+ }
196
+ const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
197
+ const rules = Array.isArray(permissions[kind]) ? permissions[kind] : [];
198
+ if (rules.includes(rule)) return false;
199
+ const nextRules = [...rules, rule];
200
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
201
+ const updated = upsertPermissionRuleText(
202
+ content,
203
+ kind,
204
+ nextRules,
205
+ raw.permissions !== void 0
206
+ );
207
+ if (updated !== null) {
208
+ await writeFileAtomic(filePath, updated);
209
+ return true;
210
+ }
211
+ permissions[kind] = nextRules;
212
+ raw.permissions = permissions;
213
+ await writeFileAtomic(filePath, stringify(raw));
180
214
  return true;
181
- }
182
- permissions[kind] = nextRules;
183
- raw.permissions = permissions;
184
- await writeFileAtomic(filePath, stringify(raw));
185
- return true;
215
+ });
186
216
  }
187
217
  function upsertTopLevelKeyText(content, key, value) {
188
218
  const newLine = `${key} = ${tomlString(value)}`;
@@ -205,14 +235,16 @@ function upsertTopLevelKeyText(content, key, value) {
205
235
  }
206
236
  async function savePermissionMode(mode) {
207
237
  const filePath = globalConfigPath();
208
- let content = "";
209
- try {
210
- content = await fs.promises.readFile(filePath, "utf8");
211
- } catch (error) {
212
- if (error.code !== "ENOENT") throw error;
213
- }
214
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
215
- await writeFileAtomic(filePath, upsertTopLevelKeyText(content, "permissionMode", mode));
238
+ return serializeConfigSave(filePath, async () => {
239
+ let content = "";
240
+ try {
241
+ content = await fs.promises.readFile(filePath, "utf8");
242
+ } catch (error) {
243
+ if (error.code !== "ENOENT") throw error;
244
+ }
245
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
246
+ await writeFileAtomic(filePath, upsertTopLevelKeyText(content, "permissionMode", mode));
247
+ });
216
248
  }
217
249
  function unquoteTomlString(raw) {
218
250
  if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
@@ -271,29 +303,35 @@ function setReasoningEffortInToml(content, modelName, effort) {
271
303
  }
272
304
  async function saveReasoningEffort(modelName, effort) {
273
305
  const filePath = globalConfigPath();
274
- let content = "";
275
- try {
276
- content = await fs.promises.readFile(filePath, "utf8");
277
- } catch (error) {
278
- if (error.code !== "ENOENT") throw error;
279
- }
280
- const updated = setReasoningEffortInToml(content, modelName, effort);
281
- if (updated === null) return false;
282
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
283
- await writeFileAtomic(filePath, updated);
284
- return true;
306
+ return serializeConfigSave(filePath, async () => {
307
+ let content = "";
308
+ try {
309
+ content = await fs.promises.readFile(filePath, "utf8");
310
+ } catch (error) {
311
+ if (error.code !== "ENOENT") throw error;
312
+ }
313
+ const updated = setReasoningEffortInToml(content, modelName, effort);
314
+ if (updated === null) return false;
315
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
316
+ await writeFileAtomic(filePath, updated);
317
+ return true;
318
+ });
285
319
  }
286
320
 
287
321
  // src/session/resume.ts
288
322
  async function resumeSession(id) {
289
323
  const store = await SessionStore.open(id);
290
324
  if (!store) return null;
291
- const [meta, loaded] = await Promise.all([store.meta(), store.starMessages()]);
292
- const messages = reconcileStarMessages(loaded);
293
- if (messages.length !== loaded.length) {
294
- await store.replaceMessages(messages);
325
+ try {
326
+ const [meta, loaded] = await Promise.all([store.meta(), store.starMessages()]);
327
+ const messages = reconcileStarMessages(loaded);
328
+ if (messages.length !== loaded.length && !await store.replaceMessages(messages)) {
329
+ throw new Error(`Could not repair persisted history for session ${id}`);
330
+ }
331
+ return { meta, messages };
332
+ } finally {
333
+ await store.close();
295
334
  }
296
- return { meta, messages };
297
335
  }
298
336
 
299
337
  // src/session/usage.ts
@@ -357,25 +395,29 @@ function aggregateUsage(metas) {
357
395
  hasLegacy = true;
358
396
  }
359
397
  }
360
- const name = typeof meta.model === "string" && meta.model.trim() ? meta.model : "unknown";
361
- const entry = models.get(name) ?? {
362
- model: name,
363
- sessions: 0,
364
- requests: 0,
365
- promptTokens: 0,
366
- completionTokens: 0,
367
- totalTokens: 0
368
- };
369
- entry.sessions += 1;
370
- entry.requests += requestCount;
371
- addTo(entry, totals);
372
- if (hasTotals && meta.usage) {
373
- const cached = tokens(meta.usage.cachedPromptTokens);
374
- const cacheRead = tokens(meta.usage.cacheReadInputTokens);
398
+ const fallbackName = typeof meta.model === "string" && meta.model.trim() ? meta.model : "unknown";
399
+ const perModel = meta.usageByModel !== null && typeof meta.usageByModel === "object";
400
+ const modelTotals = perModel ? Object.entries(meta.usageByModel ?? {}) : [[fallbackName, hasTotals ? meta.usage : void 0]];
401
+ for (const [name, modelUsage] of modelTotals) {
402
+ if (!modelUsage) continue;
403
+ const modelBucket = bucketOf(modelUsage);
404
+ const entry = models.get(name) ?? {
405
+ model: name,
406
+ sessions: 0,
407
+ requests: 0,
408
+ promptTokens: 0,
409
+ completionTokens: 0,
410
+ totalTokens: 0
411
+ };
412
+ entry.sessions += 1;
413
+ entry.requests += perModel ? tokens(modelUsage.requests) : requestCount;
414
+ addTo(entry, modelBucket);
415
+ const cached = tokens(modelUsage.cachedPromptTokens);
416
+ const cacheRead = tokens(modelUsage.cacheReadInputTokens);
375
417
  if (cached > 0) entry.cachedPromptTokens = (entry.cachedPromptTokens ?? 0) + cached;
376
418
  if (cacheRead > 0) entry.cacheReadInputTokens = (entry.cacheReadInputTokens ?? 0) + cacheRead;
419
+ models.set(name, entry);
377
420
  }
378
- models.set(name, entry);
379
421
  }
380
422
  return {
381
423
  sessions: metas.length,
@@ -487,7 +529,7 @@ function cacheHitPercent(totals) {
487
529
 
488
530
  // src/cli/clipboard.ts
489
531
  import { execFile } from "child_process";
490
- import { randomUUID } from "crypto";
532
+ import { randomUUID as randomUUID2 } from "crypto";
491
533
  import { readFile, stat, unlink } from "fs/promises";
492
534
  import os from "os";
493
535
  import path2 from "path";
@@ -575,7 +617,7 @@ async function readClipboardTempFile(tempFile) {
575
617
  return { path: "clipboard.png", mimeType: "image/png", data: buf.toString("base64") };
576
618
  }
577
619
  async function readClipboardImage() {
578
- const tempFile = path2.join(os.tmpdir(), `star-clipboard-${randomUUID()}.png`);
620
+ const tempFile = path2.join(os.tmpdir(), `star-clipboard-${randomUUID2()}.png`);
579
621
  const hasPngpaste = process.platform === "darwin" ? await commandExists("pngpaste") : false;
580
622
  const plan = clipboardImagePlan(process.platform, tempFile, hasPngpaste);
581
623
  if (!plan) return null;
@@ -927,13 +969,17 @@ function formatSearchResults(hits, query) {
927
969
  }
928
970
 
929
971
  // src/cli/diff-preview.ts
930
- import { readFile as readFile2 } from "fs/promises";
972
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
931
973
  import path5 from "path";
932
974
  var CONTEXT_LINES = 2;
933
975
  var MAX_HUNK_LINES = 15;
934
976
  var MAX_TOTAL_LINES = 12;
935
977
  var NEW_FILE_PREVIEW_LINES = 10;
936
978
  var MAX_DP_CELLS = 25e4;
979
+ var MAX_DIFF_PREVIEW_CHARS = 1e6;
980
+ function largeDiffSummary() {
981
+ return [{ kind: "marker", text: "... (large diff omitted; preview work budget exceeded)" }];
982
+ }
937
983
  function diffOps(oldText, newText) {
938
984
  const a = oldText.split("\n");
939
985
  const b = newText.split("\n");
@@ -1019,6 +1065,8 @@ function hunkRanges(ops) {
1019
1065
  return ranges;
1020
1066
  }
1021
1067
  function buildDiffLines(oldText, newText) {
1068
+ if (oldText === newText) return [];
1069
+ if (oldText.length + newText.length > MAX_DIFF_PREVIEW_CHARS) return largeDiffSummary();
1022
1070
  const ops = diffOps(oldText, newText);
1023
1071
  if (ops.length === 0) return [];
1024
1072
  const ranges = hunkRanges(ops);
@@ -1077,6 +1125,9 @@ function applyEdit(content, oldString, newString, replaceAll) {
1077
1125
  return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
1078
1126
  }
1079
1127
  function newFilePreview(content, label) {
1128
+ if (content.length > MAX_DIFF_PREVIEW_CHARS) {
1129
+ return { type: "new-file", label, lines: largeDiffSummary() };
1130
+ }
1080
1131
  const all = content.split("\n");
1081
1132
  const shown = all.slice(0, NEW_FILE_PREVIEW_LINES);
1082
1133
  const lines = shown.map((text) => ({ kind: "add", text }));
@@ -1090,6 +1141,10 @@ async function generateDiffPreview(toolName, args, cwd) {
1090
1141
  const filePath = getStringArg(args, "path");
1091
1142
  if (filePath === void 0) return null;
1092
1143
  const absolute = path5.resolve(cwd, filePath);
1144
+ const metadata = await stat2(absolute).catch(() => null);
1145
+ if (metadata?.isFile() && metadata.size > MAX_DIFF_PREVIEW_CHARS) {
1146
+ return { type: "diff", label: filePath, lines: largeDiffSummary() };
1147
+ }
1093
1148
  let existing;
1094
1149
  try {
1095
1150
  existing = await readFile2(absolute, "utf8");
@@ -2686,7 +2741,7 @@ function SelectPrompt({ title, options, onSelect, onCancel, onDelete }) {
2686
2741
  const above = start;
2687
2742
  const below = options.length - start - visible.length;
2688
2743
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
2689
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: title }),
2744
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: toTerminalSafe(title) }),
2690
2745
  above > 0 && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2691
2746
  "\u2026 ",
2692
2747
  above,
@@ -2697,16 +2752,16 @@ function SelectPrompt({ title, options, onSelect, onCancel, onDelete }) {
2697
2752
  return /* @__PURE__ */ jsxs(Text, { children: [
2698
2753
  /* @__PURE__ */ jsxs(Text, { bold: active, inverse: active, children: [
2699
2754
  active ? "\u276F " : " ",
2700
- option.label
2755
+ toTerminalSafe(option.label)
2701
2756
  ] }),
2702
2757
  option.hint && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2703
2758
  " (",
2704
- option.hint,
2759
+ toTerminalSafe(option.hint),
2705
2760
  ")"
2706
2761
  ] }),
2707
2762
  option.description && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2708
2763
  " \u2014 ",
2709
- option.description
2764
+ toTerminalSafe(option.description)
2710
2765
  ] })
2711
2766
  ] }, option.value);
2712
2767
  }),
@@ -2717,7 +2772,7 @@ function SelectPrompt({ title, options, onSelect, onCancel, onDelete }) {
2717
2772
  ] }),
2718
2773
  confirming ? /* @__PURE__ */ jsxs(Text, { color: "red", children: [
2719
2774
  "Delete ",
2720
- confirming.label,
2775
+ toTerminalSafe(confirming.label),
2721
2776
  "? [y] yes [n] no"
2722
2777
  ] }) : /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2723
2778
  "[\u2191/\u2193 or j/k] move [Enter] select",
@@ -2739,6 +2794,9 @@ import { memo, useEffect as useEffect2, useRef as useRef3, useState as useState2
2739
2794
  import { readdir } from "fs/promises";
2740
2795
  import path12 from "path";
2741
2796
  var MAX_SCAN_ENTRIES = 2e3;
2797
+ var PATH_SUGGEST_DEBOUNCE_MS = 100;
2798
+ var PATH_SUGGEST_CACHE_MS = 2e3;
2799
+ var PATH_SUGGEST_CACHE_LIMIT = 100;
2742
2800
  function extractAtToken(value, cursor) {
2743
2801
  const before = value.slice(0, cursor);
2744
2802
  const match = /(?:^|\s)@(\S*)$/.exec(before);
@@ -2773,24 +2831,67 @@ async function suggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSu
2773
2831
  matches.sort((a, b) => a.path.localeCompare(b.path));
2774
2832
  return matches.slice(0, maxSuggestions);
2775
2833
  }
2834
+ var suggestionCache = /* @__PURE__ */ new Map();
2835
+ function cachedSuggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSuggestions = MAX_SUGGESTIONS) {
2836
+ const key = `${path12.resolve(cwd)}\0${token}\0${maxScanEntries}\0${maxSuggestions}`;
2837
+ const now = Date.now();
2838
+ const cached = suggestionCache.get(key);
2839
+ if (cached && now - cached.createdAt <= PATH_SUGGEST_CACHE_MS) {
2840
+ suggestionCache.delete(key);
2841
+ suggestionCache.set(key, cached);
2842
+ return cached.pending;
2843
+ }
2844
+ const pending = Promise.resolve().then(
2845
+ () => suggestPaths(token, cwd, maxScanEntries, maxSuggestions)
2846
+ );
2847
+ suggestionCache.delete(key);
2848
+ suggestionCache.set(key, { createdAt: now, pending });
2849
+ if (suggestionCache.size > PATH_SUGGEST_CACHE_LIMIT) {
2850
+ const oldest = suggestionCache.keys().next();
2851
+ if (!oldest.done) suggestionCache.delete(oldest.value);
2852
+ }
2853
+ return pending;
2854
+ }
2776
2855
 
2777
2856
  // src/cli/components/InputBox.tsx
2778
2857
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2858
+ var graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2859
+ function previousGraphemeBoundary(value, cursor) {
2860
+ let previous = 0;
2861
+ for (const { index } of graphemeSegmenter.segment(value.slice(0, cursor))) previous = index;
2862
+ return previous;
2863
+ }
2864
+ function nextGraphemeBoundary(value, cursor) {
2865
+ const next = graphemeSegmenter.segment(value.slice(cursor))[Symbol.iterator]().next();
2866
+ return next.done ? value.length : cursor + next.value.segment.length;
2867
+ }
2868
+ function graphemeColumn(value, start, end) {
2869
+ return Array.from(graphemeSegmenter.segment(value.slice(start, end))).length;
2870
+ }
2871
+ function cursorAtGraphemeColumn(value, start, end, column) {
2872
+ let count = 0;
2873
+ for (const { index, segment } of graphemeSegmenter.segment(value.slice(start, end))) {
2874
+ if (count === column) return start + index;
2875
+ count += 1;
2876
+ if (count > column) return start + index + segment.length;
2877
+ }
2878
+ return end;
2879
+ }
2779
2880
  function moveCursorLines(value, cursor, delta) {
2780
2881
  const lineStart = value.lastIndexOf("\n", cursor - 1) + 1;
2781
- const col = cursor - lineStart;
2882
+ const col = graphemeColumn(value, lineStart, cursor);
2782
2883
  if (delta === -1) {
2783
2884
  if (lineStart === 0) return null;
2784
2885
  const prevEnd = lineStart - 1;
2785
2886
  const prevStart = value.lastIndexOf("\n", prevEnd - 1) + 1;
2786
- return prevStart + Math.min(col, prevEnd - prevStart);
2887
+ return cursorAtGraphemeColumn(value, prevStart, prevEnd, col);
2787
2888
  }
2788
2889
  const lineEndIndex = value.indexOf("\n", cursor);
2789
2890
  if (lineEndIndex === -1) return null;
2790
2891
  const nextStart = lineEndIndex + 1;
2791
2892
  const nextEndIndex = value.indexOf("\n", nextStart);
2792
2893
  const nextEnd = nextEndIndex === -1 ? value.length : nextEndIndex;
2793
- return nextStart + Math.min(col, nextEnd - nextStart);
2894
+ return cursorAtGraphemeColumn(value, nextStart, nextEnd, col);
2794
2895
  }
2795
2896
  function findHistoryMatch(history, query, from, step) {
2796
2897
  const needle = query.toLowerCase();
@@ -3000,21 +3101,20 @@ var InputBox = memo(function InputBox2({
3000
3101
  const suggestions = suggestionsEnabled && commands ? filterCommands(value, commands) : [];
3001
3102
  const argHints = suggestionsEnabled && commands && cursor === value.length ? filterArgHints(value, commands) : [];
3002
3103
  useEffect2(() => {
3003
- if (!suggestionsEnabled || !cwd) {
3004
- setPathSuggestions([]);
3005
- return;
3006
- }
3007
- const token = extractAtToken(value, cursor);
3008
- if (!token) {
3009
- setPathSuggestions([]);
3010
- return;
3011
- }
3012
3104
  let cancelled = false;
3013
- void suggestPaths(token.token, cwd).then((result) => {
3014
- if (!cancelled) setPathSuggestions(result);
3015
- });
3105
+ setPathSuggestions([]);
3106
+ const token = suggestionsEnabled && cwd ? extractAtToken(value, cursor) : null;
3107
+ if (!token || !cwd) return;
3108
+ const timer = setTimeout(() => {
3109
+ void cachedSuggestPaths(token.token, cwd).then((result) => {
3110
+ if (!cancelled) setPathSuggestions(result);
3111
+ }).catch(() => {
3112
+ if (!cancelled) setPathSuggestions([]);
3113
+ });
3114
+ }, PATH_SUGGEST_DEBOUNCE_MS);
3016
3115
  return () => {
3017
3116
  cancelled = true;
3117
+ clearTimeout(timer);
3018
3118
  };
3019
3119
  }, [value, cursor, cwd, suggestionsEnabled]);
3020
3120
  const showPathSuggestions = suggestions.length === 0 && argHints.length === 0 && pathSuggestions.length > 0;
@@ -3097,7 +3197,7 @@ var InputBox = memo(function InputBox2({
3097
3197
  }
3098
3198
  const requery = (query) => setSearch({ query, match: findHistoryMatch(history, query, history.length - 1, -1) });
3099
3199
  if (key.backspace || key.delete) {
3100
- requery(search.query.slice(0, -1));
3200
+ requery(search.query.slice(0, previousGraphemeBoundary(search.query, search.query.length)));
3101
3201
  return;
3102
3202
  }
3103
3203
  if (key.upArrow) {
@@ -3223,13 +3323,13 @@ ${value.slice(cursor)}`, cursor + 1);
3223
3323
  if (moved !== null) setCursor(moved);
3224
3324
  return;
3225
3325
  } else if (key.leftArrow) {
3226
- setCursor((prev) => Math.max(0, prev - 1));
3326
+ setCursor((prev) => previousGraphemeBoundary(value, prev));
3227
3327
  return;
3228
3328
  } else if (key.rightArrow) {
3229
3329
  if (suggestionCount > 0 && cursor === value.length) {
3230
3330
  completeHighlighted();
3231
3331
  } else {
3232
- setCursor((prev) => Math.min(value.length, prev + 1));
3332
+ setCursor((prev) => nextGraphemeBoundary(value, prev));
3233
3333
  }
3234
3334
  return;
3235
3335
  }
@@ -3258,17 +3358,17 @@ ${value.slice(cursor)}`, cursor + 1);
3258
3358
  }
3259
3359
  if (key.backspace || key.delete) {
3260
3360
  if (cursor === 0) return;
3261
- let remove = 1;
3361
+ let removeStart = previousGraphemeBoundary(value, cursor);
3262
3362
  for (const match of value.matchAll(PASTE_TOKEN_RE)) {
3263
3363
  if (match.index + match[0].length === cursor) {
3264
- remove = match[0].length;
3364
+ removeStart = match.index;
3265
3365
  const id = Number(match[1]);
3266
3366
  pastesRef.current.delete(id);
3267
3367
  if (lastPasteRef.current?.id === id) lastPasteRef.current = null;
3268
3368
  break;
3269
3369
  }
3270
3370
  }
3271
- edit(value.slice(0, cursor - remove) + value.slice(cursor), cursor - remove);
3371
+ edit(value.slice(0, removeStart) + value.slice(cursor), removeStart);
3272
3372
  return;
3273
3373
  }
3274
3374
  if (input && !key.ctrl && !key.meta) {
@@ -3276,9 +3376,10 @@ ${value.slice(cursor)}`, cursor + 1);
3276
3376
  }
3277
3377
  });
3278
3378
  const before = value.slice(0, cursor);
3279
- const at = value[cursor];
3280
- const after = value.slice(cursor + 1);
3281
- const cursorChar = at === "\n" || at === void 0 || at === PASTE_DELIM ? " " : at;
3379
+ const nextBoundary = nextGraphemeBoundary(value, cursor);
3380
+ const at = value.slice(cursor, nextBoundary);
3381
+ const after = value.slice(nextBoundary);
3382
+ const cursorChar = at === "\n" || at === "" || at === PASTE_DELIM ? " " : at;
3282
3383
  const tail = (at === "\n" ? "\n" : "") + stylePasteTokens(after);
3283
3384
  const styled = search ? `\x1B[36m(reverse-search) \x1B[39m'${search.query}': ${search.match === null ? "\x1B[2mno match\x1B[22m\x1B[7m \x1B[27m" : `${history[search.match]}\x1B[7m \x1B[27m`}` : `\x1B[36m\u276F \x1B[39m${stylePasteTokens(before)}\x1B[7m${cursorChar}\x1B[27m${tail}`;
3284
3385
  const menuStart = Math.min(
@@ -3800,14 +3901,43 @@ function RewindConfirmPrompt({
3800
3901
  // src/cli/components/StatusBar.tsx
3801
3902
  import { Box as Box9, Text as Text10, useStdout as useStdout3 } from "ink";
3802
3903
  import { memo as memo3 } from "react";
3803
- import { jsxs as jsxs10 } from "react/jsx-runtime";
3904
+ import { jsx as jsx10 } from "react/jsx-runtime";
3804
3905
  var BG_LABEL_MAX = 40;
3906
+ var BG_PER_LABEL_MAX = 24;
3805
3907
  var WIDE_COLUMNS = 120;
3806
3908
  function cwdLabel(cwd, wide) {
3807
3909
  if (wide) return cwd;
3808
3910
  const base = cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop();
3809
3911
  return base && base.length > 0 ? base : cwd;
3810
3912
  }
3913
+ function fitStatusSegments(segments, columns) {
3914
+ const clean = segments.filter((s) => s != null).map((s) => toTerminalSafe(s).replaceAll("\n", " "));
3915
+ if (clean.length === 0) return [];
3916
+ const available = Math.max(0, columns - clean.length + 1);
3917
+ const widths = clean.map(displayWidth);
3918
+ const budgets = clean.map(() => 0);
3919
+ let active = clean.map((_, index) => index);
3920
+ let remaining = available;
3921
+ while (remaining > 0 && active.length > 0) {
3922
+ const share = Math.floor(remaining / active.length);
3923
+ if (share === 0) {
3924
+ for (const index of active.slice(0, remaining)) budgets[index] = (budgets[index] ?? 0) + 1;
3925
+ break;
3926
+ }
3927
+ let used = 0;
3928
+ const next = [];
3929
+ for (const index of active) {
3930
+ const grant = Math.min((widths[index] ?? 0) - (budgets[index] ?? 0), share);
3931
+ budgets[index] = (budgets[index] ?? 0) + grant;
3932
+ used += grant;
3933
+ if ((budgets[index] ?? 0) < (widths[index] ?? 0)) next.push(index);
3934
+ }
3935
+ if (used === 0) break;
3936
+ remaining -= used;
3937
+ active = next;
3938
+ }
3939
+ return clean.map((segment, index) => truncateToWidth(segment, budgets[index] ?? 0));
3940
+ }
3811
3941
  var StatusBar = memo3(function StatusBar2({
3812
3942
  cwd,
3813
3943
  model,
@@ -3821,72 +3951,69 @@ var StatusBar = memo3(function StatusBar2({
3821
3951
  backgroundTasks = []
3822
3952
  }) {
3823
3953
  const { stdout } = useStdout3();
3824
- const wide = (stdout?.columns ?? 80) >= WIDE_COLUMNS;
3825
- const joined = backgroundTasks.map((label) => toTerminalSafe(label)).join(", ");
3826
- const labels = joined.length > BG_LABEL_MAX ? `${joined.slice(0, BG_LABEL_MAX)}\u2026` : joined;
3827
- return /* @__PURE__ */ jsxs10(Box9, { justifyContent: "space-between", children: [
3828
- /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3829
- folderIcon,
3830
- cwdLabel(cwd, wide),
3831
- gitBranch ? ` [${gitBranch}]` : ""
3832
- ] }),
3833
- backgroundTasks.length > 0 && /* @__PURE__ */ jsxs10(Text10, { color: "yellow", children: [
3834
- "bg: ",
3835
- backgroundTasks.length,
3836
- " (",
3837
- labels,
3838
- ")"
3839
- ] }),
3840
- /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3841
- "\u2726 ",
3842
- model
3843
- ] }),
3844
- contextPercent != null && /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3845
- "ctx: ",
3846
- contextPercent,
3847
- "%",
3848
- contextTokens ? ` (${formatTokensCompact(contextTokens.used)}/${formatTokensCompact(contextTokens.window)})` : ""
3849
- ] }),
3850
- /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3851
- "cache: ",
3852
- cachePercent != null ? `${cachePercent}%` : "Not provided"
3853
- ] }),
3854
- sessionCostUsd != null && sessionCostUsd > 0 && /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3855
- "$",
3856
- formatDollars(sessionCostUsd)
3857
- ] }),
3858
- /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3859
- formatTokens(tokens2),
3860
- " tokens"
3861
- ] }),
3862
- /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3863
- "mode: ",
3864
- permissionMode
3865
- ] })
3866
- ] });
3954
+ const columns = stdout?.columns ?? 80;
3955
+ const wide = columns >= WIDE_COLUMNS;
3956
+ const cwdSegment = `${folderIcon}${cwdLabel(cwd, wide)}${gitBranch ? ` [${gitBranch}]` : ""}`;
3957
+ const modelSegment = `\u2726 ${model}`;
3958
+ const ctxSegment = contextPercent != null ? `ctx: ${contextPercent}%${contextTokens ? ` (${formatTokensCompact(contextTokens.used)}/${formatTokensCompact(contextTokens.window)})` : ""}` : null;
3959
+ const cacheSegment = `cache: ${cachePercent != null ? `${cachePercent}%` : "Not provided"}`;
3960
+ const costSegment = sessionCostUsd != null && sessionCostUsd > 0 ? `$${formatDollars(sessionCostUsd)}` : null;
3961
+ const tokensSegment = `${formatTokens(tokens2)} tokens`;
3962
+ const modeSegment = `mode: ${permissionMode}`;
3963
+ let bgSegment = null;
3964
+ if (backgroundTasks.length > 0) {
3965
+ const joined = backgroundTasks.map((label) => truncateToWidth(toTerminalSafe(label), BG_PER_LABEL_MAX)).join(", ");
3966
+ bgSegment = `bg: ${backgroundTasks.length} (${truncateToWidth(joined, BG_LABEL_MAX)})`;
3967
+ }
3968
+ const raw = [
3969
+ cwdSegment,
3970
+ bgSegment,
3971
+ modelSegment,
3972
+ ctxSegment,
3973
+ cacheSegment,
3974
+ costSegment,
3975
+ tokensSegment,
3976
+ modeSegment
3977
+ ];
3978
+ const keys = ["cwd", "bg", "model", "ctx", "cache", "cost", "tokens", "mode"].filter(
3979
+ (_, index) => raw[index] != null
3980
+ );
3981
+ const fitted = fitStatusSegments(raw, columns);
3982
+ return /* @__PURE__ */ jsx10(Box9, { justifyContent: "space-between", children: fitted.map((segment, index) => /* @__PURE__ */ jsx10(
3983
+ Text10,
3984
+ {
3985
+ color: keys[index] === "bg" ? "yellow" : void 0,
3986
+ dimColor: keys[index] !== "bg",
3987
+ children: segment
3988
+ },
3989
+ keys[index]
3990
+ )) });
3867
3991
  });
3868
3992
 
3869
3993
  // src/cli/components/StreamingMessage.tsx
3870
3994
  import { Box as Box10, Text as Text11, useStdout as useStdout4 } from "ink";
3871
3995
  import { memo as memo4 } from "react";
3872
- import { jsx as jsx10, jsxs as jsxs11 } from "react/jsx-runtime";
3996
+ import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
3873
3997
  var StreamingMessage = memo4(function StreamingMessage2({
3874
3998
  text,
3875
3999
  continuation
3876
4000
  }) {
3877
4001
  const { stdout } = useStdout4();
3878
- return /* @__PURE__ */ jsxs11(Box10, { flexDirection: "column", marginBottom: 1, children: [
3879
- !continuation && /* @__PURE__ */ jsx10(Text11, { bold: true, color: "green", children: "\u2726 star" }),
3880
- /* @__PURE__ */ jsx10(Text11, { color: "green", children: renderMarkdown(text, "32", stdout.columns ?? 80) })
4002
+ return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", marginBottom: 1, children: [
4003
+ !continuation && /* @__PURE__ */ jsx11(Text11, { bold: true, color: "green", children: "\u2726 star" }),
4004
+ /* @__PURE__ */ jsx11(Text11, { color: "green", children: renderMarkdown(text, "32", stdout.columns ?? 80) })
3881
4005
  ] });
3882
4006
  });
3883
4007
 
3884
4008
  // src/cli/components/ThinkingIndicator.tsx
3885
4009
  import { Box as Box11, Text as Text12 } from "ink";
3886
- import { memo as memo5, useEffect as useEffect3, useState as useState6 } from "react";
4010
+ import { memo as memo5, useEffect as useEffect3, useRef as useRef6, useState as useState6 } from "react";
3887
4011
 
3888
4012
  // src/cli/ticker.ts
3889
4013
  var TICK_MS = 100;
4014
+ function isMotionDisabled(env = process.env) {
4015
+ return env.STAR_NO_MOTION === "1";
4016
+ }
3890
4017
  function nextFlush(prev, next) {
3891
4018
  if (prev.streamed === next.streamed && prev.reasoning === next.reasoning) return null;
3892
4019
  return next;
@@ -3901,22 +4028,58 @@ function startTicker(onTick, intervalMs = TICK_MS) {
3901
4028
  }
3902
4029
 
3903
4030
  // src/cli/components/ThinkingIndicator.tsx
3904
- import { jsxs as jsxs12 } from "react/jsx-runtime";
4031
+ import { jsxs as jsxs11 } from "react/jsx-runtime";
3905
4032
  var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
3906
4033
  var REASONING_TAIL_LENGTH = 200;
3907
4034
  var THOUGHT_SUMMARY_LENGTH = 100;
3908
- function truncateTail(text, max) {
3909
- const flat = text.replace(/\s+/g, " ").trim();
3910
- if (flat.length <= max) return flat;
3911
- return `\u2026${flat.slice(flat.length - max)}`;
3912
- }
3913
- function roughReasoningTokens(text) {
3914
- let weight = 0;
3915
- for (let i = 0; i < text.length; i++) {
3916
- const code = text.charCodeAt(i);
3917
- weight += code >= 12288 && code <= 12543 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 65280 && code <= 65519 ? 4 : 1;
4035
+ function createReasoningDisplayCache(max = REASONING_TAIL_LENGTH) {
4036
+ return {
4037
+ max,
4038
+ text: "",
4039
+ weight: 0,
4040
+ flatLength: 0,
4041
+ flatTail: "",
4042
+ hasContent: false,
4043
+ pendingSpace: false
4044
+ };
4045
+ }
4046
+ function appendFlat(cache, text) {
4047
+ cache.flatLength += 1;
4048
+ cache.flatTail = `${cache.flatTail}${text}`.slice(-cache.max);
4049
+ }
4050
+ function updateReasoningDisplay(cache, text) {
4051
+ if (text !== cache.text) {
4052
+ if (!text.startsWith(cache.text)) {
4053
+ cache.text = "";
4054
+ cache.weight = 0;
4055
+ cache.flatLength = 0;
4056
+ cache.flatTail = "";
4057
+ cache.hasContent = false;
4058
+ cache.pendingSpace = false;
4059
+ }
4060
+ const appended = text.slice(cache.text.length);
4061
+ for (let i = 0; i < appended.length; i++) {
4062
+ const ch = appended[i] ?? "";
4063
+ const code = ch.charCodeAt(0);
4064
+ cache.weight += code >= 12288 && code <= 12543 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 65280 && code <= 65519 ? 4 : 1;
4065
+ if (/\s/.test(ch)) {
4066
+ cache.pendingSpace = cache.hasContent;
4067
+ } else {
4068
+ if (cache.pendingSpace) appendFlat(cache, " ");
4069
+ appendFlat(cache, ch);
4070
+ cache.hasContent = true;
4071
+ cache.pendingSpace = false;
4072
+ }
4073
+ }
4074
+ cache.text = text;
3918
4075
  }
3919
- return Math.ceil(weight / 4);
4076
+ return {
4077
+ tail: cache.flatLength <= cache.max ? cache.flatTail : `\u2026${cache.flatTail}`,
4078
+ thoughtTokens: Math.ceil(cache.weight / 4)
4079
+ };
4080
+ }
4081
+ function truncateTail(text, max) {
4082
+ return updateReasoningDisplay(createReasoningDisplayCache(max), text).tail;
3920
4083
  }
3921
4084
  var ThinkingIndicator = memo5(function ThinkingIndicator2({
3922
4085
  reasoning,
@@ -3925,26 +4088,30 @@ var ThinkingIndicator = memo5(function ThinkingIndicator2({
3925
4088
  startedAt
3926
4089
  }) {
3927
4090
  const [tick, setTick] = useState6(0);
3928
- useEffect3(() => startTicker((t) => setTick(t)), []);
3929
- const tail = reasoning ? truncateTail(reasoning, REASONING_TAIL_LENGTH) : "";
4091
+ const noMotion = isMotionDisabled();
4092
+ useEffect3(() => {
4093
+ if (noMotion) return;
4094
+ return startTicker((t) => setTick(t));
4095
+ }, [noMotion]);
4096
+ const cacheRef = useRef6(createReasoningDisplayCache());
4097
+ const display = updateReasoningDisplay(cacheRef.current, reasoning ?? "");
3930
4098
  const spinner = SPINNER_FRAMES[(frame ?? tick) % SPINNER_FRAMES.length];
3931
4099
  const label = activity ?? `${thinkingIcon} star is thinking\u2026`;
3932
4100
  const elapsedSec = startedAt === void 0 ? void 0 : Math.max(0, Math.floor((Date.now() - startedAt) / 1e3));
3933
4101
  const stats = [];
3934
4102
  if (elapsedSec !== void 0 && elapsedSec >= 3) stats.push(formatElapsedSeconds(elapsedSec));
3935
- const thoughtTokens = reasoning ? roughReasoningTokens(reasoning) : 0;
3936
- if (thoughtTokens > 0) stats.push(`~${formatTokens(thoughtTokens)} thought`);
4103
+ if (display.thoughtTokens > 0) stats.push(`~${formatTokens(display.thoughtTokens)} thought`);
3937
4104
  const status = stats.length > 0 ? ` (${stats.join(" \xB7 ")})` : "";
3938
- return /* @__PURE__ */ jsxs12(Box11, { flexDirection: "column", children: [
3939
- /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
4105
+ return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", children: [
4106
+ /* @__PURE__ */ jsxs11(Text12, { dimColor: true, children: [
3940
4107
  spinner,
3941
4108
  " ",
3942
4109
  label,
3943
4110
  status
3944
4111
  ] }),
3945
- tail !== "" && /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
4112
+ display.tail !== "" && /* @__PURE__ */ jsxs11(Text12, { dimColor: true, children: [
3946
4113
  " ",
3947
- tail
4114
+ display.tail
3948
4115
  ] })
3949
4116
  ] });
3950
4117
  });
@@ -3952,7 +4119,7 @@ var ThinkingIndicator = memo5(function ThinkingIndicator2({
3952
4119
  // src/cli/components/TodoPanel.tsx
3953
4120
  import { Box as Box12, Text as Text13 } from "ink";
3954
4121
  import { memo as memo6 } from "react";
3955
- import { jsxs as jsxs13 } from "react/jsx-runtime";
4122
+ import { jsxs as jsxs12 } from "react/jsx-runtime";
3956
4123
  var SYMBOLS = {
3957
4124
  pending: "\u25CB",
3958
4125
  in_progress: "\u25D0",
@@ -3973,13 +4140,13 @@ function visibleTodoWindow(todos, max = MAX_VISIBLE_TODOS) {
3973
4140
  var TodoPanel = memo6(function TodoPanel2({ todos }) {
3974
4141
  if (todos.length === 0 || todos.every((t) => t.status === "done")) return null;
3975
4142
  const { items, hiddenBefore, hiddenAfter } = visibleTodoWindow(todos);
3976
- return /* @__PURE__ */ jsxs13(Box12, { flexDirection: "column", children: [
3977
- hiddenBefore > 0 && /* @__PURE__ */ jsxs13(Text13, { dimColor: true, children: [
4143
+ return /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
4144
+ hiddenBefore > 0 && /* @__PURE__ */ jsxs12(Text13, { dimColor: true, children: [
3978
4145
  " \u2026 ",
3979
4146
  hiddenBefore,
3980
4147
  " more above"
3981
4148
  ] }),
3982
- items.map((todo) => /* @__PURE__ */ jsxs13(
4149
+ items.map((todo) => /* @__PURE__ */ jsxs12(
3983
4150
  Text13,
3984
4151
  {
3985
4152
  dimColor: todo.status === "done",
@@ -3993,7 +4160,7 @@ var TodoPanel = memo6(function TodoPanel2({ todos }) {
3993
4160
  },
3994
4161
  todo.id
3995
4162
  )),
3996
- hiddenAfter > 0 && /* @__PURE__ */ jsxs13(Text13, { dimColor: true, children: [
4163
+ hiddenAfter > 0 && /* @__PURE__ */ jsxs12(Text13, { dimColor: true, children: [
3997
4164
  " \u2026 ",
3998
4165
  hiddenAfter,
3999
4166
  " more"
@@ -4024,13 +4191,21 @@ var JUNK_ENTRY_PATTERN = /^(.)\1{19,}$/;
4024
4191
  function historyPath() {
4025
4192
  return path13.join(starHome(), "history");
4026
4193
  }
4027
- function readLines(file) {
4194
+ function readEntries(file) {
4028
4195
  const raw = fs7.readFileSync(file, "utf8");
4029
- return raw.split("\n").map((line) => line.replace(/\r$/, "")).filter((line) => line.trim().length > 0);
4196
+ return raw.split("\n").map((line) => line.replace(/\r$/, "")).filter((line) => line.trim().length > 0).map((line) => {
4197
+ if (!line.startsWith('"')) return line;
4198
+ try {
4199
+ const parsed = JSON.parse(line);
4200
+ return typeof parsed === "string" ? parsed : line;
4201
+ } catch {
4202
+ return line;
4203
+ }
4204
+ });
4030
4205
  }
4031
- function writeHistory(file, lines) {
4206
+ function writeHistory(file, entries) {
4032
4207
  fs7.mkdirSync(path13.dirname(file), { recursive: true });
4033
- fs7.writeFileSync(file, `${lines.join("\n")}
4208
+ fs7.writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}
4034
4209
  `);
4035
4210
  }
4036
4211
  function isJunkHistoryEntry(line) {
@@ -4042,7 +4217,7 @@ function isSkippableEntry(line) {
4042
4217
  function loadHistory(limit = HISTORY_LIMIT) {
4043
4218
  try {
4044
4219
  const file = historyPath();
4045
- const lines = readLines(file);
4220
+ const lines = readEntries(file);
4046
4221
  const clean = lines.filter((line) => !isSkippableEntry(line));
4047
4222
  if (clean.length !== lines.length || clean.length > HISTORY_LIMIT) {
4048
4223
  writeHistory(file, clean.slice(-HISTORY_LIMIT));
@@ -4053,13 +4228,13 @@ function loadHistory(limit = HISTORY_LIMIT) {
4053
4228
  }
4054
4229
  }
4055
4230
  function appendHistory(entry) {
4056
- const line = entry.replace(/[\r\n]+/g, " ").trim();
4231
+ const line = entry.trim();
4057
4232
  if (line.length === 0 || isSkippableEntry(line)) return;
4058
4233
  try {
4059
4234
  const file = historyPath();
4060
4235
  let existing = [];
4061
4236
  try {
4062
- existing = readLines(file);
4237
+ existing = readEntries(file);
4063
4238
  } catch {
4064
4239
  }
4065
4240
  if (existing[existing.length - 1] === line) return;
@@ -4068,7 +4243,7 @@ function appendHistory(entry) {
4068
4243
  return;
4069
4244
  }
4070
4245
  fs7.mkdirSync(path13.dirname(file), { recursive: true });
4071
- fs7.appendFileSync(file, `${line}
4246
+ fs7.appendFileSync(file, `${JSON.stringify(line)}
4072
4247
  `);
4073
4248
  } catch {
4074
4249
  }
@@ -4168,12 +4343,15 @@ var TerminalTitle = class _TerminalTitle {
4168
4343
  deps;
4169
4344
  timer = null;
4170
4345
  frame = 0;
4346
+ running = false;
4171
4347
  static forCwd(cwd, deps = {}) {
4172
4348
  return new _TerminalTitle(`star-cli \u2014 ${path14.basename(cwd) || cwd}`, deps);
4173
4349
  }
4174
4350
  start() {
4175
- if (this.timer) return;
4351
+ if (this.running) return;
4352
+ this.running = true;
4176
4353
  this.render(SPINNER_FRAMES2[this.frame] ?? "\u280B");
4354
+ if (isMotionDisabled(this.deps.env)) return;
4177
4355
  this.timer = setInterval(() => {
4178
4356
  this.frame = (this.frame + 1) % SPINNER_FRAMES2.length;
4179
4357
  this.render(SPINNER_FRAMES2[this.frame] ?? "\u280B");
@@ -4183,6 +4361,7 @@ var TerminalTitle = class _TerminalTitle {
4183
4361
  // "done" leaves a bell glyph in the title until the next turn starts;
4184
4362
  // "idle" (interrupt, unmount) restores the bare title.
4185
4363
  stop(kind = "done") {
4364
+ this.running = false;
4186
4365
  if (this.timer) {
4187
4366
  clearInterval(this.timer);
4188
4367
  this.timer = null;
@@ -4264,7 +4443,7 @@ async function checkForUpdate(currentVersion, fetcher = defaultFetcher2, statePa
4264
4443
  }
4265
4444
 
4266
4445
  // src/cli/repl.tsx
4267
- import { jsx as jsx11, jsxs as jsxs14 } from "react/jsx-runtime";
4446
+ import { jsx as jsx12, jsxs as jsxs13 } from "react/jsx-runtime";
4268
4447
  var CLEAR_TERMINAL = process.platform === "win32" ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H";
4269
4448
  function emptyUsage() {
4270
4449
  return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -4311,6 +4490,7 @@ function Repl({
4311
4490
  const [epoch, setEpoch] = useState7(0);
4312
4491
  const [streamingText, setStreamingText] = useState7(null);
4313
4492
  const [isStreaming, setIsStreaming] = useState7(false);
4493
+ const [shellRunning, setShellRunning] = useState7(false);
4314
4494
  const [thinking, setThinking] = useState7(false);
4315
4495
  const [thinkingText, setThinkingText] = useState7("");
4316
4496
  const [thoughtSummary, setThoughtSummary] = useState7(null);
@@ -4325,52 +4505,54 @@ function Repl({
4325
4505
  const [pendingRewind, setPendingRewindState] = useState7(null);
4326
4506
  const [picker, setPickerState] = useState7(null);
4327
4507
  const [pendingConnect, setPendingConnectState] = useState7(null);
4328
- const backendRef = useRef6(backend);
4329
- const nextIdRef = useRef6(initialDisplay.length);
4330
- const abortRef = useRef6(null);
4331
- const busyCommandRef = useRef6(null);
4332
- const busyAbortRef = useRef6(null);
4333
- const streamedRef = useRef6("");
4334
- const turnStartedAtRef = useRef6(0);
4335
- const lastCommittedLenRef = useRef6(0);
4336
- const thinkingRef = useRef6(false);
4337
- const reasoningRef = useRef6("");
4338
- const tickerStopRef = useRef6(null);
4339
- const flushedRef = useRef6({ streamed: "", reasoning: "" });
4340
- const toolCardsRef = useRef6(/* @__PURE__ */ new Map());
4341
- const pendingRef = useRef6(null);
4342
- const permissionChainRef = useRef6(Promise.resolve());
4343
- const alwaysAllowedRef = useRef6(/* @__PURE__ */ new Set());
4344
- const alwaysAllowedListRef = useRef6([]);
4345
- const prevModeRef = useRef6(null);
4346
- const planApprovalRef = useRef6(false);
4347
- const pendingRewindRef = useRef6(null);
4348
- const pickerRef = useRef6(null);
4349
- const titleRef = useRef6(null);
4350
- const pendingConnectRef = useRef6(null);
4351
- const modelNameRef = useRef6(model);
4352
- const sessionStoreRef = useRef6(sessionStore);
4353
- const configRef = useRef6(config);
4508
+ const backendRef = useRef7(backend);
4509
+ const nextIdRef = useRef7(initialDisplay.length);
4510
+ const abortRef = useRef7(null);
4511
+ const operationKindRef = useRef7(null);
4512
+ const operationIdRef = useRef7(0);
4513
+ const busyCommandRef = useRef7(null);
4514
+ const busyAbortRef = useRef7(null);
4515
+ const streamedRef = useRef7("");
4516
+ const turnStartedAtRef = useRef7(0);
4517
+ const lastCommittedLenRef = useRef7(0);
4518
+ const thinkingRef = useRef7(false);
4519
+ const reasoningRef = useRef7("");
4520
+ const tickerStopRef = useRef7(null);
4521
+ const flushedRef = useRef7({ streamed: "", reasoning: "" });
4522
+ const toolCardsRef = useRef7(/* @__PURE__ */ new Map());
4523
+ const pendingRef = useRef7(null);
4524
+ const permissionChainRef = useRef7(Promise.resolve());
4525
+ const alwaysAllowedRef = useRef7(/* @__PURE__ */ new Set());
4526
+ const alwaysAllowedListRef = useRef7([]);
4527
+ const prevModeRef = useRef7(null);
4528
+ const planApprovalRef = useRef7(false);
4529
+ const pendingRewindRef = useRef7(null);
4530
+ const pickerRef = useRef7(null);
4531
+ const titleRef = useRef7(null);
4532
+ const pendingConnectRef = useRef7(null);
4533
+ const modelNameRef = useRef7(model);
4534
+ const sessionStoreRef = useRef7(sessionStore);
4535
+ const configRef = useRef7(config);
4354
4536
  useEffect4(() => {
4355
4537
  configRef.current = config;
4356
4538
  }, [config]);
4357
- const usageRef = useRef6(initialUsage ? { ...initialUsage } : emptyUsage());
4358
- const turnChunksRef = useRef6(0);
4359
- const messagesRef = useRef6(initialDisplay);
4539
+ const usageRef = useRef7(initialUsage ? { ...initialUsage } : emptyUsage());
4540
+ const turnChunksRef = useRef7(0);
4541
+ const messagesRef = useRef7(initialDisplay);
4360
4542
  const [queueItems, setQueueItems] = useState7([]);
4361
- const queueRef = useRef6(new PromptQueue());
4543
+ const queueRef = useRef7(new PromptQueue());
4362
4544
  const [pendingImages, setPendingImages] = useState7([]);
4363
- const pendingImagesRef = useRef6([]);
4364
- const imageSeqRef = useRef6(1);
4545
+ const pendingImagesRef = useRef7([]);
4546
+ const imageSeqRef = useRef7(1);
4365
4547
  const [inputRefill, setInputRefill] = useState7();
4366
- const refillSeqRef = useRef6(0);
4367
- const lastEscRef = useRef6(null);
4548
+ const refillSeqRef = useRef7(0);
4549
+ const lastEscRef = useRef7(null);
4368
4550
  const [gitBranch, setGitBranch] = useState7(null);
4369
4551
  const [contextUsage, setContextUsage] = useState7(null);
4370
4552
  const [initialHistory] = useState7(() => loadHistory());
4371
- const budgetWarnedRef = useRef6(false);
4372
- const budgetExceededRef = useRef6(false);
4373
- const runStreamRef = useRef6(
4553
+ const budgetWarnedRef = useRef7(false);
4554
+ const budgetExceededRef = useRef7(false);
4555
+ const runStreamRef = useRef7(
4374
4556
  null
4375
4557
  );
4376
4558
  const pushMessage = useCallback(
@@ -4463,13 +4645,40 @@ function Repl({
4463
4645
  pendingImagesRef.current = [];
4464
4646
  setPendingImages([]);
4465
4647
  }, []);
4466
- const sessionCostUsd = useCallback(
4467
- () => computeCostUsd(
4468
- usageRef.current,
4469
- config.models.find((m) => m.name === modelNameRef.current)
4470
- ),
4471
- [config]
4648
+ const recordUsage = useCallback(
4649
+ (delta, _modelName, costUsd) => {
4650
+ const usage = usageRef.current;
4651
+ usage.requests += 1;
4652
+ usage.promptTokens += delta.promptTokens;
4653
+ usage.completionTokens += delta.completionTokens;
4654
+ usage.totalTokens += delta.promptTokens + delta.completionTokens || delta.totalTokens;
4655
+ if (delta.cachedPromptTokens) {
4656
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + delta.cachedPromptTokens;
4657
+ }
4658
+ if (delta.cacheReadInputTokens) {
4659
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + delta.cacheReadInputTokens;
4660
+ }
4661
+ if (costUsd === null) usage.costUnknown = true;
4662
+ else usage.costUsd = (usage.costUsd ?? 0) + costUsd;
4663
+ if (cacheUsageReported(delta)) {
4664
+ if (!usage.cache) usage.cache = { cachedTokens: 0, promptTokens: 0 };
4665
+ addToCacheTotals(usage.cache, delta);
4666
+ }
4667
+ setUsageVersion((version) => version + 1);
4668
+ },
4669
+ []
4472
4670
  );
4671
+ useEffect4(() => {
4672
+ if (backendRef.current instanceof AgentLoop) backendRef.current.onUsage = recordUsage;
4673
+ }, [recordUsage]);
4674
+ const sessionCostUsd = useCallback(() => {
4675
+ const usage = usageRef.current;
4676
+ if (usage.costUsd !== void 0 || usage.costUnknown) return usage.costUsd ?? 0;
4677
+ return computeCostUsd(
4678
+ usage,
4679
+ config.models.find((model2) => model2.name === modelNameRef.current)
4680
+ );
4681
+ }, [config]);
4473
4682
  const handlePasteImage = useCallback(() => {
4474
4683
  void readClipboardImage().then((result) => {
4475
4684
  if (!result) return;
@@ -4657,10 +4866,19 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
4657
4866
  const stopped = stopBackgroundWork();
4658
4867
  if (stopped > 0) {
4659
4868
  pushMessage("system", `${stopped} background task(s) still running; reports will be lost.`);
4660
- setTimeout(exit, 50);
4661
- return;
4662
4869
  }
4663
- exit();
4870
+ void (async () => {
4871
+ try {
4872
+ await sessionStoreRef.current?.close();
4873
+ } catch (error) {
4874
+ pushMessage(
4875
+ "system",
4876
+ `Failed to flush session metadata: ${error instanceof Error ? error.message : String(error)}`
4877
+ );
4878
+ }
4879
+ if (stopped > 0) setTimeout(exit, 50);
4880
+ else exit();
4881
+ })();
4664
4882
  }, [exit, pushMessage, stopBackgroundWork]);
4665
4883
  useInput((_input, key) => {
4666
4884
  if (key.escape) {
@@ -4734,6 +4952,7 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
4734
4952
  system: SYSTEM_PROMPT,
4735
4953
  sessionStore: sessionStoreRef.current,
4736
4954
  contextMaxTokens: contextWindowTokens(config, name),
4955
+ maxTokens: modelConfig.maxTokens,
4737
4956
  providerMetadata: reasoningEffortMetadata(config, name),
4738
4957
  temperature: modelConfig.temperature,
4739
4958
  streamIdleTimeoutSec: modelConfig.streamIdleTimeoutSec,
@@ -4746,6 +4965,7 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
4746
4965
  await loop.loadMessages([...prev.getStarMessages()]);
4747
4966
  }
4748
4967
  attachConfirmHandler(loop);
4968
+ loop.onUsage = recordUsage;
4749
4969
  backendRef.current = loop;
4750
4970
  modelNameRef.current = name;
4751
4971
  setModelName(name);
@@ -4755,7 +4975,7 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
4755
4975
  return `Failed to switch model: ${error instanceof Error ? error.message : String(error)}`;
4756
4976
  }
4757
4977
  },
4758
- [config, cwd, attachConfirmHandler]
4978
+ [config, cwd, attachConfirmHandler, recordUsage]
4759
4979
  );
4760
4980
  const pickReasoningEffort = useCallback(
4761
4981
  async (name) => {
@@ -4812,38 +5032,76 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
4812
5032
  if (!resolvedId) {
4813
5033
  return `Session not found: ${id}`;
4814
5034
  }
4815
- const resumed = await resumeSession(resolvedId);
5035
+ const currentStore = sessionStoreRef.current;
5036
+ const sameStore = currentStore?.id === resolvedId ? currentStore : null;
5037
+ const resumed = sameStore ? await (async () => {
5038
+ const [meta, loaded] = await Promise.all([sameStore.meta(), sameStore.starMessages()]);
5039
+ return { meta, messages: reconcileStarMessages(loaded) };
5040
+ })() : await resumeSession(resolvedId);
4816
5041
  if (!resumed) {
4817
5042
  return `Session not found: ${id}`;
4818
5043
  }
4819
- const store = await SessionStore.open(resolvedId);
5044
+ const store = sameStore ?? await SessionStore.open(resolvedId);
4820
5045
  if (!store) {
4821
5046
  return `Session not found: ${id}`;
4822
5047
  }
4823
5048
  const stopped = stopBackgroundWork();
4824
5049
  await current.loadMessages(resumed.messages);
5050
+ const previousStore = sessionStoreRef.current;
4825
5051
  current.setSessionStore(store);
4826
5052
  sessionStoreRef.current = store;
5053
+ if (previousStore !== store) await previousStore?.close();
4827
5054
  setTodoSession(store.id);
4828
5055
  setTodos(await loadTodos(cwd, store.id));
4829
5056
  hydrateSnapshots(await loadSessionSnapshots(store.dir));
4830
5057
  const display = buildDisplayMessages(resumed.messages);
4831
5058
  nextIdRef.current = display.length;
4832
- applyMessages(display);
5059
+ redrawMessages(display);
4833
5060
  usageRef.current = resumed.meta.usage ? { ...resumed.meta.usage } : emptyUsage();
5061
+ budgetWarnedRef.current = false;
5062
+ budgetExceededRef.current = false;
4834
5063
  setUsageVersion((v) => v + 1);
4835
5064
  const stoppedNote = stopped > 0 ? ` Stopped ${stopped} background task(s) from the previous session.` : "";
4836
5065
  return `Resumed session ${resolvedId} (${resumed.messages.length} messages).${stoppedNote}`;
4837
5066
  },
4838
- [applyMessages, cwd, stopBackgroundWork]
5067
+ [cwd, redrawMessages, stopBackgroundWork]
4839
5068
  );
4840
5069
  const terminalTitle = useCallback(() => {
4841
5070
  titleRef.current ??= TerminalTitle.forCwd(cwd);
4842
5071
  return titleRef.current;
4843
5072
  }, [cwd]);
5073
+ const drainQueue = useCallback(() => {
5074
+ if (abortRef.current || busyCommandRef.current || planApprovalRef.current) return;
5075
+ const status = budgetState(sessionCostUsd(), config.sessionBudgetUsd);
5076
+ const nextPrompt = queueRef.current.dequeue();
5077
+ if (!nextPrompt) return;
5078
+ if (status === "exceeded") {
5079
+ const dropped = queueRef.current.clear().length + 1;
5080
+ setQueueItems([]);
5081
+ pushMessage("system", `Dropped ${dropped} queued message(s) \u2014 session budget exceeded.`);
5082
+ } else {
5083
+ setQueueItems(queueRef.current.list());
5084
+ void runStreamRef.current?.(nextPrompt.text, nextPrompt.images);
5085
+ }
5086
+ }, [config.sessionBudgetUsd, pushMessage, sessionCostUsd]);
4844
5087
  const runStream = useCallback(
4845
5088
  async (input, extraImages = []) => {
4846
- const resolved = await resolveMentions(input, cwd);
5089
+ const controller = new AbortController();
5090
+ const operationId = ++operationIdRef.current;
5091
+ abortRef.current = controller;
5092
+ operationKindRef.current = "model";
5093
+ let resolved;
5094
+ try {
5095
+ resolved = await resolveMentions(input, cwd);
5096
+ } catch (error) {
5097
+ if (operationIdRef.current === operationId) {
5098
+ abortRef.current = null;
5099
+ operationKindRef.current = null;
5100
+ }
5101
+ pushMessage("system", `Error: ${error instanceof Error ? error.message : String(error)}`);
5102
+ drainQueue();
5103
+ return;
5104
+ }
4847
5105
  const images = [...resolved.images, ...extraImages];
4848
5106
  pushMessage(
4849
5107
  "user",
@@ -4853,8 +5111,6 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
4853
5111
  for (const skip of resolved.skipped) {
4854
5112
  pushMessage("system", `Skipped @${skip.path}: ${skip.reason}`);
4855
5113
  }
4856
- const controller = new AbortController();
4857
- abortRef.current = controller;
4858
5114
  const turnStartedAt = Date.now();
4859
5115
  turnStartedAtRef.current = turnStartedAt;
4860
5116
  streamedRef.current = "";
@@ -4967,27 +5223,6 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
4967
5223
  reasoningRef.current = "";
4968
5224
  setThinking(true);
4969
5225
  setThinkingText("");
4970
- } else if (event.type === "finish") {
4971
- if (event.usage) {
4972
- const usage = usageRef.current;
4973
- usage.requests += 1;
4974
- usage.promptTokens += event.usage.promptTokens;
4975
- usage.completionTokens += event.usage.completionTokens;
4976
- usage.totalTokens += event.usage.totalTokens;
4977
- if (event.usage.cachedPromptTokens) {
4978
- usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + event.usage.cachedPromptTokens;
4979
- }
4980
- if (event.usage.cacheReadInputTokens) {
4981
- usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + event.usage.cacheReadInputTokens;
4982
- }
4983
- if (cacheUsageReported(event.usage)) {
4984
- if (!usage.cache) usage.cache = { cachedTokens: 0, promptTokens: 0 };
4985
- addToCacheTotals(usage.cache, event.usage);
4986
- }
4987
- setUsageVersion((v) => v + 1);
4988
- sessionStoreRef.current?.addUsage(event.usage).catch(() => {
4989
- });
4990
- }
4991
5226
  } else if (event.type === "retry") {
4992
5227
  streamedRef.current = streamedRef.current.slice(0, lastCommittedLenRef.current);
4993
5228
  flushedRef.current = { ...flushedRef.current, streamed: "" };
@@ -5014,7 +5249,10 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
5014
5249
  } finally {
5015
5250
  tickerStopRef.current?.();
5016
5251
  tickerStopRef.current = null;
5017
- abortRef.current = null;
5252
+ if (operationIdRef.current === operationId) {
5253
+ abortRef.current = null;
5254
+ operationKindRef.current = null;
5255
+ }
5018
5256
  setIsStreaming(false);
5019
5257
  thinkingRef.current = false;
5020
5258
  reasoningRef.current = "";
@@ -5073,25 +5311,18 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
5073
5311
  const backend2 = backendRef.current;
5074
5312
  if (backend2 instanceof AgentLoop) {
5075
5313
  const undelivered = backend2.takeUndeliveredSteers();
5076
- for (const text of [...undelivered].reverse()) {
5077
- queueRef.current.enqueueFront({ text, images: [] });
5078
- }
5079
- if (undelivered.length > 0) setQueueItems(queueRef.current.list());
5080
- }
5081
- const nextPrompt = queueRef.current.dequeue();
5082
- if (nextPrompt) {
5083
- if (status === "exceeded") {
5084
- const dropped = queueRef.current.clear().length + 1;
5085
- setQueueItems([]);
5086
- pushMessage(
5087
- "system",
5088
- `Dropped ${dropped} queued message(s) \u2014 session budget exceeded.`
5089
- );
5314
+ if (interrupted && undelivered.length > 0) {
5315
+ refillSeqRef.current += 1;
5316
+ setInputRefill({ text: undelivered.join("\n"), seq: refillSeqRef.current });
5317
+ pushMessage("system", "Undelivered steering input restored to the draft.");
5090
5318
  } else {
5091
- setQueueItems(queueRef.current.list());
5092
- void runStreamRef.current?.(nextPrompt.text, nextPrompt.images);
5319
+ for (const text of [...undelivered].reverse()) {
5320
+ queueRef.current.enqueueFront({ text, images: [] });
5321
+ }
5322
+ if (undelivered.length > 0) setQueueItems(queueRef.current.list());
5093
5323
  }
5094
5324
  }
5325
+ drainQueue();
5095
5326
  }
5096
5327
  },
5097
5328
  [
@@ -5102,7 +5333,8 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
5102
5333
  cwd,
5103
5334
  config,
5104
5335
  sessionCostUsd,
5105
- terminalTitle
5336
+ terminalTitle,
5337
+ drainQueue
5106
5338
  ]
5107
5339
  );
5108
5340
  useEffect4(() => {
@@ -5111,13 +5343,17 @@ Full configuration details: ${CONFIG_DOCS_URL}`;
5111
5343
  const handlePlanDecision = useCallback(
5112
5344
  (decision) => {
5113
5345
  setPlanApproval(false);
5114
- if (decision === "no") return;
5346
+ if (decision === "no") {
5347
+ drainQueue();
5348
+ return;
5349
+ }
5350
+ if (abortRef.current || busyCommandRef.current) return;
5115
5351
  const restore = prevModeRef.current ?? "ask";
5116
5352
  applySessionMode(restore);
5117
5353
  pushMessage("system", `Plan approved \u2014 permission mode restored to ${restore}.`);
5118
5354
  void runStream("The plan above is approved. Proceed with the implementation.");
5119
5355
  },
5120
- [applySessionMode, pushMessage, runStream, setPlanApproval]
5356
+ [applySessionMode, drainQueue, pushMessage, runStream, setPlanApproval]
5121
5357
  );
5122
5358
  const registry = useMemo(() => {
5123
5359
  const ctx = {
@@ -5298,12 +5534,16 @@ ${lines.join("\n")}`;
5298
5534
  return "Nothing to fork \u2014 the current session has no messages yet.";
5299
5535
  }
5300
5536
  const fork = await SessionStore.create(cwd, modelNameRef.current);
5301
- await fork.replaceMessages(messages2);
5537
+ if (!await fork.replaceMessages(messages2)) {
5538
+ await fork.close();
5539
+ return "Failed to persist the fork; the current session is unchanged.";
5540
+ }
5302
5541
  const oldMeta = await store.meta();
5303
5542
  const title = `Fork of ${oldMeta.title || shortSessionId(oldMeta.id)}`;
5304
5543
  await fork.setTitle(title);
5305
5544
  current.setSessionStore(fork);
5306
5545
  sessionStoreRef.current = fork;
5546
+ await store.close();
5307
5547
  setTodoSession(fork.id);
5308
5548
  return `Forked into new session ${fork.id} ("${title}") and switched to it. Checkpoints and rewind history do not carry over.`;
5309
5549
  },
@@ -5314,8 +5554,10 @@ ${lines.join("\n")}`;
5314
5554
  }
5315
5555
  const stopped = stopBackgroundWork();
5316
5556
  const store = await SessionStore.create(cwd, modelNameRef.current);
5557
+ const previousStore = sessionStoreRef.current;
5317
5558
  current.setSessionStore(store);
5318
5559
  sessionStoreRef.current = store;
5560
+ await previousStore?.close();
5319
5561
  setTodoSession(store.id);
5320
5562
  const [first] = current.getStarMessages();
5321
5563
  await current.loadMessages(first?.message.role === "system" ? [first] : []);
@@ -5353,9 +5595,11 @@ ${lines.join("\n")}`;
5353
5595
  },
5354
5596
  listTasks: () => formatTaskList(defaultTaskManager.list()),
5355
5597
  showUsage: () => {
5598
+ const usage = usageRef.current;
5356
5599
  const modelConfig = config.models.find((m) => m.name === modelNameRef.current);
5357
- return `${formatUsage(usageRef.current)}
5358
- ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5600
+ const cost = usage.costUsd !== void 0 || usage.costUnknown ? `Estimated cost: $${formatDollars(usage.costUsd ?? 0)} across recorded models${usage.costUnknown ? " (some requests have no configured price)" : ""}` : estimateCost(usage, modelNameRef.current, modelConfig);
5601
+ return `${formatUsage(usage)}
5602
+ ${cost}`;
5359
5603
  },
5360
5604
  showGlobalUsage: async () => formatUsageDashboard(await collectUsageStats(), config.models),
5361
5605
  compactContext: async () => {
@@ -5363,6 +5607,8 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5363
5607
  return `Busy \u2014 /${busyCommandRef.current} is still running.`;
5364
5608
  }
5365
5609
  busyCommandRef.current = "compact";
5610
+ const operationId = ++operationIdRef.current;
5611
+ operationKindRef.current = "command";
5366
5612
  const controller = new AbortController();
5367
5613
  busyAbortRef.current = controller;
5368
5614
  turnStartedAtRef.current = Date.now();
@@ -5377,20 +5623,19 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5377
5623
  if (result.compacted && result.messages) {
5378
5624
  const display = buildDisplayMessages(result.messages);
5379
5625
  nextIdRef.current = display.length;
5380
- applyMessages(display);
5626
+ redrawMessages(display);
5381
5627
  }
5382
5628
  return result.message;
5383
5629
  } finally {
5384
5630
  tickerStopRef.current?.();
5385
5631
  tickerStopRef.current = null;
5386
5632
  setActivity(null);
5387
- busyCommandRef.current = null;
5388
- busyAbortRef.current = null;
5389
- const nextPrompt = queueRef.current.dequeue();
5390
- if (nextPrompt) {
5391
- setQueueItems(queueRef.current.list());
5392
- void runStreamRef.current?.(nextPrompt.text, nextPrompt.images);
5633
+ if (operationIdRef.current === operationId) {
5634
+ busyCommandRef.current = null;
5635
+ busyAbortRef.current = null;
5636
+ operationKindRef.current = null;
5393
5637
  }
5638
+ drainQueue();
5394
5639
  }
5395
5640
  },
5396
5641
  exportSession: (arg) => exportSession({
@@ -5592,7 +5837,6 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5592
5837
  pickReasoningEffort,
5593
5838
  resume,
5594
5839
  runStream,
5595
- applyMessages,
5596
5840
  redrawMessages,
5597
5841
  applySessionMode,
5598
5842
  setPendingRewind,
@@ -5600,13 +5844,17 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5600
5844
  showPicker,
5601
5845
  handlePickerCancel,
5602
5846
  setPicker,
5603
- stopBackgroundWork
5847
+ stopBackgroundWork,
5848
+ drainQueue
5604
5849
  ]);
5605
5850
  const runShellBang = useCallback(
5606
5851
  async (raw) => {
5607
5852
  pushMessage("user", `!${raw.trim()}`);
5608
5853
  const controller = new AbortController();
5854
+ const operationId = ++operationIdRef.current;
5609
5855
  abortRef.current = controller;
5856
+ operationKindRef.current = "shell";
5857
+ setShellRunning(true);
5610
5858
  try {
5611
5859
  const outcome = await executeShellBang(raw, cwd, controller.signal);
5612
5860
  if (!outcome.ok) {
@@ -5630,13 +5878,18 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5630
5878
  if (current instanceof AgentLoop) {
5631
5879
  await current.appendContextMessage(outcome.contextMessage);
5632
5880
  }
5881
+ } catch (error) {
5882
+ pushMessage("system", `Error: ${error instanceof Error ? error.message : String(error)}`);
5633
5883
  } finally {
5634
- if (abortRef.current === controller) {
5884
+ if (operationIdRef.current === operationId) {
5635
5885
  abortRef.current = null;
5886
+ operationKindRef.current = null;
5636
5887
  }
5888
+ setShellRunning(false);
5889
+ drainQueue();
5637
5890
  }
5638
5891
  },
5639
- [cwd, pushMessage]
5892
+ [cwd, drainQueue, pushMessage]
5640
5893
  );
5641
5894
  const handleSubmit = useCallback(
5642
5895
  (text) => {
@@ -5675,7 +5928,28 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5675
5928
  return;
5676
5929
  }
5677
5930
  }
5678
- void command.run(parsed.args, registry.ctx);
5931
+ const lifecycleCommand = BUSY_BLOCKED_COMMANDS.has(parsed.name) && parsed.name !== "compact";
5932
+ const operationId = ++operationIdRef.current;
5933
+ if (lifecycleCommand) {
5934
+ busyCommandRef.current = parsed.name;
5935
+ operationKindRef.current = "command";
5936
+ }
5937
+ void (async () => {
5938
+ try {
5939
+ await command.run(parsed.args, registry.ctx);
5940
+ } catch (error) {
5941
+ pushMessage(
5942
+ "system",
5943
+ `Error: ${error instanceof Error ? error.message : String(error)}`
5944
+ );
5945
+ } finally {
5946
+ if (lifecycleCommand && operationIdRef.current === operationId) {
5947
+ busyCommandRef.current = null;
5948
+ operationKindRef.current = null;
5949
+ drainQueue();
5950
+ }
5951
+ }
5952
+ })();
5679
5953
  return;
5680
5954
  }
5681
5955
  const budget = config.sessionBudgetUsd;
@@ -5690,7 +5964,8 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5690
5964
  if (images.length > 0) clearPendingImages();
5691
5965
  if (abortRef.current || busyCommandRef.current) {
5692
5966
  const backend2 = backendRef.current;
5693
- if (abortRef.current && images.length === 0 && backend2 instanceof AgentLoop) {
5967
+ if (abortRef.current && operationKindRef.current === "model" && images.length === 0 && backend2 instanceof AgentLoop) {
5968
+ const requestedOperation = operationIdRef.current;
5694
5969
  void (async () => {
5695
5970
  const picked = await showPicker("Turn is running \u2014 how should this be sent?", [
5696
5971
  {
@@ -5704,6 +5979,15 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5704
5979
  description: "sent as a new prompt once the current turn finishes"
5705
5980
  }
5706
5981
  ]);
5982
+ if (operationIdRef.current !== requestedOperation || !abortRef.current) {
5983
+ if (!abortRef.current && !busyCommandRef.current) {
5984
+ void runStream(text, images);
5985
+ } else {
5986
+ queueRef.current.enqueue({ text, images });
5987
+ setQueueItems(queueRef.current.list());
5988
+ }
5989
+ return;
5990
+ }
5707
5991
  if (picked === "steer") {
5708
5992
  backend2.injectUserMessage(text);
5709
5993
  pushMessage(
@@ -5736,7 +6020,8 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5736
6020
  config,
5737
6021
  clearPendingImages,
5738
6022
  sessionCostUsd,
5739
- showPicker
6023
+ showPicker,
6024
+ drainQueue
5740
6025
  ]
5741
6026
  );
5742
6027
  const commandHints = useMemo(
@@ -5749,8 +6034,11 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5749
6034
  [registry]
5750
6035
  );
5751
6036
  useEffect4(() => {
6037
+ let cancelled = false;
5752
6038
  const refresh = () => {
5753
- setGitBranch(getGitSummaryCached(cwd)?.branch ?? null);
6039
+ void getGitSummaryCachedAsync(cwd).then((summary) => {
6040
+ if (!cancelled) setGitBranch(summary?.branch ?? null);
6041
+ });
5754
6042
  const current = backendRef.current;
5755
6043
  if (current instanceof AgentLoop) {
5756
6044
  const window_ = contextWindowTokens(config, modelNameRef.current);
@@ -5766,14 +6054,16 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5766
6054
  }
5767
6055
  };
5768
6056
  refresh();
5769
- if (!isStreaming) return;
5770
- const timer = setInterval(refresh, 2e3);
5771
- return () => clearInterval(timer);
6057
+ const timer = isStreaming ? setInterval(refresh, 2e3) : null;
6058
+ return () => {
6059
+ cancelled = true;
6060
+ if (timer) clearInterval(timer);
6061
+ };
5772
6062
  }, [cwd, config, isStreaming, epoch, modelName]);
5773
6063
  const sessionCost = sessionCostUsd();
5774
- return /* @__PURE__ */ jsxs14(Box13, { flexDirection: "column", children: [
5775
- /* @__PURE__ */ jsx11(MessageList, { messages }, `messages-${epoch}`),
5776
- (thinking || activity !== null) && /* @__PURE__ */ jsx11(
6064
+ return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", children: [
6065
+ /* @__PURE__ */ jsx12(MessageList, { messages }, `messages-${epoch}`),
6066
+ (thinking || activity !== null) && /* @__PURE__ */ jsx12(
5777
6067
  ThinkingIndicator,
5778
6068
  {
5779
6069
  reasoning: thinkingText,
@@ -5781,9 +6071,9 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5781
6071
  startedAt: turnStartedAtRef.current
5782
6072
  }
5783
6073
  ),
5784
- !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx11(Text14, { dimColor: true, children: thoughtSummary }),
5785
- streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx11(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
5786
- pending && /* @__PURE__ */ jsx11(
6074
+ !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx12(Text14, { dimColor: true, children: thoughtSummary }),
6075
+ streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx12(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
6076
+ pending && /* @__PURE__ */ jsx12(
5787
6077
  PermissionPrompt,
5788
6078
  {
5789
6079
  request: pending.request,
@@ -5791,8 +6081,8 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5791
6081
  onDecision: handleDecision
5792
6082
  }
5793
6083
  ),
5794
- planApproval && /* @__PURE__ */ jsx11(ExitPlanPrompt, { onDecision: handlePlanDecision }),
5795
- pendingRewind && /* @__PURE__ */ jsx11(
6084
+ planApproval && /* @__PURE__ */ jsx12(ExitPlanPrompt, { onDecision: handlePlanDecision }),
6085
+ pendingRewind && /* @__PURE__ */ jsx12(
5796
6086
  RewindConfirmPrompt,
5797
6087
  {
5798
6088
  summary: pendingRewind.summary,
@@ -5802,7 +6092,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5802
6092
  onDecision: handleRewindDecision
5803
6093
  }
5804
6094
  ),
5805
- picker && /* @__PURE__ */ jsx11(
6095
+ picker && /* @__PURE__ */ jsx12(
5806
6096
  SelectPrompt,
5807
6097
  {
5808
6098
  title: picker.title,
@@ -5812,7 +6102,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5812
6102
  onDelete: picker.onDelete
5813
6103
  }
5814
6104
  ),
5815
- pendingConnect && /* @__PURE__ */ jsx11(
6105
+ pendingConnect && /* @__PURE__ */ jsx12(
5816
6106
  ConnectWizard,
5817
6107
  {
5818
6108
  existingProviderNames: config.providers.map((p) => p.name),
@@ -5824,25 +6114,25 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5824
6114
  onCancel: cancelConnect
5825
6115
  }
5826
6116
  ),
5827
- queueItems.slice(0, MAX_VISIBLE_QUEUED).map((item) => /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
6117
+ queueItems.slice(0, MAX_VISIBLE_QUEUED).map((item) => /* @__PURE__ */ jsxs13(Text14, { dimColor: true, children: [
5828
6118
  "queued: ",
5829
6119
  item.text.length > 80 ? `${item.text.slice(0, 80)}\u2026` : item.text
5830
6120
  ] }, item.id)),
5831
- queueItems.length > MAX_VISIBLE_QUEUED && /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
6121
+ queueItems.length > MAX_VISIBLE_QUEUED && /* @__PURE__ */ jsxs13(Text14, { dimColor: true, children: [
5832
6122
  "\u2026 ",
5833
6123
  queueItems.length - MAX_VISIBLE_QUEUED,
5834
6124
  " more queued"
5835
6125
  ] }),
5836
- pendingImages.map((staged) => /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
6126
+ pendingImages.map((staged) => /* @__PURE__ */ jsxs13(Text14, { dimColor: true, children: [
5837
6127
  "[image attached: ",
5838
6128
  staged.image.path,
5839
6129
  "]"
5840
6130
  ] }, staged.id)),
5841
- /* @__PURE__ */ jsx11(TodoPanel, { todos }),
5842
- /* @__PURE__ */ jsx11(
6131
+ /* @__PURE__ */ jsx12(TodoPanel, { todos }),
6132
+ /* @__PURE__ */ jsx12(
5843
6133
  InputBox,
5844
6134
  {
5845
- isStreaming,
6135
+ isStreaming: isStreaming || shellRunning,
5846
6136
  disabled: pending !== null || planApproval || pendingRewind !== null || pendingConnect !== null || picker !== null,
5847
6137
  commands: commandHints,
5848
6138
  cwd,
@@ -5854,7 +6144,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5854
6144
  onPasteImage: handlePasteImage
5855
6145
  }
5856
6146
  ),
5857
- /* @__PURE__ */ jsx11(
6147
+ /* @__PURE__ */ jsx12(
5858
6148
  StatusBar,
5859
6149
  {
5860
6150
  cwd,
@@ -5877,7 +6167,7 @@ function renderRepl(backend, opts) {
5877
6167
  defaultTaskManager.cleanup();
5878
6168
  });
5879
6169
  return render(
5880
- /* @__PURE__ */ jsx11(
6170
+ /* @__PURE__ */ jsx12(
5881
6171
  Repl,
5882
6172
  {
5883
6173
  backend,
@@ -5899,4 +6189,4 @@ export {
5899
6189
  Repl,
5900
6190
  renderRepl
5901
6191
  };
5902
- //# sourceMappingURL=repl-SSCURTAH.js.map
6192
+ //# sourceMappingURL=repl-N2H7JDSH.js.map