@cryer/star-cli 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  AgentLoop,
4
+ MAX_IMAGE_DIMENSION,
4
5
  SKIP_DIRS,
5
6
  TodoStore,
6
7
  WORKING_DIFF_MAX_LINES,
@@ -23,25 +24,32 @@ import {
23
24
  formatTaskStarted,
24
25
  formatTodos,
25
26
  getGitSummary,
26
- globalConfigPath,
27
27
  hydrateSnapshots,
28
28
  isAllowedByRules,
29
29
  isDangerousCommand,
30
30
  isGitRepo,
31
31
  isSensitivePath,
32
32
  listSnapshots,
33
+ listTurnSnapshots,
33
34
  loadTodos,
34
35
  parseTodoArgs,
35
- projectConfigPath,
36
+ probeImageDimensions,
36
37
  reconcileToolCalls,
38
+ resetTodos,
37
39
  resolveShell,
40
+ resolveSnapshotContent,
38
41
  rewindToSnapshot,
42
+ summarizeMessages,
43
+ undoTurnSnapshots
44
+ } from "./chunk-BV4PLM6X.js";
45
+ import {
46
+ envFilePath,
47
+ globalConfigPath,
48
+ projectConfigPath,
39
49
  sessionsDir,
40
50
  starHome,
41
- summarizeMessages,
42
- undoTurnSnapshots,
43
51
  userMemoryPath
44
- } from "./chunk-DZWTIMJ2.js";
52
+ } from "./chunk-LADQCYKL.js";
45
53
 
46
54
  // src/main.tsx
47
55
  import { Command } from "commander";
@@ -109,10 +117,125 @@ function buildDisplayMessages(messages) {
109
117
  }
110
118
 
111
119
  // src/cli/mentions.ts
112
- import { readFile, stat } from "fs/promises";
120
+ import { readFile as readFile2, readdir, stat } from "fs/promises";
121
+ import path2 from "path";
122
+
123
+ // src/cli/image.ts
124
+ import { execFile } from "child_process";
125
+ import { randomUUID } from "crypto";
126
+ import { readFile, unlink, writeFile } from "fs/promises";
127
+ import os from "os";
113
128
  import path from "path";
129
+ import { promisify } from "util";
130
+ var RESIZE_TIMEOUT_MS = 1e4;
131
+ var execFileAsync = promisify(execFile);
132
+ function windowsResizeScript(inputFile, outputFile, maxDimension) {
133
+ const input = inputFile.replace(/'/g, "''");
134
+ const output = outputFile.replace(/'/g, "''");
135
+ return [
136
+ "Add-Type -AssemblyName System.Drawing",
137
+ `$img = [System.Drawing.Image]::FromFile('${input}')`,
138
+ `$scale = [Math]::Min(1.0, ${maxDimension} / [Math]::Max($img.Width, $img.Height))`,
139
+ "$w = [int][Math]::Max(1, [Math]::Round($img.Width * $scale))",
140
+ "$h = [int][Math]::Max(1, [Math]::Round($img.Height * $scale))",
141
+ "$bmp = New-Object System.Drawing.Bitmap($w, $h)",
142
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
143
+ "$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
144
+ "$g.DrawImage($img, 0, 0, $w, $h)",
145
+ `$bmp.Save('${output}', [System.Drawing.Imaging.ImageFormat]::Png)`,
146
+ "$g.Dispose(); $bmp.Dispose(); $img.Dispose()"
147
+ ].join("; ");
148
+ }
149
+ function resizeImagePlan(platform, inputFile, outputFile, maxDimension, tools = {}) {
150
+ if (platform === "win32") {
151
+ return {
152
+ command: "powershell.exe",
153
+ args: [
154
+ "-NoProfile",
155
+ "-NonInteractive",
156
+ "-Command",
157
+ windowsResizeScript(inputFile, outputFile, maxDimension)
158
+ ]
159
+ };
160
+ }
161
+ if (platform === "darwin") {
162
+ return {
163
+ command: "sips",
164
+ args: ["-Z", String(maxDimension), inputFile, "--out", outputFile]
165
+ };
166
+ }
167
+ if (platform === "linux") {
168
+ if (tools.hasConvert) {
169
+ return {
170
+ command: "convert",
171
+ args: [inputFile, "-resize", `${maxDimension}x${maxDimension}>`, outputFile]
172
+ };
173
+ }
174
+ if (tools.hasFfmpeg) {
175
+ return {
176
+ command: "ffmpeg",
177
+ args: [
178
+ "-y",
179
+ "-i",
180
+ inputFile,
181
+ "-vf",
182
+ `scale='if(gt(iw,ih),min(iw,${maxDimension}),-1)':'if(gt(iw,ih),-1,min(ih,${maxDimension}))'`,
183
+ outputFile
184
+ ]
185
+ };
186
+ }
187
+ return null;
188
+ }
189
+ return null;
190
+ }
191
+ async function commandExists(command) {
192
+ const checker = process.platform === "win32" ? "where" : "which";
193
+ try {
194
+ await execFileAsync(checker, [command], { timeout: RESIZE_TIMEOUT_MS });
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+ async function downsampleImageIfNeeded(image) {
201
+ const buf = Buffer.from(image.data, "base64");
202
+ const dims = probeImageDimensions(buf);
203
+ if (!dims || Math.max(dims.width, dims.height) <= MAX_IMAGE_DIMENSION) {
204
+ return { image, oversized: false, resized: false };
205
+ }
206
+ const inputFile = path.join(os.tmpdir(), `star-image-${randomUUID()}.src`);
207
+ const outputFile = path.join(os.tmpdir(), `star-image-${randomUUID()}.png`);
208
+ try {
209
+ const [hasConvert, hasFfmpeg] = process.platform === "linux" ? await Promise.all([commandExists("convert"), commandExists("ffmpeg")]) : [false, false];
210
+ const plan = resizeImagePlan(process.platform, inputFile, outputFile, MAX_IMAGE_DIMENSION, {
211
+ hasConvert,
212
+ hasFfmpeg
213
+ });
214
+ if (!plan) return { image, oversized: true, resized: false };
215
+ await writeFile(inputFile, buf);
216
+ await execFileAsync(plan.command, plan.args, { timeout: RESIZE_TIMEOUT_MS });
217
+ const out = await readFile(outputFile).catch(() => null);
218
+ if (!out || out.length === 0) return { image, oversized: true, resized: false };
219
+ return {
220
+ image: { path: image.path, mimeType: "image/png", data: out.toString("base64") },
221
+ oversized: true,
222
+ resized: true
223
+ };
224
+ } catch {
225
+ return { image, oversized: true, resized: false };
226
+ } finally {
227
+ await unlink(inputFile).catch(() => {
228
+ });
229
+ await unlink(outputFile).catch(() => {
230
+ });
231
+ }
232
+ }
233
+
234
+ // src/cli/mentions.ts
114
235
  var MAX_MENTION_BYTES = 100 * 1024;
115
236
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
237
+ var MAX_DIR_MENTION_ENTRIES = 200;
238
+ var MAX_DIR_MENTION_DEPTH = 10;
116
239
  var IMAGE_MIME_TYPES = {
117
240
  ".png": "image/png",
118
241
  ".jpg": "image/jpeg",
@@ -121,17 +244,22 @@ var IMAGE_MIME_TYPES = {
121
244
  ".webp": "image/webp"
122
245
  };
123
246
  function imageMimeType(filePath) {
124
- return IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? null;
247
+ return IMAGE_MIME_TYPES[path2.extname(filePath).toLowerCase()] ?? null;
125
248
  }
126
249
  async function readImageInput(filePath, cwd) {
127
250
  const mimeType = imageMimeType(filePath);
128
251
  if (!mimeType) return null;
129
- const abs = path.resolve(cwd, filePath);
252
+ const abs = path2.resolve(cwd, filePath);
130
253
  const st = await stat(abs).catch(() => null);
131
254
  if (!st || !st.isFile() || st.size > MAX_IMAGE_BYTES) return null;
132
- const buf = await readFile(abs).catch(() => null);
255
+ const buf = await readFile2(abs).catch(() => null);
133
256
  if (!buf) return null;
134
- return { path: filePath, mimeType, data: buf.toString("base64") };
257
+ const result = await downsampleImageIfNeeded({
258
+ path: filePath,
259
+ mimeType,
260
+ data: buf.toString("base64")
261
+ });
262
+ return result.image;
135
263
  }
136
264
  var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\]*|[A-Za-z0-9._\-/\\]+)/g;
137
265
  function parseMentions(text) {
@@ -165,6 +293,51 @@ function isBinaryContent(buf) {
165
293
  const probe = buf.subarray(0, 8e3);
166
294
  return probe.includes(0);
167
295
  }
296
+ async function listDirEntries(dir, prefix, depth, state, ignore) {
297
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
298
+ if (!entries) return;
299
+ const visible = entries.filter((entry) => entry.isDirectory() || entry.isFile()).filter((entry) => !isSensitivePath(entry.name)).filter((entry) => !ignore?.(path2.join(dir, entry.name), entry.isDirectory())).sort((a, b) => {
300
+ const dirDiff = Number(b.isDirectory()) - Number(a.isDirectory());
301
+ return dirDiff !== 0 ? dirDiff : a.name.localeCompare(b.name);
302
+ });
303
+ for (let i = 0; i < visible.length; i++) {
304
+ const entry = visible[i];
305
+ if (!entry) continue;
306
+ state.total += 1;
307
+ const last = i === visible.length - 1;
308
+ const isDir = entry.isDirectory();
309
+ if (state.shown < MAX_DIR_MENTION_ENTRIES) {
310
+ state.shown += 1;
311
+ state.lines.push(`${prefix}${last ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 "}${entry.name}${isDir ? "/" : ""}`);
312
+ if (isDir && depth < MAX_DIR_MENTION_DEPTH) {
313
+ await listDirEntries(
314
+ path2.join(dir, entry.name),
315
+ `${prefix}${last ? " " : "\u2502 "}`,
316
+ depth + 1,
317
+ state,
318
+ ignore
319
+ );
320
+ }
321
+ } else if (isDir && depth < MAX_DIR_MENTION_DEPTH) {
322
+ await listDirEntries(path2.join(dir, entry.name), prefix, depth + 1, state, ignore);
323
+ }
324
+ }
325
+ }
326
+ async function directoryBlock(abs, mention, ignore) {
327
+ const display = `${mention.replace(/\\/g, "/").replace(/\/+$/, "")}/`;
328
+ const state = { lines: [`${display}`], shown: 0, total: 0 };
329
+ const probe = await readdir(abs).catch(() => null);
330
+ if (!probe) return null;
331
+ await listDirEntries(abs, "", 1, state, ignore);
332
+ if (state.total > state.shown) {
333
+ state.lines.push(
334
+ `... (truncated, ${state.total - state.shown} more entries \u2014 use glob/grep tools or mention a subdirectory to see more)`
335
+ );
336
+ }
337
+ return `--- @${display} (directory) ---
338
+ ${state.lines.join("\n")}
339
+ --- end ---`;
340
+ }
168
341
  async function resolveMentions(text, cwd) {
169
342
  const { cleanText, mentions } = parseMentions(text);
170
343
  if (mentions.length === 0) {
@@ -174,8 +347,9 @@ async function resolveMentions(text, cwd) {
174
347
  const attached = [];
175
348
  const skipped = [];
176
349
  const images = [];
350
+ let ignore;
177
351
  for (const mention of mentions) {
178
- const abs = path.resolve(cwd, mention);
352
+ const abs = path2.resolve(cwd, mention);
179
353
  if (isSensitivePath(abs)) {
180
354
  skipped.push({ path: mention, reason: "sensitive file" });
181
355
  continue;
@@ -191,13 +365,18 @@ async function resolveMentions(text, cwd) {
191
365
  skipped.push({ path: mention, reason: "image too large" });
192
366
  continue;
193
367
  }
194
- const buf2 = await readFile(abs).catch(() => null);
368
+ const buf2 = await readFile2(abs).catch(() => null);
195
369
  if (!buf2) {
196
370
  skipped.push({ path: mention, reason: "unreadable" });
197
371
  continue;
198
372
  }
373
+ const result = await downsampleImageIfNeeded({
374
+ path: mention,
375
+ mimeType,
376
+ data: buf2.toString("base64")
377
+ });
199
378
  attached.push(mention);
200
- images.push({ path: mention, mimeType, data: buf2.toString("base64") });
379
+ images.push(result.image);
201
380
  continue;
202
381
  }
203
382
  const st = await stat(abs).catch(() => null);
@@ -206,14 +385,23 @@ async function resolveMentions(text, cwd) {
206
385
  continue;
207
386
  }
208
387
  if (st.isDirectory()) {
209
- skipped.push({ path: mention, reason: "is a directory" });
388
+ if (ignore === void 0) {
389
+ ignore = createIgnorePredicate(cwd) ?? null;
390
+ }
391
+ const block = await directoryBlock(abs, mention, ignore ?? void 0);
392
+ if (!block) {
393
+ skipped.push({ path: mention, reason: "unreadable" });
394
+ continue;
395
+ }
396
+ attached.push(mention);
397
+ blocks.push(block);
210
398
  continue;
211
399
  }
212
400
  if (st.size > MAX_MENTION_BYTES) {
213
401
  skipped.push({ path: mention, reason: `exceeds ${MAX_MENTION_BYTES / 1024}KB limit` });
214
402
  continue;
215
403
  }
216
- const buf = await readFile(abs).catch(() => null);
404
+ const buf = await readFile2(abs).catch(() => null);
217
405
  if (!buf) {
218
406
  skipped.push({ path: mention, reason: "unreadable" });
219
407
  continue;
@@ -275,12 +463,12 @@ var UsageTracker = class {
275
463
  };
276
464
 
277
465
  // src/cli/repl.tsx
278
- import { Box as Box11, Text as Text12, render, useApp, useInput as useInput6 } from "ink";
279
- import { useCallback, useEffect as useEffect2, useMemo, useRef as useRef3, useState as useState3 } from "react";
466
+ import { Box as Box13, Text as Text14, render, useApp, useInput as useInput8 } from "ink";
467
+ import { useCallback, useEffect as useEffect2, useMemo, useRef as useRef5, useState as useState5 } from "react";
280
468
 
281
469
  // src/config/save.ts
282
470
  import fs from "fs";
283
- import path2 from "path";
471
+ import path3 from "path";
284
472
  import { parse, stringify } from "smol-toml";
285
473
  async function addAllowRule(rule) {
286
474
  return addPermissionRule("allow", rule);
@@ -299,7 +487,7 @@ async function addPermissionRule(kind, rule) {
299
487
  if (rules.includes(rule)) return false;
300
488
  permissions[kind] = [...rules, rule];
301
489
  raw.permissions = permissions;
302
- await fs.promises.mkdir(path2.dirname(filePath), { recursive: true });
490
+ await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
303
491
  await fs.promises.writeFile(filePath, stringify(raw), "utf8");
304
492
  return true;
305
493
  }
@@ -313,7 +501,7 @@ async function savePermissionMode(mode) {
313
501
  if (error.code !== "ENOENT") throw error;
314
502
  }
315
503
  raw.permissionMode = mode;
316
- await fs.promises.mkdir(path2.dirname(filePath), { recursive: true });
504
+ await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
317
505
  await fs.promises.writeFile(filePath, stringify(raw), "utf8");
318
506
  }
319
507
 
@@ -321,6 +509,10 @@ async function savePermissionMode(mode) {
321
509
  import { z } from "zod";
322
510
  var ProviderConfigSchema = z.object({
323
511
  name: z.string(),
512
+ // openai-responses talks to the Responses API (/responses) instead of chat
513
+ // completions — the wire format codex uses, and the native API of
514
+ // reasoning-model relays that otherwise hide reasoning behind empty chat
515
+ // completions. streamChat sends strictSchemas:false + store:false with it.
324
516
  protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
325
517
  baseURL: z.string(),
326
518
  apiKeyEnv: z.string().optional(),
@@ -377,13 +569,18 @@ var ConfigSchema = z.object({
377
569
  // Extra attempts per model request when a stream fails transiently
378
570
  // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
379
571
  // relay hiccup does not silently end a half-finished turn. 0 disables.
380
- // Retries after a timeout/empty failure scale both stream timeouts up
381
- // (attempt number ×, capped at 3x) since the relay is likely overloaded.
572
+ // The wait between attempts honors Retry-After headers and otherwise backs
573
+ // off exponentially with jitter (llm/retry.ts); retries after a
574
+ // timeout/empty failure also scale both stream timeouts up (attempt number
575
+ // ×, capped at 3x) since the relay is likely overloaded.
382
576
  streamMaxRetries: z.number().int().min(0).default(3),
383
- // How many times a turn may be nudged to continue when the model ends it
384
- // with a text-only reply that announces pending work ("我将…", "I will…")
385
- // instead of tool calls — a common premature-stop failure of weaker
386
- // agentic models. Never fires in plan mode. 0 disables.
577
+ // How many consecutive unproductive replies may be nudged before the turn
578
+ // is handed back: a text-only reply that announces pending work ("我将…",
579
+ // "I will…") or answers a nudge with more words, or an empty reply that
580
+ // survived the stream retry budget (steered with a nudge — a changed
581
+ // request — since identical resends already failed). Tool calls reset the
582
+ // count, so a productive task keeps going; exhaustion surfaces a notice
583
+ // instead of stopping silently. Never fires in plan mode. 0 disables.
387
584
  maxAutoContinues: z.number().int().min(0).default(2),
388
585
  contextCompaction: z.enum(["summary", "truncate"]).default("summary"),
389
586
  // Optional per-session dollar budget; when set, the REPL stops the session
@@ -420,6 +617,71 @@ function resolveModelConfig(config, modelName) {
420
617
  return model;
421
618
  }
422
619
 
620
+ // src/llm/sse-normalize.ts
621
+ var SseNormalizeTransform = class extends TransformStream {
622
+ constructor() {
623
+ let buffer = "";
624
+ let pending = null;
625
+ const flushPending = (controller) => {
626
+ if (pending === null) return;
627
+ controller.enqueue(`data: ${pending}
628
+
629
+ `);
630
+ pending = null;
631
+ };
632
+ const handleLine = (raw, controller) => {
633
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
634
+ if (line === "") {
635
+ flushPending(controller);
636
+ return;
637
+ }
638
+ if (line.startsWith("data:")) {
639
+ flushPending(controller);
640
+ pending = line.slice(5).replace(/^ /, "");
641
+ return;
642
+ }
643
+ if (line.startsWith("{") && pending !== null) {
644
+ flushPending(controller);
645
+ pending = line;
646
+ return;
647
+ }
648
+ flushPending(controller);
649
+ controller.enqueue(`${line}
650
+ `);
651
+ };
652
+ super({
653
+ transform(chunk, controller) {
654
+ buffer += chunk;
655
+ let index = buffer.indexOf("\n");
656
+ while (index !== -1) {
657
+ handleLine(buffer.slice(0, index), controller);
658
+ buffer = buffer.slice(index + 1);
659
+ index = buffer.indexOf("\n");
660
+ }
661
+ },
662
+ flush(controller) {
663
+ if (buffer.length > 0) handleLine(buffer, controller);
664
+ flushPending(controller);
665
+ }
666
+ });
667
+ }
668
+ };
669
+ function createSseNormalizingFetch(baseFetch = fetch) {
670
+ return async (input, init) => {
671
+ const response = await baseFetch(input, init);
672
+ const contentType = response.headers.get("content-type") ?? "";
673
+ if (!response.body || !contentType.includes("text/event-stream")) {
674
+ return response;
675
+ }
676
+ const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new SseNormalizeTransform()).pipeThrough(new TextEncoderStream());
677
+ return new Response(body, {
678
+ status: response.status,
679
+ statusText: response.statusText,
680
+ headers: response.headers
681
+ });
682
+ };
683
+ }
684
+
423
685
  // src/llm/provider.ts
424
686
  function resolveApiKey(provider) {
425
687
  const fromEnv = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : void 0;
@@ -458,22 +720,26 @@ function createModel(config, modelName) {
458
720
  return createOpenAI({
459
721
  baseURL: provider.baseURL,
460
722
  apiKey,
461
- headers: provider.headers
723
+ headers: provider.headers,
724
+ // Relays fronting the Responses API sometimes batch several JSON
725
+ // events into one SSE data payload, which the SDK's parser cannot
726
+ // read; normalize the wire format back to one event per data line.
727
+ fetch: createSseNormalizingFetch()
462
728
  }).responses(modelConfig.model);
463
729
  }
464
730
  }
465
731
 
466
732
  // src/session/checkpoints.ts
467
733
  import fs2 from "fs/promises";
468
- import path3 from "path";
734
+ import path4 from "path";
469
735
  function checkpointsDir(sessionDir) {
470
- return path3.join(sessionDir, "checkpoints");
736
+ return path4.join(sessionDir, "checkpoints");
471
737
  }
472
738
  function indexPath(sessionDir) {
473
- return path3.join(checkpointsDir(sessionDir), "index.json");
739
+ return path4.join(checkpointsDir(sessionDir), "index.json");
474
740
  }
475
741
  function checkpointContentPath(sessionDir, id) {
476
- return path3.join(checkpointsDir(sessionDir), `${id}.snapshot`);
742
+ return path4.join(checkpointsDir(sessionDir), `${id}.snapshot`);
477
743
  }
478
744
  async function listCheckpointRecords(sessionDir) {
479
745
  let raw;
@@ -521,11 +787,11 @@ async function loadSessionSnapshots(sessionDir) {
521
787
 
522
788
  // src/session/clear.ts
523
789
  import fs4 from "fs/promises";
524
- import path5 from "path";
790
+ import path6 from "path";
525
791
 
526
792
  // src/session/store.ts
527
793
  import fs3 from "fs/promises";
528
- import path4 from "path";
794
+ import path5 from "path";
529
795
  function generateId(now) {
530
796
  const pad = (n) => String(n).padStart(2, "0");
531
797
  const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
@@ -547,13 +813,13 @@ var SessionStore = class _SessionStore {
547
813
  initialized = false;
548
814
  constructor(id) {
549
815
  this.id = id;
550
- this.dir = path4.join(sessionsDir(), id);
816
+ this.dir = path5.join(sessionsDir(), id);
551
817
  }
552
818
  metaPath() {
553
- return path4.join(this.dir, "meta.json");
819
+ return path5.join(this.dir, "meta.json");
554
820
  }
555
821
  messagesPath() {
556
- return path4.join(this.dir, "messages.jsonl");
822
+ return path5.join(this.dir, "messages.jsonl");
557
823
  }
558
824
  static async create(cwd, model) {
559
825
  const store = new _SessionStore(generateId(/* @__PURE__ */ new Date()));
@@ -587,7 +853,7 @@ var SessionStore = class _SessionStore {
587
853
  const metas = [];
588
854
  for (const entry of entries) {
589
855
  try {
590
- const raw = await fs3.readFile(path4.join(sessionsDir(), entry, "meta.json"), "utf8");
856
+ const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
591
857
  const meta = JSON.parse(raw);
592
858
  if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
593
859
  } catch {
@@ -714,7 +980,7 @@ async function clearSessions(cwd, excludeId) {
714
980
  let removed2 = 0;
715
981
  for (const meta of metas) {
716
982
  if (meta.id === excludeId) continue;
717
- await fs4.rm(path5.join(sessionsDir(), meta.id), { recursive: true, force: true });
983
+ await fs4.rm(path6.join(sessionsDir(), meta.id), { recursive: true, force: true });
718
984
  removed2 += 1;
719
985
  }
720
986
  return removed2;
@@ -728,7 +994,7 @@ async function clearSessions(cwd, excludeId) {
728
994
  let removed = 0;
729
995
  for (const entry of entries) {
730
996
  if (entry === excludeId) continue;
731
- await fs4.rm(path5.join(sessionsDir(), entry), { recursive: true, force: true });
997
+ await fs4.rm(path6.join(sessionsDir(), entry), { recursive: true, force: true });
732
998
  removed += 1;
733
999
  }
734
1000
  return removed;
@@ -736,11 +1002,11 @@ async function clearSessions(cwd, excludeId) {
736
1002
 
737
1003
  // src/session/list.ts
738
1004
  import fs5 from "fs/promises";
739
- import path6 from "path";
1005
+ import path7 from "path";
740
1006
  async function countMessages(id) {
741
1007
  let raw;
742
1008
  try {
743
- raw = await fs5.readFile(path6.join(sessionsDir(), id, "messages.jsonl"), "utf8");
1009
+ raw = await fs5.readFile(path7.join(sessionsDir(), id, "messages.jsonl"), "utf8");
744
1010
  } catch {
745
1011
  return 0;
746
1012
  }
@@ -998,11 +1264,11 @@ function formatUsageDashboard(stats, models, options = {}) {
998
1264
 
999
1265
  // src/version.ts
1000
1266
  import fs6 from "fs";
1001
- import path7 from "path";
1267
+ import path8 from "path";
1002
1268
  import { fileURLToPath } from "url";
1003
1269
  function readPackageVersion() {
1004
1270
  try {
1005
- const pkgPath = path7.join(path7.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1271
+ const pkgPath = path8.join(path8.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1006
1272
  const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf8"));
1007
1273
  if (typeof pkg === "object" && pkg !== null) {
1008
1274
  const version = pkg.version;
@@ -1023,16 +1289,30 @@ function budgetState(costUsd, budgetUsd) {
1023
1289
  return "ok";
1024
1290
  }
1025
1291
 
1292
+ // src/cli/cache-stats.ts
1293
+ function cacheUsageReported(usage) {
1294
+ return usage.cachedPromptTokens != null || usage.cacheReadInputTokens != null;
1295
+ }
1296
+ function addToCacheTotals(totals, usage) {
1297
+ totals.cachedTokens += (usage.cachedPromptTokens ?? 0) + (usage.cacheReadInputTokens ?? 0);
1298
+ totals.promptTokens += usage.promptTokens + (usage.cacheReadInputTokens ?? 0);
1299
+ }
1300
+ function cacheHitPercent(totals) {
1301
+ if (totals.promptTokens <= 0) return null;
1302
+ const rate = totals.cachedTokens / totals.promptTokens;
1303
+ return Math.round(Math.min(1, Math.max(0, rate)) * 100);
1304
+ }
1305
+
1026
1306
  // src/cli/clipboard.ts
1027
- import { execFile } from "child_process";
1028
- import { randomUUID } from "crypto";
1029
- import { readFile as readFile2, unlink } from "fs/promises";
1030
- import os from "os";
1031
- import path8 from "path";
1032
- import { promisify } from "util";
1307
+ import { execFile as execFile2 } from "child_process";
1308
+ import { randomUUID as randomUUID2 } from "crypto";
1309
+ import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1310
+ import os2 from "os";
1311
+ import path9 from "path";
1312
+ import { promisify as promisify2 } from "util";
1033
1313
  var CLIPBOARD_TIMEOUT_MS = 5e3;
1034
1314
  var MAX_CLIPBOARD_IMAGE_BYTES = 10 * 1024 * 1024;
1035
- var execFileAsync = promisify(execFile);
1315
+ var execFileAsync2 = promisify2(execFile2);
1036
1316
  function windowsClipboardImageScript(tempFile) {
1037
1317
  const escaped = tempFile.replace(/'/g, "''");
1038
1318
  return [
@@ -1092,40 +1372,52 @@ function clipboardCopyCommand(platform) {
1092
1372
  if (platform === "linux") return { command: "xclip", args: ["-selection", "clipboard"] };
1093
1373
  return null;
1094
1374
  }
1095
- async function commandExists(command) {
1375
+ async function commandExists2(command) {
1096
1376
  const checker = process.platform === "win32" ? "where" : "which";
1097
1377
  try {
1098
- await execFileAsync(checker, [command], { timeout: CLIPBOARD_TIMEOUT_MS });
1378
+ await execFileAsync2(checker, [command], { timeout: CLIPBOARD_TIMEOUT_MS });
1099
1379
  return true;
1100
1380
  } catch {
1101
1381
  return false;
1102
1382
  }
1103
1383
  }
1384
+ async function finalizeClipboardImage(image) {
1385
+ const result = await downsampleImageIfNeeded(image);
1386
+ return { image: result.image, oversized: result.oversized && !result.resized };
1387
+ }
1104
1388
  async function readClipboardImage() {
1105
- const tempFile = path8.join(os.tmpdir(), `star-clipboard-${randomUUID()}.png`);
1106
- const hasPngpaste = process.platform === "darwin" ? await commandExists("pngpaste") : false;
1389
+ const tempFile = path9.join(os2.tmpdir(), `star-clipboard-${randomUUID2()}.png`);
1390
+ const hasPngpaste = process.platform === "darwin" ? await commandExists2("pngpaste") : false;
1107
1391
  const plan = clipboardImagePlan(process.platform, tempFile, hasPngpaste);
1108
1392
  if (!plan) return null;
1109
1393
  try {
1110
1394
  if (plan.kind === "stdout") {
1111
- const { stdout } = await execFileAsync(plan.command, plan.args, {
1395
+ const { stdout } = await execFileAsync2(plan.command, plan.args, {
1112
1396
  timeout: CLIPBOARD_TIMEOUT_MS,
1113
1397
  maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
1114
1398
  encoding: "buffer"
1115
1399
  });
1116
1400
  const buf2 = stdout;
1117
1401
  if (buf2.length === 0) return null;
1118
- return { path: "clipboard.png", mimeType: "image/png", data: buf2.toString("base64") };
1402
+ return finalizeClipboardImage({
1403
+ path: "clipboard.png",
1404
+ mimeType: "image/png",
1405
+ data: buf2.toString("base64")
1406
+ });
1119
1407
  }
1120
- await execFileAsync(plan.command, plan.args, { timeout: CLIPBOARD_TIMEOUT_MS });
1121
- const buf = await readFile2(tempFile).catch(() => null);
1408
+ await execFileAsync2(plan.command, plan.args, { timeout: CLIPBOARD_TIMEOUT_MS });
1409
+ const buf = await readFile3(tempFile).catch(() => null);
1122
1410
  if (!buf || buf.length === 0) return null;
1123
- return { path: "clipboard.png", mimeType: "image/png", data: buf.toString("base64") };
1411
+ return finalizeClipboardImage({
1412
+ path: "clipboard.png",
1413
+ mimeType: "image/png",
1414
+ data: buf.toString("base64")
1415
+ });
1124
1416
  } catch {
1125
1417
  return null;
1126
1418
  } finally {
1127
1419
  if (plan.kind === "temp-file") {
1128
- await unlink(tempFile).catch(() => {
1420
+ await unlink2(tempFile).catch(() => {
1129
1421
  });
1130
1422
  }
1131
1423
  }
@@ -1134,7 +1426,7 @@ async function copyText(text) {
1134
1426
  const plan = clipboardCopyCommand(process.platform);
1135
1427
  if (!plan) return false;
1136
1428
  return new Promise((resolve) => {
1137
- const child = execFile(
1429
+ const child = execFile2(
1138
1430
  plan.command,
1139
1431
  plan.args,
1140
1432
  { timeout: CLIPBOARD_TIMEOUT_MS },
@@ -1148,7 +1440,7 @@ async function copyText(text) {
1148
1440
 
1149
1441
  // src/cli/commands/actions.ts
1150
1442
  import fs7 from "fs/promises";
1151
- import path9 from "path";
1443
+ import path10 from "path";
1152
1444
  var MIN_COMPACT_MESSAGES = 4;
1153
1445
  async function compactSession(opts) {
1154
1446
  const { backend, sessionStore, config, model } = opts;
@@ -1209,9 +1501,9 @@ async function exportSession(opts) {
1209
1501
  return "Nothing to export: the session has no messages.";
1210
1502
  }
1211
1503
  const sessionId = sessionStore?.id ?? `unknown-${Date.now()}`;
1212
- const target = arg ? path9.resolve(cwd, arg) : path9.resolve(cwd, `star-session-${sessionId}.md`);
1504
+ const target = arg ? path10.resolve(cwd, arg) : path10.resolve(cwd, `star-session-${sessionId}.md`);
1213
1505
  const existed = await fileExists(target);
1214
- await fs7.mkdir(path9.dirname(target), { recursive: true });
1506
+ await fs7.mkdir(path10.dirname(target), { recursive: true });
1215
1507
  await fs7.writeFile(target, renderSessionMarkdown(sessionId, messages));
1216
1508
  return existed ? `Exported ${messages.length} messages to ${target} (overwrote existing file).` : `Exported ${messages.length} messages to ${target}.`;
1217
1509
  }
@@ -1333,8 +1625,8 @@ function formatSearchResults(hits, query) {
1333
1625
  }
1334
1626
 
1335
1627
  // src/cli/diff-preview.ts
1336
- import { readFile as readFile3 } from "fs/promises";
1337
- import path10 from "path";
1628
+ import { readFile as readFile4 } from "fs/promises";
1629
+ import path11 from "path";
1338
1630
  var CONTEXT_LINES = 2;
1339
1631
  var MAX_HUNK_LINES = 15;
1340
1632
  var MAX_TOTAL_LINES = 12;
@@ -1495,10 +1787,10 @@ async function generateDiffPreview(toolName, args, cwd) {
1495
1787
  if (toolName !== "edit_file" && toolName !== "write_file") return null;
1496
1788
  const filePath = getStringArg(args, "path");
1497
1789
  if (filePath === void 0) return null;
1498
- const absolute = path10.resolve(cwd, filePath);
1790
+ const absolute = path11.resolve(cwd, filePath);
1499
1791
  let existing;
1500
1792
  try {
1501
- existing = await readFile3(absolute, "utf8");
1793
+ existing = await readFile4(absolute, "utf8");
1502
1794
  } catch {
1503
1795
  existing = null;
1504
1796
  }
@@ -1795,6 +2087,19 @@ ${content}`);
1795
2087
  ctx.addSystemMessage(await ctx.rewind(args.trim()));
1796
2088
  }
1797
2089
  });
2090
+ register({
2091
+ name: "connect",
2092
+ description: "Interactive wizard: add an LLM provider + model, store the key safely, and save to config",
2093
+ usage: "/connect",
2094
+ category: "Settings",
2095
+ async run(_args, ctx) {
2096
+ if (!ctx.connect) {
2097
+ ctx.addSystemMessage("/connect: this context cannot run the interactive wizard.");
2098
+ return;
2099
+ }
2100
+ ctx.addSystemMessage(await ctx.connect());
2101
+ }
2102
+ });
1798
2103
  register({
1799
2104
  name: "init",
1800
2105
  description: "Generate an AGENTS.md for the current project",
@@ -1922,9 +2227,260 @@ ${result.diff}${note ? `
1922
2227
  });
1923
2228
  }
1924
2229
 
2230
+ // src/cli/commands/connect.ts
2231
+ import { spawn } from "child_process";
2232
+ import fs9 from "fs";
2233
+ import path12 from "path";
2234
+
2235
+ // src/config/env.ts
2236
+ import fs8 from "fs";
2237
+ var ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2238
+ function parseEnvContent(content) {
2239
+ const vars = {};
2240
+ for (const rawLine of content.split(/\r?\n/)) {
2241
+ const line = rawLine.trim();
2242
+ if (!line || line.startsWith("#")) continue;
2243
+ const eq = line.indexOf("=");
2244
+ if (eq <= 0) continue;
2245
+ const key = line.slice(0, eq).trim();
2246
+ if (!ENV_KEY_PATTERN.test(key)) continue;
2247
+ let value = line.slice(eq + 1).trim();
2248
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
2249
+ value = value.slice(1, -1);
2250
+ }
2251
+ vars[key] = value;
2252
+ }
2253
+ return vars;
2254
+ }
2255
+ function loadEnvFile(filePath = envFilePath()) {
2256
+ let content;
2257
+ try {
2258
+ content = fs8.readFileSync(filePath, "utf8");
2259
+ } catch {
2260
+ return;
2261
+ }
2262
+ for (const [key, value] of Object.entries(parseEnvContent(content))) {
2263
+ if (process.env[key] === void 0) {
2264
+ process.env[key] = value;
2265
+ }
2266
+ }
2267
+ }
2268
+ function upsertEnvContent(content, key, value) {
2269
+ const entry = `${key}=${value.replace(/[\r\n]/g, "")}`;
2270
+ const pattern = new RegExp(`^\\s*${key}\\s*=`);
2271
+ const lines = content.split("\n");
2272
+ while (lines.length > 0 && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
2273
+ for (let i = 0; i < lines.length; i++) {
2274
+ if (pattern.test(lines[i] ?? "")) {
2275
+ lines[i] = entry;
2276
+ return `${lines.join("\n")}
2277
+ `;
2278
+ }
2279
+ }
2280
+ lines.push(entry);
2281
+ return `${lines.join("\n")}
2282
+ `;
2283
+ }
2284
+
2285
+ // src/cli/commands/connect.ts
2286
+ var PROVIDER_PRESETS = [
2287
+ {
2288
+ key: "openai",
2289
+ label: "OpenAI",
2290
+ baseURL: "https://api.openai.com/v1",
2291
+ protocol: "openai-compatible",
2292
+ modelHint: "gpt-4o"
2293
+ },
2294
+ {
2295
+ key: "anthropic",
2296
+ label: "Anthropic",
2297
+ baseURL: "https://api.anthropic.com",
2298
+ protocol: "anthropic",
2299
+ modelHint: "claude-sonnet-4-5"
2300
+ },
2301
+ {
2302
+ key: "moonshot",
2303
+ label: "Kimi (Moonshot)",
2304
+ baseURL: "https://api.moonshot.cn/v1",
2305
+ protocol: "openai-compatible",
2306
+ modelHint: "kimi-k2-0905-preview"
2307
+ },
2308
+ {
2309
+ key: "deepseek",
2310
+ label: "DeepSeek",
2311
+ baseURL: "https://api.deepseek.com/v1",
2312
+ protocol: "openai-compatible",
2313
+ modelHint: "deepseek-chat"
2314
+ }
2315
+ ];
2316
+ var PROTOCOLS = ["openai-compatible", "anthropic", "openai-responses"];
2317
+ var PROTOCOL_DESCRIPTIONS = {
2318
+ "openai-compatible": "POST /chat/completions \u2014 most providers",
2319
+ anthropic: "Anthropic Messages API",
2320
+ "openai-responses": "OpenAI Responses API (endpoints exposing only /responses)"
2321
+ };
2322
+ var CONNECT_DEFAULT_CONTEXT_TOKENS = 128e3;
2323
+ function deriveProviderName(baseURL) {
2324
+ let host = baseURL;
2325
+ try {
2326
+ host = new URL(baseURL).hostname;
2327
+ } catch {
2328
+ host = baseURL.replace(/^[a-z]+:\/\//i, "").split(/[/:?#]/)[0] ?? baseURL;
2329
+ }
2330
+ const labels = host.toLowerCase().split(".");
2331
+ const skip = /* @__PURE__ */ new Set(["www", "api"]);
2332
+ const meaningful = labels.filter((label) => label !== "" && !skip.has(label));
2333
+ const raw = meaningful[0] ?? "provider";
2334
+ const slug = raw.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
2335
+ return slug || "provider";
2336
+ }
2337
+ function dedupeName(base, taken) {
2338
+ const names = new Set(taken);
2339
+ if (!names.has(base)) return base;
2340
+ for (let i = 2; ; i++) {
2341
+ const candidate = `${base}-${i}`;
2342
+ if (!names.has(candidate)) return candidate;
2343
+ }
2344
+ }
2345
+ function apiKeyEnvFor(providerName) {
2346
+ return `STAR_API_KEY_${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
2347
+ }
2348
+ function maskApiKey(key) {
2349
+ if (key.length <= 8) return "\u2022\u2022\u2022\u2022\u2022\u2022";
2350
+ return `${key.slice(0, 3)}\u2026${key.slice(-4)}`;
2351
+ }
2352
+ function tomlString(value) {
2353
+ return JSON.stringify(value);
2354
+ }
2355
+ function buildProviderTomlBlock(provider) {
2356
+ return [
2357
+ "[[providers]]",
2358
+ `name = ${tomlString(provider.name)}`,
2359
+ "# wire format: openai-compatible | anthropic | openai-responses",
2360
+ `protocol = ${tomlString(provider.protocol)}`,
2361
+ `baseURL = ${tomlString(provider.baseURL)}`,
2362
+ "# environment variable holding the API key; the key itself lives in",
2363
+ "# ~/.star-cli/.env (written by /connect), never in this file",
2364
+ `apiKeyEnv = ${tomlString(provider.apiKeyEnv ?? "")}`,
2365
+ "# inline key instead of apiKeyEnv (not recommended):",
2366
+ '# apiKey = "sk-..."',
2367
+ "# extra HTTP headers sent to the provider:",
2368
+ '# headers = { "X-Title" = "star-cli" }',
2369
+ "",
2370
+ ""
2371
+ ].join("\n");
2372
+ }
2373
+ function buildModelTomlBlock(model) {
2374
+ return [
2375
+ "[[models]]",
2376
+ `name = ${tomlString(model.name)}`,
2377
+ `provider = ${tomlString(model.provider)}`,
2378
+ `model = ${tomlString(model.model)}`,
2379
+ "# per-model context window in tokens \u2014 overrides the top-level",
2380
+ "# contextMaxTokens for compaction and the ctx % in the status bar",
2381
+ `contextMaxTokens = ${model.contextMaxTokens ?? CONNECT_DEFAULT_CONTEXT_TOKENS}`,
2382
+ "# pricing in USD per 1M tokens \u2014 set your provider's rates; BOTH",
2383
+ "# promptPrice and completionPrice are required together for the $",
2384
+ "# estimates in /cost and /usage (0 prices a token at $0)",
2385
+ `promptPrice = ${model.promptPrice ?? 0}`,
2386
+ `completionPrice = ${model.completionPrice ?? 0}`,
2387
+ "# cap on generated tokens per reply:",
2388
+ "# maxTokens = 8192",
2389
+ "",
2390
+ ""
2391
+ ].join("\n");
2392
+ }
2393
+ function saveConnection(answers, configPath = globalConfigPath(), envPath = envFilePath()) {
2394
+ const provider = {
2395
+ name: answers.providerName,
2396
+ protocol: answers.protocol,
2397
+ baseURL: answers.baseURL,
2398
+ apiKeyEnv: answers.apiKeyEnv
2399
+ };
2400
+ const model = {
2401
+ name: answers.modelName,
2402
+ provider: answers.providerName,
2403
+ model: answers.modelId,
2404
+ contextMaxTokens: CONNECT_DEFAULT_CONTEXT_TOKENS,
2405
+ promptPrice: 0,
2406
+ completionPrice: 0
2407
+ };
2408
+ fs9.mkdirSync(path12.dirname(envPath), { recursive: true });
2409
+ let envContent = "";
2410
+ try {
2411
+ envContent = fs9.readFileSync(envPath, "utf8");
2412
+ } catch {
2413
+ envContent = "";
2414
+ }
2415
+ fs9.writeFileSync(
2416
+ envPath,
2417
+ upsertEnvContent(envContent, provider.apiKeyEnv ?? "", answers.apiKey),
2418
+ {
2419
+ encoding: "utf8",
2420
+ mode: 384
2421
+ }
2422
+ );
2423
+ try {
2424
+ fs9.chmodSync(envPath, 384);
2425
+ } catch {
2426
+ }
2427
+ if (provider.apiKeyEnv) {
2428
+ process.env[provider.apiKeyEnv] = answers.apiKey;
2429
+ }
2430
+ fs9.mkdirSync(path12.dirname(configPath), { recursive: true });
2431
+ let existing = "";
2432
+ try {
2433
+ existing = fs9.readFileSync(configPath, "utf8");
2434
+ } catch {
2435
+ existing = "";
2436
+ }
2437
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2438
+ fs9.writeFileSync(
2439
+ configPath,
2440
+ existing + separator + buildProviderTomlBlock(provider) + buildModelTomlBlock(model),
2441
+ "utf8"
2442
+ );
2443
+ return { provider, model, configPath, envPath };
2444
+ }
2445
+ function setDefaultModelInToml(content, modelName) {
2446
+ const line = `defaultModel = ${tomlString(modelName)}`;
2447
+ const lines = content.split("\n");
2448
+ let inTopLevel = true;
2449
+ for (let i = 0; i < lines.length; i++) {
2450
+ const current = lines[i] ?? "";
2451
+ if (/^\s*\[/.test(current)) inTopLevel = false;
2452
+ if (inTopLevel && /^\s*defaultModel\s*=/.test(current)) {
2453
+ lines[i] = line;
2454
+ return lines.join("\n");
2455
+ }
2456
+ }
2457
+ return `${line}
2458
+ ${content}`;
2459
+ }
2460
+ function saveDefaultModel(modelName, configPath = globalConfigPath()) {
2461
+ let content = "";
2462
+ try {
2463
+ content = fs9.readFileSync(configPath, "utf8");
2464
+ } catch {
2465
+ content = "";
2466
+ }
2467
+ fs9.mkdirSync(path12.dirname(configPath), { recursive: true });
2468
+ fs9.writeFileSync(configPath, setDefaultModelInToml(content, modelName), "utf8");
2469
+ }
2470
+ function openConfigFile(filePath) {
2471
+ const [command, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", filePath]] : process.platform === "darwin" ? ["open", [filePath]] : ["xdg-open", [filePath]];
2472
+ try {
2473
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
2474
+ child.on("error", () => {
2475
+ });
2476
+ child.unref();
2477
+ } catch {
2478
+ }
2479
+ }
2480
+
1925
2481
  // src/cli/commands/custom.ts
1926
2482
  import { readFileSync as readFileSync2, readdirSync } from "fs";
1927
- import path11 from "path";
2483
+ import path13 from "path";
1928
2484
  var NAME_PATTERN = /^[a-z0-9-]+$/;
1929
2485
  var DESCRIPTION_PATTERN = /^<!--\s*description:\s*(.+?)\s*-->\s*$/;
1930
2486
  var DEFAULT_DESCRIPTION = "Custom prompt command";
@@ -1959,14 +2515,14 @@ function scanDir(dir, into) {
1959
2515
  if (!entry.endsWith(".md")) continue;
1960
2516
  const name = entry.slice(0, -".md".length);
1961
2517
  if (!NAME_PATTERN.test(name)) continue;
1962
- const parsed = parseCommandFile(path11.join(dir, entry), name);
2518
+ const parsed = parseCommandFile(path13.join(dir, entry), name);
1963
2519
  if (parsed) into.set(name, parsed);
1964
2520
  }
1965
2521
  }
1966
2522
  function loadCustomCommands(cwd, home = starHome()) {
1967
2523
  const byName = /* @__PURE__ */ new Map();
1968
- scanDir(path11.join(home, "commands"), byName);
1969
- scanDir(path11.join(cwd, ".star", "commands"), byName);
2524
+ scanDir(path13.join(home, "commands"), byName);
2525
+ scanDir(path13.join(cwd, ".star", "commands"), byName);
1970
2526
  return [...byName.values()];
1971
2527
  }
1972
2528
  function registerCustomCommands(registry, cwd, home) {
@@ -1992,8 +2548,8 @@ function registerCustomCommands(registry, cwd, home) {
1992
2548
  }
1993
2549
 
1994
2550
  // src/cli/commands/doctor.ts
1995
- import fs8 from "fs/promises";
1996
- import path12 from "path";
2551
+ import fs10 from "fs/promises";
2552
+ import path14 from "path";
1997
2553
  var MIN_NODE_MAJOR = 20;
1998
2554
  var REGISTRY_URL = "https://registry.npmjs.org/";
1999
2555
  var REGISTRY_TIMEOUT_MS = 5e3;
@@ -2007,7 +2563,7 @@ function nodeMajor(version) {
2007
2563
  }
2008
2564
  async function pathExists(target) {
2009
2565
  try {
2010
- await fs8.access(target);
2566
+ await fs10.access(target);
2011
2567
  return true;
2012
2568
  } catch {
2013
2569
  return false;
@@ -2045,7 +2601,7 @@ async function runDoctor(opts) {
2045
2601
  detail: `${spec.label} (${spec.shell})`
2046
2602
  });
2047
2603
  }
2048
- const configPath = path12.join(home, "config.toml");
2604
+ const configPath = path14.join(home, "config.toml");
2049
2605
  const configExists = await pathExists(configPath);
2050
2606
  checks.push({
2051
2607
  name: "STAR_HOME / config",
@@ -2082,14 +2638,14 @@ async function runDoctor(opts) {
2082
2638
  });
2083
2639
  }
2084
2640
  }
2085
- const sessions = path12.join(home, "sessions");
2641
+ const sessions = path14.join(home, "sessions");
2086
2642
  let writable = false;
2087
2643
  let writeError = "";
2088
2644
  try {
2089
- await fs8.mkdir(sessions, { recursive: true });
2090
- const probe = path12.join(sessions, `.doctor-probe-${process.pid}`);
2091
- await fs8.writeFile(probe, "ok");
2092
- await fs8.unlink(probe);
2645
+ await fs10.mkdir(sessions, { recursive: true });
2646
+ const probe = path14.join(sessions, `.doctor-probe-${process.pid}`);
2647
+ await fs10.writeFile(probe, "ok");
2648
+ await fs10.unlink(probe);
2093
2649
  writable = true;
2094
2650
  } catch (error) {
2095
2651
  writeError = error instanceof Error ? error.message : String(error);
@@ -2124,15 +2680,15 @@ function formatDoctorReport(report) {
2124
2680
  }
2125
2681
 
2126
2682
  // src/cli/commands/init-project.ts
2127
- import fs9 from "fs/promises";
2128
- import path13 from "path";
2683
+ import fs11 from "fs/promises";
2684
+ import path15 from "path";
2129
2685
  import { generateText } from "ai";
2130
2686
  var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", "coverage"]);
2131
2687
  var MAX_ENTRIES = 20;
2132
2688
  var README_HEAD_LINES = 5;
2133
2689
  async function fileExists2(target) {
2134
2690
  try {
2135
- await fs9.access(target);
2691
+ await fs11.access(target);
2136
2692
  return true;
2137
2693
  } catch {
2138
2694
  return false;
@@ -2140,7 +2696,7 @@ async function fileExists2(target) {
2140
2696
  }
2141
2697
  async function readPackageJson(cwd) {
2142
2698
  try {
2143
- const raw = await fs9.readFile(path13.join(cwd, "package.json"), "utf8");
2699
+ const raw = await fs11.readFile(path15.join(cwd, "package.json"), "utf8");
2144
2700
  const parsed = JSON.parse(raw);
2145
2701
  if (typeof parsed !== "object" || parsed === null) return null;
2146
2702
  return parsed;
@@ -2157,10 +2713,10 @@ function stringRecord(value) {
2157
2713
  return out;
2158
2714
  }
2159
2715
  async function detectPackageManager(cwd) {
2160
- if (await fileExists2(path13.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2161
- if (await fileExists2(path13.join(cwd, "yarn.lock"))) return "yarn";
2162
- if (await fileExists2(path13.join(cwd, "package-lock.json"))) return "npm";
2163
- if (await fileExists2(path13.join(cwd, "bun.lockb"))) return "bun";
2716
+ if (await fileExists2(path15.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2717
+ if (await fileExists2(path15.join(cwd, "yarn.lock"))) return "yarn";
2718
+ if (await fileExists2(path15.join(cwd, "package-lock.json"))) return "npm";
2719
+ if (await fileExists2(path15.join(cwd, "bun.lockb"))) return "bun";
2164
2720
  return null;
2165
2721
  }
2166
2722
  function inferCommands(pm, scripts) {
@@ -2175,7 +2731,7 @@ function inferCommands(pm, scripts) {
2175
2731
  }
2176
2732
  async function listEntries(cwd) {
2177
2733
  try {
2178
- const dirents = await fs9.readdir(cwd, { withFileTypes: true });
2734
+ const dirents = await fs11.readdir(cwd, { withFileTypes: true });
2179
2735
  const entries = [];
2180
2736
  for (const dirent of dirents) {
2181
2737
  if (dirent.isDirectory()) {
@@ -2194,7 +2750,7 @@ async function listEntries(cwd) {
2194
2750
  async function readReadmeHead(cwd) {
2195
2751
  for (const name of ["README.md", "readme.md", "README"]) {
2196
2752
  try {
2197
- const raw = await fs9.readFile(path13.join(cwd, name), "utf8");
2753
+ const raw = await fs11.readFile(path15.join(cwd, name), "utf8");
2198
2754
  return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).slice(0, README_HEAD_LINES);
2199
2755
  } catch {
2200
2756
  }
@@ -2207,14 +2763,14 @@ async function scanProject(cwd) {
2207
2763
  const scripts = stringRecord(pkg?.scripts);
2208
2764
  return {
2209
2765
  name: typeof pkg?.name === "string" ? pkg.name : null,
2210
- dirName: path13.basename(cwd),
2766
+ dirName: path15.basename(cwd),
2211
2767
  packageManager,
2212
2768
  scripts,
2213
2769
  dependencies: Object.keys(stringRecord(pkg?.dependencies)),
2214
2770
  devDependencies: Object.keys(stringRecord(pkg?.devDependencies)),
2215
2771
  commands: inferCommands(packageManager, scripts),
2216
- hasTsconfig: await fileExists2(path13.join(cwd, "tsconfig.json")),
2217
- isGitRepo: await fileExists2(path13.join(cwd, ".git")),
2772
+ hasTsconfig: await fileExists2(path15.join(cwd, "tsconfig.json")),
2773
+ isGitRepo: await fileExists2(path15.join(cwd, ".git")),
2218
2774
  readmeHead: await readReadmeHead(cwd),
2219
2775
  entries: await listEntries(cwd)
2220
2776
  };
@@ -2281,7 +2837,7 @@ Write the AGENTS.md.`
2281
2837
  }
2282
2838
  async function initProject(opts) {
2283
2839
  const { cwd, args, model } = opts;
2284
- const target = path13.join(cwd, "AGENTS.md");
2840
+ const target = path15.join(cwd, "AGENTS.md");
2285
2841
  const force = args.split(/\s+/).includes("force");
2286
2842
  if (!force && await fileExists2(target)) {
2287
2843
  return {
@@ -2315,7 +2871,7 @@ async function initProject(opts) {
2315
2871
  content = renderAgentsMd(facts);
2316
2872
  note = " No model available; used the template \u2014 review and refine manually.";
2317
2873
  }
2318
- await fs9.writeFile(target, content);
2874
+ await fs11.writeFile(target, content);
2319
2875
  const lines = content.split("\n").length;
2320
2876
  const how = usedLlm ? "generated with model refinement" : "generated from template";
2321
2877
  return {
@@ -2357,7 +2913,7 @@ function parseSlashCommand(input) {
2357
2913
  }
2358
2914
 
2359
2915
  // src/cli/commands/rewind.ts
2360
- import path14 from "path";
2916
+ import path16 from "path";
2361
2917
  function planRewind(snapshots, id) {
2362
2918
  const target = snapshots.find((snapshot) => snapshot.id === id);
2363
2919
  if (!target) return null;
@@ -2371,7 +2927,7 @@ function planRewind(snapshots, id) {
2371
2927
  return { target, affected, messageIndex };
2372
2928
  }
2373
2929
  function displayPath(filePath, cwd) {
2374
- const relative = path14.relative(cwd, filePath);
2930
+ const relative = path16.relative(cwd, filePath);
2375
2931
  return relative !== "" && !relative.startsWith("..") ? relative : filePath;
2376
2932
  }
2377
2933
  function formatTime(timestamp) {
@@ -2421,29 +2977,110 @@ function didYouMeanSuffix(names) {
2421
2977
  return ` Did you mean: ${listed.join(", ")}?`;
2422
2978
  }
2423
2979
 
2424
- // src/cli/components/ExitPlanPrompt.tsx
2980
+ // src/cli/commands/undo.ts
2981
+ import { readFile as readFile5 } from "fs/promises";
2982
+ import path17 from "path";
2983
+ function displayPath2(filePath, cwd) {
2984
+ const relative = path17.relative(cwd, filePath);
2985
+ return relative !== "" && !relative.startsWith("..") ? relative : filePath;
2986
+ }
2987
+ async function buildUndoDiffs(snapshots, cwd) {
2988
+ const diffs = [];
2989
+ for (const snapshot of snapshots) {
2990
+ let current = "";
2991
+ try {
2992
+ current = await readFile5(snapshot.path, "utf8");
2993
+ } catch {
2994
+ }
2995
+ const before = snapshot.existed ? await resolveSnapshotContent(snapshot) : "";
2996
+ const lines = before === null ? [{ kind: "marker", text: "(snapshot content unavailable)" }] : buildDiffLines(current, before);
2997
+ diffs.push({ label: displayPath2(snapshot.path, cwd), lines });
2998
+ }
2999
+ return diffs;
3000
+ }
3001
+
3002
+ // src/cli/components/ConnectWizard.tsx
3003
+ import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
3004
+ import { useRef as useRef4, useState as useState4 } from "react";
3005
+
3006
+ // src/cli/components/SelectPrompt.tsx
2425
3007
  import { Box, Text, useInput } from "ink";
3008
+ import { useRef, useState } from "react";
2426
3009
  import { jsx, jsxs } from "react/jsx-runtime";
2427
- function ExitPlanPrompt({ onDecision }) {
3010
+ var SELECT_PROMPT_MAX_VISIBLE = 9;
3011
+ function SelectPrompt({ title, options, onSelect, onCancel }) {
3012
+ const [index, setIndex] = useState(0);
3013
+ const indexRef = useRef(0);
3014
+ const move = (delta) => {
3015
+ indexRef.current = Math.max(0, Math.min(indexRef.current + delta, options.length - 1));
3016
+ setIndex(indexRef.current);
3017
+ };
2428
3018
  useInput((input, key) => {
2429
- const ch = input.toLowerCase();
2430
- if (ch === "y") onDecision("yes");
2431
- else if (ch === "n" || key.escape) onDecision("no");
3019
+ if (key.escape) {
3020
+ onCancel();
3021
+ return;
3022
+ }
3023
+ if (key.return) {
3024
+ const option = options[indexRef.current];
3025
+ if (option) onSelect(option.value);
3026
+ return;
3027
+ }
3028
+ if (key.downArrow || input === "j") {
3029
+ move(1);
3030
+ } else if (key.upArrow || input === "k") {
3031
+ move(-1);
3032
+ }
2432
3033
  });
3034
+ const maxStart = Math.max(0, options.length - SELECT_PROMPT_MAX_VISIBLE);
3035
+ const start = Math.min(Math.max(0, index - Math.floor(SELECT_PROMPT_MAX_VISIBLE / 2)), maxStart);
3036
+ const visible = options.slice(start, start + SELECT_PROMPT_MAX_VISIBLE);
3037
+ const above = start;
3038
+ const below = options.length - start - visible.length;
2433
3039
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
2434
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Plan ready (plan mode)" }),
2435
- /* @__PURE__ */ jsx(Text, { children: "The plan above was produced without making any changes." }),
2436
- /* @__PURE__ */ jsx(Text, { children: "[y] approve and execute [n] keep refining (Esc = n)" })
2437
- ] });
3040
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: title }),
3041
+ above > 0 && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3042
+ "\u2026 ",
3043
+ above,
3044
+ " more above"
3045
+ ] }),
3046
+ visible.map((option, i) => {
3047
+ const active = start + i === index;
3048
+ return /* @__PURE__ */ jsxs(Text, { children: [
3049
+ /* @__PURE__ */ jsxs(Text, { bold: active, inverse: active, children: [
3050
+ active ? "\u276F " : " ",
3051
+ option.label
3052
+ ] }),
3053
+ option.hint && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3054
+ " (",
3055
+ option.hint,
3056
+ ")"
3057
+ ] }),
3058
+ option.description && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3059
+ " \u2014 ",
3060
+ option.description
3061
+ ] })
3062
+ ] }, option.value);
3063
+ }),
3064
+ below > 0 && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3065
+ "\u2026 ",
3066
+ below,
3067
+ " more below"
3068
+ ] }),
3069
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "[\u2191/\u2193 or j/k] move [Enter] select [Esc] cancel" })
3070
+ ] });
2438
3071
  }
2439
3072
 
3073
+ // src/cli/components/TextField.tsx
3074
+ import { Box as Box3, Text as Text3, useInput as useInput3 } from "ink";
3075
+ import { useRef as useRef3, useState as useState3 } from "react";
3076
+
2440
3077
  // src/cli/components/InputBox.tsx
2441
- import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
2442
- import { useEffect, useRef, useState } from "react";
3078
+ import { Box as Box2, Text as Text2, useInput as useInput2, useStdout } from "ink";
3079
+ import { useEffect, useRef as useRef2, useState as useState2 } from "react";
2443
3080
 
2444
3081
  // src/cli/path-suggest.ts
2445
- import { readdir } from "fs/promises";
2446
- import path15 from "path";
3082
+ import { readdir as readdir2 } from "fs/promises";
3083
+ import path18 from "path";
2447
3084
  var MAX_SCAN_ENTRIES = 2e3;
2448
3085
  function extractAtToken(value, cursor) {
2449
3086
  const before = value.slice(0, cursor);
@@ -2458,8 +3095,8 @@ async function suggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSu
2458
3095
  const dirPart = slash >= 0 ? normalized.slice(0, slash + 1) : "";
2459
3096
  const basePrefix = slash >= 0 ? normalized.slice(slash + 1) : normalized;
2460
3097
  if (dirPart.split("/").some((segment) => SKIP_DIRS.has(segment))) return [];
2461
- const dirAbs = path15.resolve(cwd, dirPart === "" ? "." : dirPart);
2462
- const entries = await readdir(dirAbs, { withFileTypes: true }).catch(() => null);
3098
+ const dirAbs = path18.resolve(cwd, dirPart === "" ? "." : dirPart);
3099
+ const entries = await readdir2(dirAbs, { withFileTypes: true }).catch(() => null);
2463
3100
  if (!entries) return [];
2464
3101
  const ignore = createIgnorePredicate(cwd);
2465
3102
  const lower = basePrefix.toLowerCase();
@@ -2473,7 +3110,7 @@ async function suggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSu
2473
3110
  if (isDir && SKIP_DIRS.has(entry.name)) continue;
2474
3111
  if (entry.name.startsWith(".") && !basePrefix.startsWith(".")) continue;
2475
3112
  if (!entry.name.toLowerCase().startsWith(lower)) continue;
2476
- if (ignore?.(path15.join(dirAbs, entry.name), isDir)) continue;
3113
+ if (ignore?.(path18.join(dirAbs, entry.name), isDir)) continue;
2477
3114
  matches.push({ path: `${dirPart}${entry.name}${isDir ? "/" : ""}`, isDir });
2478
3115
  }
2479
3116
  matches.sort((a, b) => a.path.localeCompare(b.path));
@@ -2482,6 +3119,95 @@ async function suggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSu
2482
3119
 
2483
3120
  // src/cli/components/InputBox.tsx
2484
3121
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
3122
+ function moveCursorLines(value, cursor, delta) {
3123
+ const lineStart = value.lastIndexOf("\n", cursor - 1) + 1;
3124
+ const col = cursor - lineStart;
3125
+ if (delta === -1) {
3126
+ if (lineStart === 0) return null;
3127
+ const prevEnd = lineStart - 1;
3128
+ const prevStart = value.lastIndexOf("\n", prevEnd - 1) + 1;
3129
+ return prevStart + Math.min(col, prevEnd - prevStart);
3130
+ }
3131
+ const lineEndIndex = value.indexOf("\n", cursor);
3132
+ if (lineEndIndex === -1) return null;
3133
+ const nextStart = lineEndIndex + 1;
3134
+ const nextEndIndex = value.indexOf("\n", nextStart);
3135
+ const nextEnd = nextEndIndex === -1 ? value.length : nextEndIndex;
3136
+ return nextStart + Math.min(col, nextEnd - nextStart);
3137
+ }
3138
+ function findHistoryMatch(history, query, from, step) {
3139
+ const needle = query.toLowerCase();
3140
+ for (let i = from; i >= 0 && i < history.length; i += step) {
3141
+ if ((history[i] ?? "").toLowerCase().includes(needle)) return i;
3142
+ }
3143
+ return null;
3144
+ }
3145
+ var PASTE_COLLAPSE_MIN_LINES = 10;
3146
+ var PASTE_COLLAPSE_MIN_CHARS = 500;
3147
+ var PASTE_MERGE_WINDOW_MS = 100;
3148
+ var PASTE_MERGE_MIN_CHARS = 32;
3149
+ var PASTE_DELIM = "\u200B";
3150
+ var PASTE_TOKEN_RE = /\u200B\[pasted #(\d+): \d+ lines?\]\u200B/g;
3151
+ var BRACKETED_PASTE_START = "\x1B[200~";
3152
+ var BRACKETED_PASTE_END = "\x1B[201~";
3153
+ var PARTIAL_BRACKET_RE = /\u001B(?:\[(?:2(?:0(?:[01])?)?)?)?$/;
3154
+ function feedBracketedPaste(state, raw) {
3155
+ let stream = state.pending + raw;
3156
+ state.pending = "";
3157
+ if (stream.startsWith("[200~") || stream.startsWith("[201~")) {
3158
+ stream = `\x1B${stream}`;
3159
+ }
3160
+ const segments = [];
3161
+ let buffer = state.buffer;
3162
+ let bracketed = state.active;
3163
+ let i = 0;
3164
+ const pushText = (text) => {
3165
+ const cleaned = text.replaceAll("\x1B", "");
3166
+ if (cleaned.length > 0) segments.push({ type: "text", text: cleaned });
3167
+ };
3168
+ const holdPartial = (tail) => {
3169
+ const partial = PARTIAL_BRACKET_RE.exec(tail);
3170
+ if (!partial) return tail;
3171
+ state.pending = partial[0];
3172
+ return tail.slice(0, tail.length - partial[0].length);
3173
+ };
3174
+ while (i < stream.length) {
3175
+ if (bracketed) {
3176
+ const idx = stream.indexOf(BRACKETED_PASTE_END, i);
3177
+ if (idx === -1) {
3178
+ buffer += holdPartial(stream.slice(i));
3179
+ i = stream.length;
3180
+ } else {
3181
+ buffer += stream.slice(i, idx);
3182
+ segments.push({ type: "paste", text: buffer });
3183
+ buffer = "";
3184
+ bracketed = false;
3185
+ i = idx + BRACKETED_PASTE_END.length;
3186
+ }
3187
+ } else {
3188
+ const idx = stream.indexOf(BRACKETED_PASTE_START, i);
3189
+ if (idx === -1) {
3190
+ pushText(holdPartial(stream.slice(i)));
3191
+ i = stream.length;
3192
+ } else {
3193
+ pushText(stream.slice(i, idx));
3194
+ buffer = "";
3195
+ bracketed = true;
3196
+ i = idx + BRACKETED_PASTE_START.length;
3197
+ }
3198
+ }
3199
+ }
3200
+ state.active = bracketed;
3201
+ state.buffer = buffer;
3202
+ return segments.length === 0 ? null : segments;
3203
+ }
3204
+ function pasteToken(id, lineCount) {
3205
+ const label = `[pasted #${id}: ${lineCount} ${lineCount === 1 ? "line" : "lines"}]`;
3206
+ return `${PASTE_DELIM}${label}${PASTE_DELIM}`;
3207
+ }
3208
+ function stylePasteTokens(segment) {
3209
+ return segment.replace(PASTE_TOKEN_RE, (match) => `\x1B[2m${match}\x1B[22m`);
3210
+ }
2485
3211
  function InputBox({
2486
3212
  isStreaming,
2487
3213
  disabled,
@@ -2494,28 +3220,126 @@ function InputBox({
2494
3220
  onExit,
2495
3221
  onPasteImage
2496
3222
  }) {
2497
- const [value, setValue] = useState("");
2498
- const [cursor, setCursor] = useState(0);
2499
- const [history, setHistory] = useState(() => initialHistory ?? []);
2500
- const [highlight, setHighlight] = useState(0);
2501
- const [suggestionsDismissed, setSuggestionsDismissed] = useState(false);
2502
- const [pathSuggestions, setPathSuggestions] = useState([]);
2503
- const historyIndexRef = useRef(null);
2504
- const draftRef = useRef("");
2505
- const refillSeqRef = useRef(0);
3223
+ const [value, setValue] = useState2("");
3224
+ const [cursor, setCursor] = useState2(0);
3225
+ const [history, setHistory] = useState2(() => initialHistory ?? []);
3226
+ const [highlight, setHighlight] = useState2(0);
3227
+ const [suggestionsDismissed, setSuggestionsDismissed] = useState2(false);
3228
+ const [pathSuggestions, setPathSuggestions] = useState2([]);
3229
+ const historyIndexRef = useRef2(null);
3230
+ const draftRef = useRef2("");
3231
+ const refillSeqRef = useRef2(0);
3232
+ const [search, setSearch] = useState2(null);
3233
+ const searchSavedRef = useRef2(null);
3234
+ const pastesRef = useRef2(/* @__PURE__ */ new Map());
3235
+ const pasteSeqRef = useRef2(0);
3236
+ const bracketRef = useRef2({ active: false, buffer: "", pending: "" });
3237
+ const lastPasteRef = useRef2(
3238
+ null
3239
+ );
3240
+ const valueRef = useRef2(value);
3241
+ const cursorRef = useRef2(cursor);
3242
+ useEffect(() => {
3243
+ valueRef.current = value;
3244
+ cursorRef.current = cursor;
3245
+ });
3246
+ const { stdout } = useStdout();
3247
+ useEffect(() => {
3248
+ if (!stdout?.isTTY) return;
3249
+ stdout.write("\x1B[?2004h");
3250
+ return () => {
3251
+ stdout.write("\x1B[?2004l");
3252
+ };
3253
+ }, [stdout]);
3254
+ const expandPastes = (raw) => raw.replace(PASTE_TOKEN_RE, (match, id) => pastesRef.current.get(Number(id)) ?? match);
3255
+ const clearPastes = () => {
3256
+ pastesRef.current.clear();
3257
+ pasteSeqRef.current = 0;
3258
+ lastPasteRef.current = null;
3259
+ const bracket = bracketRef.current;
3260
+ bracket.active = false;
3261
+ bracket.buffer = "";
3262
+ bracket.pending = "";
3263
+ };
2506
3264
  const edit = (next, nextCursor) => {
3265
+ const idx = historyIndexRef.current;
3266
+ if (idx !== null && next !== history[idx]) historyIndexRef.current = null;
3267
+ const clamped = Math.max(0, Math.min(nextCursor, next.length));
3268
+ valueRef.current = next;
3269
+ cursorRef.current = clamped;
2507
3270
  setValue(next);
2508
- setCursor(Math.max(0, Math.min(nextCursor, next.length)));
3271
+ setCursor(clamped);
2509
3272
  setHighlight(0);
2510
3273
  setSuggestionsDismissed(false);
2511
3274
  };
3275
+ const normalizeNewlines = (text) => text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3276
+ const collapseOrInsert = (text) => {
3277
+ const currentValue = valueRef.current;
3278
+ const currentCursor = cursorRef.current;
3279
+ const lineCount = text.split("\n").length;
3280
+ if (lineCount >= PASTE_COLLAPSE_MIN_LINES || text.length >= PASTE_COLLAPSE_MIN_CHARS) {
3281
+ const id = ++pasteSeqRef.current;
3282
+ pastesRef.current.set(id, text);
3283
+ const token = pasteToken(id, lineCount);
3284
+ const nextCursor = currentCursor + token.length;
3285
+ edit(
3286
+ currentValue.slice(0, currentCursor) + token + currentValue.slice(currentCursor),
3287
+ nextCursor
3288
+ );
3289
+ lastPasteRef.current = { id, token, cursor: nextCursor, time: Date.now() };
3290
+ return;
3291
+ }
3292
+ edit(
3293
+ currentValue.slice(0, currentCursor) + text + currentValue.slice(currentCursor),
3294
+ currentCursor + text.length
3295
+ );
3296
+ };
3297
+ const insertPlainText = (rawText) => {
3298
+ const text = normalizeNewlines(rawText);
3299
+ const currentValue = valueRef.current;
3300
+ const currentCursor = cursorRef.current;
3301
+ const last = lastPasteRef.current;
3302
+ if (last && Date.now() - last.time <= PASTE_MERGE_WINDOW_MS && currentCursor === last.cursor && currentValue.endsWith(last.token, currentCursor) && (text.includes("\n") || text.length >= PASTE_MERGE_MIN_CHARS)) {
3303
+ const merged = (pastesRef.current.get(last.id) ?? "") + text;
3304
+ pastesRef.current.set(last.id, merged);
3305
+ const token = pasteToken(last.id, merged.split("\n").length);
3306
+ const start = currentCursor - last.token.length;
3307
+ const nextCursor = start + token.length;
3308
+ edit(currentValue.slice(0, start) + token + currentValue.slice(currentCursor), nextCursor);
3309
+ lastPasteRef.current = { id: last.id, token, cursor: nextCursor, time: Date.now() };
3310
+ return;
3311
+ }
3312
+ collapseOrInsert(text);
3313
+ };
3314
+ const flushBracketPaste = () => {
3315
+ const bracket = bracketRef.current;
3316
+ const content = bracket.buffer;
3317
+ bracket.active = false;
3318
+ bracket.buffer = "";
3319
+ bracket.pending = "";
3320
+ if (content.length > 0) collapseOrInsert(normalizeNewlines(content));
3321
+ };
3322
+ const applyPasteSegments = (segments) => {
3323
+ if (search) {
3324
+ const query = search.query + normalizeNewlines(segments.map((seg) => seg.text).join(""));
3325
+ setSearch({ query, match: findHistoryMatch(history, query, history.length - 1, -1) });
3326
+ return;
3327
+ }
3328
+ for (const seg of segments) {
3329
+ if (seg.type === "paste") {
3330
+ collapseOrInsert(normalizeNewlines(seg.text));
3331
+ } else {
3332
+ insertPlainText(seg.text);
3333
+ }
3334
+ }
3335
+ };
2512
3336
  useEffect(() => {
2513
3337
  if (refill && refill.seq !== refillSeqRef.current) {
2514
3338
  refillSeqRef.current = refill.seq;
2515
3339
  edit(refill.text, refill.text.length);
2516
3340
  }
2517
3341
  });
2518
- const suggestionsEnabled = !isStreaming && !disabled && !suggestionsDismissed;
3342
+ const suggestionsEnabled = !isStreaming && !disabled && !suggestionsDismissed && !search;
2519
3343
  const suggestions = suggestionsEnabled && commands ? filterCommands(value, commands) : [];
2520
3344
  useEffect(() => {
2521
3345
  if (!suggestionsEnabled || !cwd) {
@@ -2556,11 +3380,87 @@ function InputBox({
2556
3380
  );
2557
3381
  };
2558
3382
  useInput2((input, key) => {
3383
+ if (!disabled) {
3384
+ const bracket = bracketRef.current;
3385
+ const feedable = input.length > 0 && !key.ctrl && !key.meta;
3386
+ if (bracket.active && (!feedable || input === "\r")) {
3387
+ flushBracketPaste();
3388
+ if (feedable) return;
3389
+ }
3390
+ if (feedable) {
3391
+ const segments = feedBracketedPaste(bracket, input);
3392
+ if (segments === null) return;
3393
+ const only = segments.length === 1 ? segments[0] : void 0;
3394
+ if (!only || only.type !== "text" || only.text !== input) {
3395
+ applyPasteSegments(segments);
3396
+ return;
3397
+ }
3398
+ } else {
3399
+ bracket.pending = "";
3400
+ }
3401
+ }
3402
+ if (key.ctrl && input === "r") {
3403
+ if (isStreaming || disabled) return;
3404
+ if (search) {
3405
+ const from = (search.match ?? history.length) - 1;
3406
+ const older = findHistoryMatch(history, search.query, from, -1);
3407
+ if (older !== null) setSearch({ ...search, match: older });
3408
+ } else {
3409
+ searchSavedRef.current = { value, cursor };
3410
+ setSearch({ query: "", match: history.length > 0 ? history.length - 1 : null });
3411
+ }
3412
+ return;
3413
+ }
3414
+ if (search) {
3415
+ const exitSearch = (accept) => {
3416
+ const saved = searchSavedRef.current;
3417
+ setSearch(null);
3418
+ searchSavedRef.current = null;
3419
+ const match = accept && search.match !== null ? history[search.match] : void 0;
3420
+ if (match !== void 0) {
3421
+ edit(match, match.length);
3422
+ } else if (saved) {
3423
+ edit(saved.value, saved.cursor);
3424
+ }
3425
+ };
3426
+ if (key.escape || key.ctrl && (input === "c" || input === "g")) {
3427
+ exitSearch(false);
3428
+ return;
3429
+ }
3430
+ if (key.return) {
3431
+ exitSearch(true);
3432
+ return;
3433
+ }
3434
+ const requery = (query) => setSearch({ query, match: findHistoryMatch(history, query, history.length - 1, -1) });
3435
+ if (key.backspace || key.delete) {
3436
+ requery(search.query.slice(0, -1));
3437
+ return;
3438
+ }
3439
+ if (key.upArrow) {
3440
+ const from = (search.match ?? history.length) - 1;
3441
+ const older = findHistoryMatch(history, search.query, from, -1);
3442
+ if (older !== null) setSearch({ ...search, match: older });
3443
+ return;
3444
+ }
3445
+ if (key.downArrow) {
3446
+ if (search.match !== null) {
3447
+ const newer = findHistoryMatch(history, search.query, search.match + 1, 1);
3448
+ if (newer !== null) setSearch({ ...search, match: newer });
3449
+ }
3450
+ return;
3451
+ }
3452
+ if (input && !key.ctrl && !key.meta) {
3453
+ const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3454
+ requery(search.query + text);
3455
+ }
3456
+ return;
3457
+ }
2559
3458
  if (key.ctrl && input === "c") {
2560
3459
  if (isStreaming || disabled) {
2561
3460
  onInterrupt();
2562
3461
  } else {
2563
3462
  edit("", 0);
3463
+ clearPastes();
2564
3464
  historyIndexRef.current = null;
2565
3465
  }
2566
3466
  return;
@@ -2579,27 +3479,28 @@ function InputBox({
2579
3479
  return;
2580
3480
  }
2581
3481
  if (key.return) {
2582
- if (key.shift) {
2583
- edit(`${value.slice(0, cursor)}
2584
- ${value.slice(cursor)}`, cursor + 1);
2585
- return;
2586
- }
2587
- if (value[cursor - 1] === "\\") {
2588
- edit(`${value.slice(0, cursor - 1)}
2589
- ${value.slice(cursor)}`, cursor);
3482
+ const currentValue = valueRef.current;
3483
+ const currentCursor = cursorRef.current;
3484
+ if (currentValue[currentCursor - 1] === "\\") {
3485
+ edit(
3486
+ `${currentValue.slice(0, currentCursor - 1)}
3487
+ ${currentValue.slice(currentCursor)}`,
3488
+ currentCursor
3489
+ );
2590
3490
  return;
2591
3491
  }
2592
- const text = value.trim();
3492
+ const text = expandPastes(currentValue).trim();
2593
3493
  if (text.length > 0) {
2594
- setHistory((prev) => [...prev, text]);
3494
+ if (!text.startsWith("/")) setHistory((prev) => [...prev, text]);
2595
3495
  onSubmit(text);
2596
3496
  }
2597
3497
  edit("", 0);
3498
+ clearPastes();
2598
3499
  historyIndexRef.current = null;
2599
3500
  draftRef.current = "";
2600
3501
  return;
2601
3502
  }
2602
- if (input === "\n" || input === "\r" || /^\[13;\d+u$/.test(input)) {
3503
+ if (input === "\n" || input === "\r") {
2603
3504
  edit(`${value.slice(0, cursor)}
2604
3505
  ${value.slice(cursor)}`, cursor + 1);
2605
3506
  return;
@@ -2611,34 +3512,47 @@ ${value.slice(cursor)}`, cursor + 1);
2611
3512
  return;
2612
3513
  }
2613
3514
  } else if (key.upArrow) {
3515
+ if (historyIndexRef.current !== null) {
3516
+ if (historyIndexRef.current > 0) {
3517
+ historyIndexRef.current -= 1;
3518
+ const entry2 = history[historyIndexRef.current] ?? "";
3519
+ edit(entry2, entry2.length);
3520
+ }
3521
+ return;
3522
+ }
2614
3523
  if (suggestionCount > 0) {
2615
3524
  setHighlight((prev) => (prev - 1 + suggestionCount) % suggestionCount);
2616
3525
  return;
2617
3526
  }
2618
- if (history.length === 0) return;
2619
- if (historyIndexRef.current === null) {
2620
- draftRef.current = value;
2621
- historyIndexRef.current = history.length - 1;
2622
- } else if (historyIndexRef.current > 0) {
2623
- historyIndexRef.current -= 1;
3527
+ const moved = moveCursorLines(value, cursor, -1);
3528
+ if (moved !== null) {
3529
+ setCursor(moved);
3530
+ return;
2624
3531
  }
3532
+ if (history.length === 0) return;
3533
+ draftRef.current = value;
3534
+ historyIndexRef.current = history.length - 1;
2625
3535
  const entry = history[historyIndexRef.current] ?? "";
2626
3536
  edit(entry, entry.length);
2627
3537
  return;
2628
3538
  } else if (key.downArrow) {
3539
+ if (historyIndexRef.current !== null) {
3540
+ if (historyIndexRef.current < history.length - 1) {
3541
+ historyIndexRef.current += 1;
3542
+ const entry = history[historyIndexRef.current] ?? "";
3543
+ edit(entry, entry.length);
3544
+ } else {
3545
+ historyIndexRef.current = null;
3546
+ edit(draftRef.current, draftRef.current.length);
3547
+ }
3548
+ return;
3549
+ }
2629
3550
  if (suggestionCount > 0) {
2630
3551
  setHighlight((prev) => (prev + 1) % suggestionCount);
2631
3552
  return;
2632
3553
  }
2633
- if (historyIndexRef.current === null) return;
2634
- if (historyIndexRef.current < history.length - 1) {
2635
- historyIndexRef.current += 1;
2636
- const entry = history[historyIndexRef.current] ?? "";
2637
- edit(entry, entry.length);
2638
- } else {
2639
- historyIndexRef.current = null;
2640
- edit(draftRef.current, draftRef.current.length);
2641
- }
3554
+ const moved = moveCursorLines(value, cursor, 1);
3555
+ if (moved !== null) setCursor(moved);
2642
3556
  return;
2643
3557
  } else if (key.leftArrow) {
2644
3558
  setCursor((prev) => Math.max(0, prev - 1));
@@ -2676,20 +3590,29 @@ ${value.slice(cursor)}`, cursor + 1);
2676
3590
  }
2677
3591
  if (key.backspace || key.delete) {
2678
3592
  if (cursor === 0) return;
2679
- edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
3593
+ let remove = 1;
3594
+ for (const match of value.matchAll(PASTE_TOKEN_RE)) {
3595
+ if (match.index + match[0].length === cursor) {
3596
+ remove = match[0].length;
3597
+ const id = Number(match[1]);
3598
+ pastesRef.current.delete(id);
3599
+ if (lastPasteRef.current?.id === id) lastPasteRef.current = null;
3600
+ break;
3601
+ }
3602
+ }
3603
+ edit(value.slice(0, cursor - remove) + value.slice(cursor), cursor - remove);
2680
3604
  return;
2681
3605
  }
2682
3606
  if (input && !key.ctrl && !key.meta) {
2683
- const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2684
- edit(value.slice(0, cursor) + text + value.slice(cursor), cursor + text.length);
3607
+ insertPlainText(input);
2685
3608
  }
2686
3609
  });
2687
3610
  const before = value.slice(0, cursor);
2688
3611
  const at = value[cursor];
2689
3612
  const after = value.slice(cursor + 1);
2690
- const cursorChar = at === "\n" || at === void 0 ? " " : at;
2691
- const tail = (at === "\n" ? "\n" : "") + after;
2692
- const styled = `\x1B[36m> \x1B[39m${before}\x1B[7m${cursorChar}\x1B[27m${tail}`;
3613
+ const cursorChar = at === "\n" || at === void 0 || at === PASTE_DELIM ? " " : at;
3614
+ const tail = (at === "\n" ? "\n" : "") + stylePasteTokens(after);
3615
+ 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> \x1B[39m${stylePasteTokens(before)}\x1B[7m${cursorChar}\x1B[27m${tail}`;
2693
3616
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
2694
3617
  /* @__PURE__ */ jsx2(Box2, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: /* @__PURE__ */ jsx2(Text2, { children: styled }) }),
2695
3618
  suggestions.length > 0 && /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, children: suggestions.map((cmd, index) => {
@@ -2708,175 +3631,422 @@ ${value.slice(cursor)}`, cursor + 1);
2708
3631
  ] });
2709
3632
  }
2710
3633
 
3634
+ // src/cli/components/TextField.tsx
3635
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
3636
+ var MASK_CAP = 24;
3637
+ function TextField({
3638
+ label,
3639
+ initialValue = "",
3640
+ masked = false,
3641
+ placeholder,
3642
+ onSubmit,
3643
+ onCancel
3644
+ }) {
3645
+ const [value, setValue] = useState3(initialValue);
3646
+ const valueRef = useRef3(initialValue);
3647
+ const bracketRef = useRef3({ active: false, buffer: "", pending: "" });
3648
+ const flushBracket = () => {
3649
+ const bracket = bracketRef.current;
3650
+ const content = bracket.buffer.replace(/[\r\n]+/g, "");
3651
+ bracket.active = false;
3652
+ bracket.buffer = "";
3653
+ bracket.pending = "";
3654
+ if (content.length > 0) {
3655
+ valueRef.current += content;
3656
+ setValue(valueRef.current);
3657
+ }
3658
+ };
3659
+ useInput3((input, key) => {
3660
+ if (key.escape) {
3661
+ onCancel();
3662
+ return;
3663
+ }
3664
+ if (key.return) {
3665
+ flushBracket();
3666
+ onSubmit(valueRef.current.trim());
3667
+ return;
3668
+ }
3669
+ if (key.backspace || key.delete) {
3670
+ flushBracket();
3671
+ valueRef.current = valueRef.current.slice(0, -1);
3672
+ setValue(valueRef.current);
3673
+ return;
3674
+ }
3675
+ if (key.ctrl || key.meta || !input) return;
3676
+ const segments = feedBracketedPaste(bracketRef.current, input);
3677
+ if (segments === null) return;
3678
+ const text = segments.map((segment) => segment.text).join("").replace(/[\r\n]+/g, "");
3679
+ if (text.length === 0) return;
3680
+ valueRef.current += text;
3681
+ setValue(valueRef.current);
3682
+ });
3683
+ const echo = masked ? "\u2022".repeat(Math.min(value.length, MASK_CAP)) : value;
3684
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
3685
+ /* @__PURE__ */ jsxs3(Text3, { children: [
3686
+ label,
3687
+ " ",
3688
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: echo }),
3689
+ value === "" && placeholder ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: placeholder }) : null
3690
+ ] }),
3691
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "[Enter] confirm [Esc] cancel" })
3692
+ ] });
3693
+ }
3694
+
3695
+ // src/cli/components/ConnectWizard.tsx
3696
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3697
+ function YN({ question, onAnswer }) {
3698
+ useInput4((input, key) => {
3699
+ const ch = input.toLowerCase();
3700
+ if (ch === "y") onAnswer(true);
3701
+ else if (ch === "n" || key.escape) onAnswer(false);
3702
+ });
3703
+ return /* @__PURE__ */ jsxs4(Text4, { children: [
3704
+ question,
3705
+ " ",
3706
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "[y] yes [n] no" })
3707
+ ] });
3708
+ }
3709
+ function ConnectWizard({
3710
+ existingProviderNames,
3711
+ existingModelNames,
3712
+ onSave,
3713
+ onSetDefault,
3714
+ onOpenConfig,
3715
+ onFinish,
3716
+ onCancel
3717
+ }) {
3718
+ const [step, setStep] = useState4("provider");
3719
+ const [preset, setPreset] = useState4(null);
3720
+ const [protocol, setProtocol] = useState4("openai-compatible");
3721
+ const [baseURL, setBaseURL] = useState4("");
3722
+ const [apiKey, setApiKey] = useState4("");
3723
+ const [modelId, setModelId] = useState4("");
3724
+ const [answers, setAnswers] = useState4(null);
3725
+ const [error, setError] = useState4(null);
3726
+ const [busy, setBusy] = useState4(false);
3727
+ const [notes, setNotes] = useState4([]);
3728
+ const busyRef = useRef4(false);
3729
+ const notesRef = useRef4([]);
3730
+ const pushNote = (note) => {
3731
+ notesRef.current = [...notesRef.current, note];
3732
+ setNotes(notesRef.current);
3733
+ };
3734
+ const providerOptions = [
3735
+ ...PROVIDER_PRESETS.map((p) => ({ value: p.key, label: p.label, description: p.baseURL })),
3736
+ {
3737
+ value: "custom",
3738
+ label: "Custom",
3739
+ description: "any OpenAI/Anthropic-compatible endpoint"
3740
+ }
3741
+ ];
3742
+ const handleProviderSelect = (value) => {
3743
+ const found = PROVIDER_PRESETS.find((p) => p.key === value) ?? null;
3744
+ setPreset(found);
3745
+ if (found) {
3746
+ setProtocol(found.protocol);
3747
+ setBaseURL(found.baseURL);
3748
+ setStep("baseUrl");
3749
+ } else {
3750
+ setStep("protocol");
3751
+ }
3752
+ };
3753
+ const handleProtocolSelect = (value) => {
3754
+ setProtocol(value);
3755
+ setStep("baseUrl");
3756
+ };
3757
+ const submitBaseURL = (value) => {
3758
+ if (!value) {
3759
+ setError("baseURL is required");
3760
+ return;
3761
+ }
3762
+ setError(null);
3763
+ setBaseURL(value);
3764
+ setStep("apiKey");
3765
+ };
3766
+ const submitApiKey = (value) => {
3767
+ if (!value) {
3768
+ setError("API key is required");
3769
+ return;
3770
+ }
3771
+ setError(null);
3772
+ setApiKey(value);
3773
+ setStep("model");
3774
+ };
3775
+ const submitModel = (value) => {
3776
+ if (!value) {
3777
+ setError("model id is required");
3778
+ return;
3779
+ }
3780
+ setError(null);
3781
+ setModelId(value);
3782
+ const providerName = dedupeName(deriveProviderName(baseURL), existingProviderNames);
3783
+ setAnswers({
3784
+ providerName,
3785
+ protocol,
3786
+ baseURL,
3787
+ apiKeyEnv: apiKeyEnvFor(providerName),
3788
+ apiKey,
3789
+ modelName: dedupeName(value, existingModelNames),
3790
+ modelId: value
3791
+ });
3792
+ setStep("confirm");
3793
+ };
3794
+ const handleConfirm = async (yes) => {
3795
+ if (!yes) {
3796
+ onCancel();
3797
+ return;
3798
+ }
3799
+ if (busyRef.current || !answers) return;
3800
+ busyRef.current = true;
3801
+ setBusy(true);
3802
+ try {
3803
+ pushNote(await onSave(answers));
3804
+ } catch (saveError) {
3805
+ busyRef.current = false;
3806
+ setBusy(false);
3807
+ setError(
3808
+ `Save failed: ${saveError instanceof Error ? saveError.message : String(saveError)}`
3809
+ );
3810
+ return;
3811
+ }
3812
+ busyRef.current = false;
3813
+ setBusy(false);
3814
+ setStep("setDefault");
3815
+ };
3816
+ const handleSetDefault = async (yes) => {
3817
+ if (busyRef.current || !answers) return;
3818
+ busyRef.current = true;
3819
+ setBusy(true);
3820
+ try {
3821
+ pushNote(yes ? await onSetDefault(answers.modelName) : "Default model unchanged.");
3822
+ } catch (setDefaultError) {
3823
+ pushNote(
3824
+ `Could not set the default model: ${setDefaultError instanceof Error ? setDefaultError.message : String(setDefaultError)}`
3825
+ );
3826
+ }
3827
+ busyRef.current = false;
3828
+ setBusy(false);
3829
+ setStep("openFile");
3830
+ };
3831
+ const handleOpenFile = (yes) => {
3832
+ if (yes) pushNote(onOpenConfig());
3833
+ onFinish(notesRef.current.join("\n"));
3834
+ };
3835
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
3836
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: "Connect a provider" }),
3837
+ notes.map((note, index) => (
3838
+ // biome-ignore lint/suspicious/noArrayIndexKey: notes are append-only
3839
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: note }, index)
3840
+ )),
3841
+ step === "provider" && /* @__PURE__ */ jsx4(
3842
+ SelectPrompt,
3843
+ {
3844
+ title: "Which provider?",
3845
+ options: providerOptions,
3846
+ onSelect: handleProviderSelect,
3847
+ onCancel
3848
+ }
3849
+ ),
3850
+ step === "protocol" && /* @__PURE__ */ jsx4(
3851
+ SelectPrompt,
3852
+ {
3853
+ title: "API format (protocol)?",
3854
+ options: PROTOCOLS.map((p) => ({
3855
+ value: p,
3856
+ label: p,
3857
+ description: PROTOCOL_DESCRIPTIONS[p]
3858
+ })),
3859
+ onSelect: handleProtocolSelect,
3860
+ onCancel
3861
+ }
3862
+ ),
3863
+ step === "baseUrl" && /* @__PURE__ */ jsx4(
3864
+ TextField,
3865
+ {
3866
+ label: "baseURL:",
3867
+ initialValue: preset?.baseURL ?? "",
3868
+ placeholder: "https://api.example.com/v1",
3869
+ onSubmit: submitBaseURL,
3870
+ onCancel
3871
+ }
3872
+ ),
3873
+ step === "apiKey" && /* @__PURE__ */ jsx4(
3874
+ TextField,
3875
+ {
3876
+ label: "API key:",
3877
+ masked: true,
3878
+ placeholder: "paste your key (hidden)",
3879
+ onSubmit: submitApiKey,
3880
+ onCancel
3881
+ }
3882
+ ),
3883
+ step === "model" && /* @__PURE__ */ jsx4(
3884
+ TextField,
3885
+ {
3886
+ label: "Model id:",
3887
+ placeholder: preset?.modelHint ?? "e.g. gpt-4o",
3888
+ onSubmit: submitModel,
3889
+ onCancel
3890
+ }
3891
+ ),
3892
+ step === "confirm" && answers && /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
3893
+ /* @__PURE__ */ jsx4(Text4, { children: ` provider: ${answers.providerName} (${answers.protocol})` }),
3894
+ /* @__PURE__ */ jsx4(Text4, { children: ` baseURL: ${answers.baseURL}` }),
3895
+ /* @__PURE__ */ jsx4(Text4, { children: ` API key: ${maskApiKey(answers.apiKey)} \u2192 ${answers.apiKeyEnv} (stored in ~/.star-cli/.env)` }),
3896
+ /* @__PURE__ */ jsx4(Text4, { children: ` model: ${answers.modelName} \u2192 ${answers.modelId}` }),
3897
+ busy ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Saving\u2026" }) : /* @__PURE__ */ jsx4(YN, { question: "Save this provider and model?", onAnswer: handleConfirm })
3898
+ ] }),
3899
+ step === "setDefault" && (busy ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Saving\u2026" }) : /* @__PURE__ */ jsx4(
3900
+ YN,
3901
+ {
3902
+ question: `Set "${answers?.modelName}" as the default model and switch to it?`,
3903
+ onAnswer: handleSetDefault
3904
+ }
3905
+ )),
3906
+ step === "openFile" && /* @__PURE__ */ jsx4(YN, { question: "Open the config file now?", onAnswer: handleOpenFile }),
3907
+ error && /* @__PURE__ */ jsx4(Text4, { color: "red", children: error })
3908
+ ] });
3909
+ }
3910
+
3911
+ // src/cli/components/ExitPlanPrompt.tsx
3912
+ import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
3913
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3914
+ function ExitPlanPrompt({ onDecision }) {
3915
+ useInput5((input, key) => {
3916
+ const ch = input.toLowerCase();
3917
+ if (ch === "y") onDecision("yes");
3918
+ else if (ch === "n" || key.escape) onDecision("no");
3919
+ });
3920
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
3921
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: "Plan ready (plan mode)" }),
3922
+ /* @__PURE__ */ jsx5(Text5, { children: "The plan above was produced without making any changes." }),
3923
+ /* @__PURE__ */ jsx5(Text5, { children: "[y] approve and execute [n] keep refining (Esc = n)" })
3924
+ ] });
3925
+ }
3926
+
2711
3927
  // src/cli/components/MessageList.tsx
2712
- import { Box as Box3, Static, Text as Text4 } from "ink";
3928
+ import { Box as Box6, Static, Text as Text7 } from "ink";
2713
3929
  import { memo } from "react";
2714
3930
 
2715
3931
  // src/cli/components/DiffLines.tsx
2716
- import { Text as Text3 } from "ink";
2717
- import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
3932
+ import { Text as Text6 } from "ink";
3933
+ import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2718
3934
  function DiffLineView({ line }) {
2719
- if (line.kind === "add") return /* @__PURE__ */ jsxs3(Text3, { color: "green", children: [
3935
+ if (line.kind === "add") return /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
2720
3936
  "+ ",
2721
3937
  line.text
2722
3938
  ] });
2723
- if (line.kind === "del") return /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
3939
+ if (line.kind === "del") return /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
2724
3940
  "- ",
2725
3941
  line.text
2726
3942
  ] });
2727
- if (line.kind === "marker") return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: line.text });
2728
- return /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
3943
+ if (line.kind === "marker") return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: line.text });
3944
+ return /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2729
3945
  " ",
2730
3946
  line.text
2731
3947
  ] });
2732
3948
  }
2733
3949
  function DiffLines({ lines }) {
2734
- return /* @__PURE__ */ jsx3(Fragment, { children: lines.map((line, index) => (
3950
+ return /* @__PURE__ */ jsx6(Fragment, { children: lines.map((line, index) => (
2735
3951
  // biome-ignore lint/suspicious/noArrayIndexKey: diff lines have no stable identity
2736
- /* @__PURE__ */ jsx3(DiffLineView, { line }, index)
3952
+ /* @__PURE__ */ jsx6(DiffLineView, { line }, index)
2737
3953
  )) });
2738
3954
  }
2739
3955
 
2740
3956
  // src/cli/components/MessageList.tsx
2741
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3957
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
2742
3958
  var roleStyles = {
2743
3959
  user: { label: "you", color: "cyan" },
2744
3960
  assistant: { label: "star", color: "green" },
2745
3961
  system: { label: "system", color: "yellow" },
2746
3962
  tool: { label: "tool", color: "magenta" }
2747
3963
  };
3964
+ var interruptedMarker = (text) => /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: text.length > 0 ? " [interrupted]" : "[interrupted]" });
2748
3965
  var MessageList = memo(function MessageList2({ messages }) {
2749
- return /* @__PURE__ */ jsx4(Static, { items: messages, children: (message) => {
3966
+ return /* @__PURE__ */ jsx7(Static, { items: messages, children: (message) => {
2750
3967
  const marginBottom = message.tight ? 0 : 1;
2751
3968
  if (message.role === "assistant-cont") {
2752
- return /* @__PURE__ */ jsx4(Box3, { flexDirection: "column", marginBottom, children: /* @__PURE__ */ jsx4(Text4, { color: "green", children: message.text }) }, message.id);
3969
+ return /* @__PURE__ */ jsx7(Box6, { flexDirection: "column", marginBottom, children: /* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
3970
+ message.text,
3971
+ message.interrupted && interruptedMarker(message.text)
3972
+ ] }) }, message.id);
2753
3973
  }
2754
3974
  const style = roleStyles[message.role];
2755
- return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", marginBottom, children: [
2756
- /* @__PURE__ */ jsx4(Text4, { bold: true, color: style.color, children: style.label }),
2757
- /* @__PURE__ */ jsx4(Text4, { color: style.color, children: message.text }),
2758
- message.diff && /* @__PURE__ */ jsx4(DiffLines, { lines: message.diff }),
2759
- message.note && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: message.note })
3975
+ return /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", marginBottom, children: [
3976
+ /* @__PURE__ */ jsx7(Text7, { bold: true, color: style.color, children: style.label }),
3977
+ /* @__PURE__ */ jsxs7(Text7, { color: style.color, children: [
3978
+ message.text,
3979
+ message.interrupted && interruptedMarker(message.text)
3980
+ ] }),
3981
+ message.diff && /* @__PURE__ */ jsx7(DiffLines, { lines: message.diff }),
3982
+ message.note && /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: message.note })
2760
3983
  ] }, message.id);
2761
3984
  } });
2762
3985
  });
2763
3986
 
2764
3987
  // src/cli/components/PermissionPrompt.tsx
2765
- import { Box as Box4, Text as Text5, useInput as useInput3 } from "ink";
2766
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3988
+ import { Box as Box7, Text as Text8, useInput as useInput6 } from "ink";
3989
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
2767
3990
  function PermissionPrompt({ request, preview, onDecision }) {
2768
- useInput3((input) => {
3991
+ useInput6((input) => {
2769
3992
  const ch = input.toLowerCase();
2770
3993
  if (ch === "y") onDecision("yes");
2771
3994
  else if (ch === "n") onDecision("no");
2772
3995
  else if (ch === "a") onDecision("always");
2773
3996
  });
2774
- return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2775
- /* @__PURE__ */ jsxs5(Text5, { bold: true, color: "yellow", children: [
3997
+ return /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
3998
+ /* @__PURE__ */ jsxs8(Text8, { bold: true, color: "yellow", children: [
2776
3999
  "Permission required: ",
2777
4000
  request.toolName,
2778
4001
  " (",
2779
4002
  request.level,
2780
4003
  ")"
2781
4004
  ] }),
2782
- preview ? /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
2783
- /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
4005
+ preview ? /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", children: [
4006
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2784
4007
  preview.type === "new-file" ? "New file: " : "",
2785
4008
  preview.label
2786
4009
  ] }),
2787
- /* @__PURE__ */ jsx5(DiffLines, { lines: preview.lines })
2788
- ] }) : /* @__PURE__ */ jsx5(Text5, { children: summarizeArgs(request.args) }),
2789
- /* @__PURE__ */ jsx5(Text5, { children: "[y] allow [n] deny [a] always (saved to config)" })
4010
+ /* @__PURE__ */ jsx8(DiffLines, { lines: preview.lines })
4011
+ ] }) : /* @__PURE__ */ jsx8(Text8, { children: summarizeArgs(request.args) }),
4012
+ /* @__PURE__ */ jsx8(Text8, { children: "[y] allow [n] deny [a] always (saved to config)" })
2790
4013
  ] });
2791
4014
  }
2792
4015
 
2793
4016
  // src/cli/components/RewindConfirmPrompt.tsx
2794
- import { Box as Box5, Text as Text6, useInput as useInput4 } from "ink";
2795
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2796
- function RewindConfirmPrompt({ summary, onDecision }) {
2797
- useInput4((input, key) => {
4017
+ import { Box as Box8, Text as Text9, useInput as useInput7 } from "ink";
4018
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
4019
+ function RewindConfirmPrompt({
4020
+ summary,
4021
+ title = "Rewind checkpoints",
4022
+ confirmLabel = "rewind",
4023
+ diffs,
4024
+ onDecision
4025
+ }) {
4026
+ useInput7((input, key) => {
2798
4027
  const ch = input.toLowerCase();
2799
4028
  if (ch === "y") onDecision("yes");
2800
4029
  else if (ch === "n" || key.escape) onDecision("no");
2801
4030
  });
2802
- return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2803
- /* @__PURE__ */ jsx6(Text6, { bold: true, color: "yellow", children: "Rewind checkpoints" }),
2804
- /* @__PURE__ */ jsx6(Text6, { children: summary }),
2805
- /* @__PURE__ */ jsx6(Text6, { children: "[y] rewind [n] cancel (Esc = n)" })
2806
- ] });
2807
- }
2808
-
2809
- // src/cli/components/SelectPrompt.tsx
2810
- import { Box as Box6, Text as Text7, useInput as useInput5 } from "ink";
2811
- import { useRef as useRef2, useState as useState2 } from "react";
2812
- import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
2813
- var SELECT_PROMPT_MAX_VISIBLE = 9;
2814
- function SelectPrompt({ title, options, onSelect, onCancel }) {
2815
- const [index, setIndex] = useState2(0);
2816
- const indexRef = useRef2(0);
2817
- const move = (delta) => {
2818
- indexRef.current = Math.max(0, Math.min(indexRef.current + delta, options.length - 1));
2819
- setIndex(indexRef.current);
2820
- };
2821
- useInput5((input, key) => {
2822
- if (key.escape) {
2823
- onCancel();
2824
- return;
2825
- }
2826
- if (key.return) {
2827
- const option = options[indexRef.current];
2828
- if (option) onSelect(option.value);
2829
- return;
2830
- }
2831
- if (key.downArrow || input === "j") {
2832
- move(1);
2833
- } else if (key.upArrow || input === "k") {
2834
- move(-1);
2835
- }
2836
- });
2837
- const maxStart = Math.max(0, options.length - SELECT_PROMPT_MAX_VISIBLE);
2838
- const start = Math.min(Math.max(0, index - Math.floor(SELECT_PROMPT_MAX_VISIBLE / 2)), maxStart);
2839
- const visible = options.slice(start, start + SELECT_PROMPT_MAX_VISIBLE);
2840
- const above = start;
2841
- const below = options.length - start - visible.length;
2842
- return /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
2843
- /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: title }),
2844
- above > 0 && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2845
- "\u2026 ",
2846
- above,
2847
- " more above"
2848
- ] }),
2849
- visible.map((option, i) => {
2850
- const active = start + i === index;
2851
- return /* @__PURE__ */ jsxs7(Text7, { children: [
2852
- /* @__PURE__ */ jsxs7(Text7, { bold: active, inverse: active, children: [
2853
- active ? "\u276F " : " ",
2854
- option.label
2855
- ] }),
2856
- option.hint && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2857
- " (",
2858
- option.hint,
2859
- ")"
2860
- ] }),
2861
- option.description && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2862
- " \u2014 ",
2863
- option.description
2864
- ] })
2865
- ] }, option.value);
2866
- }),
2867
- below > 0 && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2868
- "\u2026 ",
2869
- below,
2870
- " more below"
2871
- ] }),
2872
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "[\u2191/\u2193 or j/k] move [Enter] select [Esc] cancel" })
4031
+ return /* @__PURE__ */ jsxs9(Box8, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
4032
+ /* @__PURE__ */ jsx9(Text9, { bold: true, color: "yellow", children: title }),
4033
+ /* @__PURE__ */ jsx9(Text9, { children: summary }),
4034
+ diffs?.map((diff) => /* @__PURE__ */ jsxs9(Box8, { flexDirection: "column", children: [
4035
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: diff.label }),
4036
+ /* @__PURE__ */ jsx9(DiffLines, { lines: diff.lines })
4037
+ ] }, diff.label)),
4038
+ /* @__PURE__ */ jsxs9(Text9, { children: [
4039
+ "[y] ",
4040
+ confirmLabel,
4041
+ " [n] cancel (Esc = n)"
4042
+ ] })
2873
4043
  ] });
2874
4044
  }
2875
4045
 
2876
4046
  // src/cli/components/StatusBar.tsx
2877
- import { Box as Box7, Text as Text8, useStdout } from "ink";
4047
+ import { Box as Box9, Text as Text10, useStdout as useStdout2 } from "ink";
2878
4048
  import { memo as memo2 } from "react";
2879
- import { jsxs as jsxs8 } from "react/jsx-runtime";
4049
+ import { jsxs as jsxs10 } from "react/jsx-runtime";
2880
4050
  var BG_LABEL_MAX = 40;
2881
4051
  var WIDE_COLUMNS = 120;
2882
4052
  function cwdLabel(cwd, wide) {
@@ -2891,43 +4061,48 @@ var StatusBar = memo2(function StatusBar2({
2891
4061
  tokens: tokens2,
2892
4062
  gitBranch,
2893
4063
  contextPercent,
4064
+ cachePercent,
2894
4065
  sessionCostUsd,
2895
4066
  backgroundTasks = []
2896
4067
  }) {
2897
- const { stdout } = useStdout();
4068
+ const { stdout } = useStdout2();
2898
4069
  const wide = (stdout?.columns ?? 80) >= WIDE_COLUMNS;
2899
4070
  const joined = backgroundTasks.join(", ");
2900
4071
  const labels = joined.length > BG_LABEL_MAX ? `${joined.slice(0, BG_LABEL_MAX)}\u2026` : joined;
2901
- return /* @__PURE__ */ jsxs8(Box7, { justifyContent: "space-between", children: [
2902
- /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4072
+ return /* @__PURE__ */ jsxs10(Box9, { justifyContent: "space-between", children: [
4073
+ /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2903
4074
  cwdLabel(cwd, wide),
2904
4075
  gitBranch ? ` [${gitBranch}]` : ""
2905
4076
  ] }),
2906
- backgroundTasks.length > 0 && /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
4077
+ backgroundTasks.length > 0 && /* @__PURE__ */ jsxs10(Text10, { color: "yellow", children: [
2907
4078
  "bg: ",
2908
4079
  backgroundTasks.length,
2909
4080
  " (",
2910
4081
  labels,
2911
4082
  ")"
2912
4083
  ] }),
2913
- /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4084
+ /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2914
4085
  "model: ",
2915
4086
  model
2916
4087
  ] }),
2917
- contextPercent != null && /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4088
+ contextPercent != null && /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2918
4089
  "ctx: ",
2919
4090
  contextPercent,
2920
4091
  "%"
2921
4092
  ] }),
2922
- sessionCostUsd != null && sessionCostUsd > 0 && /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4093
+ /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
4094
+ "cache: ",
4095
+ cachePercent != null ? `${cachePercent}%` : "Not provided"
4096
+ ] }),
4097
+ sessionCostUsd != null && sessionCostUsd > 0 && /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2923
4098
  "$",
2924
4099
  formatDollars(sessionCostUsd)
2925
4100
  ] }),
2926
- /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4101
+ /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2927
4102
  tokens2,
2928
4103
  " tokens"
2929
4104
  ] }),
2930
- /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4105
+ /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2931
4106
  "mode: ",
2932
4107
  permissionMode
2933
4108
  ] })
@@ -2935,22 +4110,22 @@ var StatusBar = memo2(function StatusBar2({
2935
4110
  });
2936
4111
 
2937
4112
  // src/cli/components/StreamingMessage.tsx
2938
- import { Box as Box8, Text as Text9 } from "ink";
4113
+ import { Box as Box10, Text as Text11 } from "ink";
2939
4114
  import { memo as memo3 } from "react";
2940
- import { jsx as jsx8, jsxs as jsxs9 } from "react/jsx-runtime";
4115
+ import { jsx as jsx10, jsxs as jsxs11 } from "react/jsx-runtime";
2941
4116
  var StreamingMessage = memo3(function StreamingMessage2({
2942
4117
  text,
2943
4118
  continuation
2944
4119
  }) {
2945
- return /* @__PURE__ */ jsxs9(Box8, { flexDirection: "column", marginBottom: 1, children: [
2946
- !continuation && /* @__PURE__ */ jsx8(Text9, { bold: true, color: "green", children: "star" }),
2947
- /* @__PURE__ */ jsx8(Text9, { color: "green", children: text })
4120
+ return /* @__PURE__ */ jsxs11(Box10, { flexDirection: "column", marginBottom: 1, children: [
4121
+ !continuation && /* @__PURE__ */ jsx10(Text11, { bold: true, color: "green", children: "star" }),
4122
+ /* @__PURE__ */ jsx10(Text11, { color: "green", children: text })
2948
4123
  ] });
2949
4124
  });
2950
4125
 
2951
4126
  // src/cli/components/ThinkingIndicator.tsx
2952
- import { Box as Box9, Text as Text10 } from "ink";
2953
- import { jsxs as jsxs10 } from "react/jsx-runtime";
4127
+ import { Box as Box11, Text as Text12 } from "ink";
4128
+ import { jsxs as jsxs12 } from "react/jsx-runtime";
2954
4129
  var SPINNER_FRAMES = ["-", "\\", "|", "/"];
2955
4130
  var REASONING_TAIL_LENGTH = 200;
2956
4131
  var THOUGHT_SUMMARY_LENGTH = 100;
@@ -2966,13 +4141,13 @@ function ThinkingIndicator({
2966
4141
  }) {
2967
4142
  const tail = reasoning ? truncateTail(reasoning, REASONING_TAIL_LENGTH) : "";
2968
4143
  const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
2969
- return /* @__PURE__ */ jsxs10(Box9, { flexDirection: "column", children: [
2970
- /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
4144
+ return /* @__PURE__ */ jsxs12(Box11, { flexDirection: "column", children: [
4145
+ /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
2971
4146
  spinner,
2972
4147
  " ",
2973
4148
  activity ?? "star is thinking\u2026"
2974
4149
  ] }),
2975
- tail !== "" && /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
4150
+ tail !== "" && /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
2976
4151
  " ",
2977
4152
  tail
2978
4153
  ] })
@@ -2980,35 +4155,71 @@ function ThinkingIndicator({
2980
4155
  }
2981
4156
 
2982
4157
  // src/cli/components/TodoPanel.tsx
2983
- import { Box as Box10, Text as Text11 } from "ink";
2984
- import { jsx as jsx9, jsxs as jsxs11 } from "react/jsx-runtime";
4158
+ import { Box as Box12, Text as Text13 } from "ink";
4159
+
4160
+ // src/cli/terminal-text.ts
4161
+ var UNSAFE_CHARS = /[\x00-\x09\x0b-\x1f\x7f]/;
4162
+ var CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f]/g;
4163
+ var TABS = /\t/g;
4164
+ function toTerminalSafe(text) {
4165
+ if (!UNSAFE_CHARS.test(text)) return text;
4166
+ return text.replace(TABS, " ").replace(CONTROL_CHARS, "");
4167
+ }
4168
+
4169
+ // src/cli/components/TodoPanel.tsx
4170
+ import { jsxs as jsxs13 } from "react/jsx-runtime";
2985
4171
  var SYMBOLS = {
2986
4172
  pending: "[ ]",
2987
4173
  in_progress: "[~]",
2988
4174
  done: "[x]"
2989
4175
  };
4176
+ var MAX_VISIBLE_TODOS = 10;
4177
+ function visibleTodoWindow(todos, max = MAX_VISIBLE_TODOS) {
4178
+ if (todos.length <= max) return { items: [...todos], hiddenBefore: 0, hiddenAfter: 0 };
4179
+ const active = todos.findIndex((todo) => todo.status === "in_progress");
4180
+ const start = active > 0 ? Math.min(active, todos.length - max) : 0;
4181
+ const items = todos.slice(start, start + max);
4182
+ return {
4183
+ items,
4184
+ hiddenBefore: start,
4185
+ hiddenAfter: todos.length - start - items.length
4186
+ };
4187
+ }
2990
4188
  function TodoPanel({ todos }) {
2991
4189
  if (todos.length === 0) return null;
2992
- return /* @__PURE__ */ jsx9(Box10, { flexDirection: "column", children: todos.map((todo) => /* @__PURE__ */ jsxs11(
2993
- Text11,
2994
- {
2995
- dimColor: todo.status === "done",
2996
- color: todo.status === "in_progress" ? "cyan" : void 0,
2997
- children: [
2998
- SYMBOLS[todo.status],
2999
- " ",
3000
- todo.title
3001
- ]
3002
- },
3003
- todo.id
3004
- )) });
4190
+ const { items, hiddenBefore, hiddenAfter } = visibleTodoWindow(todos);
4191
+ return /* @__PURE__ */ jsxs13(Box12, { flexDirection: "column", children: [
4192
+ hiddenBefore > 0 && /* @__PURE__ */ jsxs13(Text13, { dimColor: true, children: [
4193
+ " \u2026 ",
4194
+ hiddenBefore,
4195
+ " more above"
4196
+ ] }),
4197
+ items.map((todo) => /* @__PURE__ */ jsxs13(
4198
+ Text13,
4199
+ {
4200
+ dimColor: todo.status === "done",
4201
+ color: todo.status === "in_progress" ? "cyan" : void 0,
4202
+ children: [
4203
+ SYMBOLS[todo.status],
4204
+ " ",
4205
+ toTerminalSafe(todo.title)
4206
+ ]
4207
+ },
4208
+ todo.id
4209
+ )),
4210
+ hiddenAfter > 0 && /* @__PURE__ */ jsxs13(Text13, { dimColor: true, children: [
4211
+ " \u2026 ",
4212
+ hiddenAfter,
4213
+ " more"
4214
+ ] })
4215
+ ] });
3005
4216
  }
3006
4217
 
3007
4218
  // src/cli/components/ToolCallCard.tsx
3008
4219
  function formatToolCard(card) {
3009
4220
  const header = `${card.name} ${card.argsSummary}${card.isError ? " [error]" : ""}`;
3010
4221
  if (card.result === void 0) return header;
3011
- const preview = previewLines(card.result, 10);
4222
+ const preview = previewLines(toTerminalSafe(card.result), 10);
3012
4223
  return `${header}
3013
4224
  ${preview.text}${preview.truncated ? "\n... (truncated)" : ""}`;
3014
4225
  }
@@ -3020,28 +4231,44 @@ function isDoubleEscape(previous, now, windowMs = DOUBLE_ESC_WINDOW_MS) {
3020
4231
  }
3021
4232
 
3022
4233
  // src/cli/history.ts
3023
- import fs10 from "fs";
3024
- import path16 from "path";
3025
- var DEFAULT_LIMIT = 500;
3026
- var MAX_FILE_LINES = 1e3;
3027
- var TRIM_TO_LINES = 500;
4234
+ import fs12 from "fs";
4235
+ import path19 from "path";
4236
+ var HISTORY_LIMIT = 50;
4237
+ var JUNK_ENTRY_PATTERN = /^(.)\1{19,}$/;
3028
4238
  function historyPath() {
3029
- return path16.join(starHome(), "history");
4239
+ return path19.join(starHome(), "history");
3030
4240
  }
3031
4241
  function readLines(file) {
3032
- const raw = fs10.readFileSync(file, "utf8");
4242
+ const raw = fs12.readFileSync(file, "utf8");
3033
4243
  return raw.split("\n").map((line) => line.replace(/\r$/, "")).filter((line) => line.trim().length > 0);
3034
4244
  }
3035
- function loadHistory(limit = DEFAULT_LIMIT) {
4245
+ function writeHistory(file, lines) {
4246
+ fs12.mkdirSync(path19.dirname(file), { recursive: true });
4247
+ fs12.writeFileSync(file, `${lines.join("\n")}
4248
+ `);
4249
+ }
4250
+ function isJunkHistoryEntry(line) {
4251
+ return JUNK_ENTRY_PATTERN.test(line);
4252
+ }
4253
+ function isSkippableEntry(line) {
4254
+ return isJunkHistoryEntry(line) || line.startsWith("/");
4255
+ }
4256
+ function loadHistory(limit = HISTORY_LIMIT) {
3036
4257
  try {
3037
- return readLines(historyPath()).slice(-limit);
4258
+ const file = historyPath();
4259
+ const lines = readLines(file);
4260
+ const clean = lines.filter((line) => !isSkippableEntry(line));
4261
+ if (clean.length !== lines.length || clean.length > HISTORY_LIMIT) {
4262
+ writeHistory(file, clean.slice(-HISTORY_LIMIT));
4263
+ }
4264
+ return clean.slice(-limit);
3038
4265
  } catch {
3039
4266
  return [];
3040
4267
  }
3041
4268
  }
3042
4269
  function appendHistory(entry) {
3043
4270
  const line = entry.replace(/[\r\n]+/g, " ").trim();
3044
- if (line.length === 0) return;
4271
+ if (line.length === 0 || isSkippableEntry(line)) return;
3045
4272
  try {
3046
4273
  const file = historyPath();
3047
4274
  let existing = [];
@@ -3050,15 +4277,12 @@ function appendHistory(entry) {
3050
4277
  } catch {
3051
4278
  }
3052
4279
  if (existing[existing.length - 1] === line) return;
3053
- if (existing.length >= MAX_FILE_LINES) {
3054
- const kept = [...existing.slice(-(TRIM_TO_LINES - 1)), line];
3055
- fs10.mkdirSync(path16.dirname(file), { recursive: true });
3056
- fs10.writeFileSync(file, `${kept.join("\n")}
3057
- `);
4280
+ if (existing.length >= HISTORY_LIMIT) {
4281
+ writeHistory(file, [...existing.slice(-(HISTORY_LIMIT - 1)), line]);
3058
4282
  return;
3059
4283
  }
3060
- fs10.mkdirSync(path16.dirname(file), { recursive: true });
3061
- fs10.appendFileSync(file, `${line}
4284
+ fs12.mkdirSync(path19.dirname(file), { recursive: true });
4285
+ fs12.appendFileSync(file, `${line}
3062
4286
  `);
3063
4287
  } catch {
3064
4288
  }
@@ -3179,7 +4403,7 @@ async function checkForUpdate(currentVersion, fetcher = defaultFetcher2) {
3179
4403
  }
3180
4404
 
3181
4405
  // src/cli/repl.tsx
3182
- import { jsx as jsx10, jsxs as jsxs12 } from "react/jsx-runtime";
4406
+ import { jsx as jsx11, jsxs as jsxs14 } from "react/jsx-runtime";
3183
4407
  var CLEAR_TERMINAL = process.platform === "win32" ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H";
3184
4408
  function emptyUsage() {
3185
4409
  return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -3194,6 +4418,7 @@ function formatUsage(usage) {
3194
4418
  return `API usage this session: ${usage.requests} requests, ${usage.promptTokens} prompt + ${usage.completionTokens} completion = ${usage.totalTokens} tokens`;
3195
4419
  }
3196
4420
  var SESSION_PICKER_CAP = 20;
4421
+ var MAX_VISIBLE_QUEUED = 5;
3197
4422
  async function sessionPreview(id) {
3198
4423
  const store = await SessionStore.open(id);
3199
4424
  if (!store) return null;
@@ -3214,65 +4439,67 @@ function Repl({
3214
4439
  initialUsage
3215
4440
  }) {
3216
4441
  const { exit } = useApp();
3217
- const [initialDisplay] = useState3(() => buildDisplayMessages(initialMessages ?? []));
3218
- const [messages, setMessages] = useState3(initialDisplay);
3219
- const [epoch, setEpoch] = useState3(0);
3220
- const [streamingText, setStreamingText] = useState3(null);
3221
- const [isStreaming, setIsStreaming] = useState3(false);
3222
- const [thinking, setThinking] = useState3(false);
3223
- const [thinkingText, setThinkingText] = useState3("");
3224
- const [thoughtSummary, setThoughtSummary] = useState3(null);
3225
- const [, setUsageVersion] = useState3(0);
3226
- const [modelName, setModelName] = useState3(model);
3227
- const [pending, setPending] = useState3(null);
3228
- const [spinnerTick, setSpinnerTick] = useState3(0);
3229
- const [activity, setActivity] = useState3(null);
3230
- const [bgLabels, setBgLabels] = useState3(() => runningTaskLabels());
3231
- const [todos, setTodos] = useState3([]);
3232
- const [permissionModeState, setPermissionModeState] = useState3(permissionMode);
3233
- const [planApproval, setPlanApprovalState] = useState3(false);
3234
- const [pendingRewind, setPendingRewindState] = useState3(null);
3235
- const [picker, setPickerState] = useState3(null);
3236
- const backendRef = useRef3(backend);
3237
- const nextIdRef = useRef3(initialDisplay.length);
3238
- const abortRef = useRef3(null);
3239
- const streamedRef = useRef3("");
3240
- const thinkingRef = useRef3(false);
3241
- const reasoningRef = useRef3("");
3242
- const tickerStopRef = useRef3(null);
3243
- const flushedRef = useRef3({ streamed: "", reasoning: "" });
3244
- const toolCardsRef = useRef3(/* @__PURE__ */ new Map());
3245
- const pendingRef = useRef3(null);
3246
- const alwaysAllowedRef = useRef3(/* @__PURE__ */ new Set());
3247
- const prevModeRef = useRef3(null);
3248
- const planApprovalRef = useRef3(false);
3249
- const pendingRewindRef = useRef3(null);
3250
- const pickerRef = useRef3(null);
3251
- const modelNameRef = useRef3(model);
3252
- const sessionStoreRef = useRef3(sessionStore);
3253
- const usageRef = useRef3(initialUsage ? { ...initialUsage } : emptyUsage());
3254
- const turnChunksRef = useRef3(0);
3255
- const messagesRef = useRef3(initialDisplay);
3256
- const [queueItems, setQueueItems] = useState3([]);
3257
- const queueRef = useRef3(new PromptQueue());
3258
- const [pendingImages, setPendingImages] = useState3([]);
3259
- const pendingImagesRef = useRef3([]);
3260
- const imageSeqRef = useRef3(1);
3261
- const [inputRefill, setInputRefill] = useState3();
3262
- const refillSeqRef = useRef3(0);
3263
- const lastEscRef = useRef3(null);
3264
- const [gitBranch, setGitBranch] = useState3(null);
3265
- const [contextPercent, setContextPercent] = useState3(null);
3266
- const [initialHistory] = useState3(() => loadHistory());
3267
- const budgetWarnedRef = useRef3(false);
3268
- const budgetExceededRef = useRef3(false);
3269
- const runStreamRef = useRef3(
4442
+ const [initialDisplay] = useState5(() => buildDisplayMessages(initialMessages ?? []));
4443
+ const [messages, setMessages] = useState5(initialDisplay);
4444
+ const [epoch, setEpoch] = useState5(0);
4445
+ const [streamingText, setStreamingText] = useState5(null);
4446
+ const [isStreaming, setIsStreaming] = useState5(false);
4447
+ const [thinking, setThinking] = useState5(false);
4448
+ const [thinkingText, setThinkingText] = useState5("");
4449
+ const [thoughtSummary, setThoughtSummary] = useState5(null);
4450
+ const [, setUsageVersion] = useState5(0);
4451
+ const [modelName, setModelName] = useState5(model);
4452
+ const [pending, setPending] = useState5(null);
4453
+ const [spinnerTick, setSpinnerTick] = useState5(0);
4454
+ const [activity, setActivity] = useState5(null);
4455
+ const [bgLabels, setBgLabels] = useState5(() => runningTaskLabels());
4456
+ const [todos, setTodos] = useState5([]);
4457
+ const [permissionModeState, setPermissionModeState] = useState5(permissionMode);
4458
+ const [planApproval, setPlanApprovalState] = useState5(false);
4459
+ const [pendingRewind, setPendingRewindState] = useState5(null);
4460
+ const [picker, setPickerState] = useState5(null);
4461
+ const [pendingConnect, setPendingConnectState] = useState5(null);
4462
+ const backendRef = useRef5(backend);
4463
+ const nextIdRef = useRef5(initialDisplay.length);
4464
+ const abortRef = useRef5(null);
4465
+ const streamedRef = useRef5("");
4466
+ const thinkingRef = useRef5(false);
4467
+ const reasoningRef = useRef5("");
4468
+ const tickerStopRef = useRef5(null);
4469
+ const flushedRef = useRef5({ streamed: "", reasoning: "" });
4470
+ const toolCardsRef = useRef5(/* @__PURE__ */ new Map());
4471
+ const pendingRef = useRef5(null);
4472
+ const alwaysAllowedRef = useRef5(/* @__PURE__ */ new Set());
4473
+ const prevModeRef = useRef5(null);
4474
+ const planApprovalRef = useRef5(false);
4475
+ const pendingRewindRef = useRef5(null);
4476
+ const pickerRef = useRef5(null);
4477
+ const pendingConnectRef = useRef5(null);
4478
+ const modelNameRef = useRef5(model);
4479
+ const sessionStoreRef = useRef5(sessionStore);
4480
+ const usageRef = useRef5(initialUsage ? { ...initialUsage } : emptyUsage());
4481
+ const turnChunksRef = useRef5(0);
4482
+ const messagesRef = useRef5(initialDisplay);
4483
+ const [queueItems, setQueueItems] = useState5([]);
4484
+ const queueRef = useRef5(new PromptQueue());
4485
+ const [pendingImages, setPendingImages] = useState5([]);
4486
+ const pendingImagesRef = useRef5([]);
4487
+ const imageSeqRef = useRef5(1);
4488
+ const [inputRefill, setInputRefill] = useState5();
4489
+ const refillSeqRef = useRef5(0);
4490
+ const lastEscRef = useRef5(null);
4491
+ const [gitBranch, setGitBranch] = useState5(null);
4492
+ const [contextPercent, setContextPercent] = useState5(null);
4493
+ const [initialHistory] = useState5(() => loadHistory());
4494
+ const budgetWarnedRef = useRef5(false);
4495
+ const budgetExceededRef = useRef5(false);
4496
+ const runStreamRef = useRef5(
3270
4497
  null
3271
4498
  );
3272
4499
  const pushMessage = useCallback(
3273
- (role, text, note, tight) => {
4500
+ (role, text, note, tight, interrupted) => {
3274
4501
  setMessages((prev) => {
3275
- const next = [...prev, { id: nextIdRef.current++, role, text, note, tight }];
4502
+ const next = [...prev, { id: nextIdRef.current++, role, text, note, tight, interrupted }];
3276
4503
  messagesRef.current = next;
3277
4504
  return next;
3278
4505
  });
@@ -3294,10 +4521,10 @@ function Repl({
3294
4521
  [applyMessages]
3295
4522
  );
3296
4523
  const pushAssistantChunk = useCallback(
3297
- (text, tight) => {
4524
+ (text, tight, interrupted = false) => {
3298
4525
  const role = turnChunksRef.current === 0 ? "assistant" : "assistant-cont";
3299
4526
  turnChunksRef.current += 1;
3300
- pushMessage(role, text, void 0, tight);
4527
+ pushMessage(role, text, void 0, tight, interrupted);
3301
4528
  },
3302
4529
  [pushMessage]
3303
4530
  );
@@ -3317,6 +4544,10 @@ function Repl({
3317
4544
  pickerRef.current = p;
3318
4545
  setPickerState(p);
3319
4546
  }, []);
4547
+ const setPendingConnect = useCallback((p) => {
4548
+ pendingConnectRef.current = p;
4549
+ setPendingConnectState(p);
4550
+ }, []);
3320
4551
  const showPicker = useCallback(
3321
4552
  (title, options) => {
3322
4553
  if (pickerRef.current) return Promise.resolve(null);
@@ -3360,10 +4591,17 @@ function Repl({
3360
4591
  [config]
3361
4592
  );
3362
4593
  const handlePasteImage = useCallback(() => {
3363
- void readClipboardImage().then((img) => {
3364
- if (img) stageClipboardImage(img);
4594
+ void readClipboardImage().then((result) => {
4595
+ if (!result) return;
4596
+ if (result.oversized) {
4597
+ pushMessage(
4598
+ "system",
4599
+ `Image exceeds ${MAX_IMAGE_DIMENSION}px on its longest side and could not be resized automatically; the model may reject it.`
4600
+ );
4601
+ }
4602
+ stageClipboardImage(result.image);
3365
4603
  });
3366
- }, [stageClipboardImage]);
4604
+ }, [stageClipboardImage, pushMessage]);
3367
4605
  const handleRewindDecision = useCallback(
3368
4606
  (decision) => {
3369
4607
  const p = pendingRewindRef.current;
@@ -3373,6 +4611,32 @@ function Repl({
3373
4611
  },
3374
4612
  [setPendingRewind]
3375
4613
  );
4614
+ const handleConnectSave = useCallback(
4615
+ async (answers) => {
4616
+ const result = saveConnection(answers);
4617
+ config.providers.push(result.provider);
4618
+ config.models.push(result.model);
4619
+ return `Saved provider "${result.provider.name}" and model "${result.model.name}" to ${result.configPath}; the key is stored as ${result.provider.apiKeyEnv} in ${result.envPath}.`;
4620
+ },
4621
+ [config]
4622
+ );
4623
+ const handleConnectOpenConfig = useCallback(() => {
4624
+ const file = globalConfigPath();
4625
+ openConfigFile(file);
4626
+ return `Opening ${file} with the system default app (open it manually if nothing showed up).`;
4627
+ }, []);
4628
+ const finishConnect = useCallback(
4629
+ (message) => {
4630
+ const p = pendingConnectRef.current;
4631
+ if (!p) return;
4632
+ setPendingConnect(null);
4633
+ p.resolve(message);
4634
+ },
4635
+ [setPendingConnect]
4636
+ );
4637
+ const cancelConnect = useCallback(() => {
4638
+ finishConnect("Connect cancelled \u2014 nothing was saved.");
4639
+ }, [finishConnect]);
3376
4640
  const applySessionMode = useCallback(
3377
4641
  (mode) => {
3378
4642
  if (mode === config.permissionMode) return;
@@ -3415,10 +4679,12 @@ function Repl({
3415
4679
  if (message) pushMessage("system", message);
3416
4680
  });
3417
4681
  }, [pushMessage]);
4682
+ const resumedAtStart = initialMessages !== void 0;
3418
4683
  useEffect2(() => {
4684
+ if (!resumedAtStart) return;
3419
4685
  loadTodos(cwd).then(setTodos).catch(() => {
3420
4686
  });
3421
- }, [cwd]);
4687
+ }, [cwd, resumedAtStart]);
3422
4688
  useEffect2(() => {
3423
4689
  const onUpdate = (task) => {
3424
4690
  setBgLabels(runningTaskLabels());
@@ -3502,9 +4768,9 @@ function Repl({
3502
4768
  }
3503
4769
  exit();
3504
4770
  }, [exit, pushMessage]);
3505
- useInput6((_input, key) => {
4771
+ useInput8((_input, key) => {
3506
4772
  if (key.escape) {
3507
- const busy = abortRef.current !== null || pendingRef.current !== null || planApprovalRef.current || pendingRewindRef.current !== null || pickerRef.current !== null;
4773
+ const busy = abortRef.current !== null || pendingRef.current !== null || planApprovalRef.current || pendingRewindRef.current !== null || pendingConnectRef.current !== null || pickerRef.current !== null;
3508
4774
  if (busy) {
3509
4775
  lastEscRef.current = null;
3510
4776
  interrupt();
@@ -3521,7 +4787,7 @@ function Repl({
3521
4787
  }
3522
4788
  lastEscRef.current = null;
3523
4789
  if (key.shift && key.tab) {
3524
- if (abortRef.current || pendingRef.current || planApprovalRef.current || pendingRewindRef.current || pickerRef.current)
4790
+ if (abortRef.current || pendingRef.current || planApprovalRef.current || pendingRewindRef.current || pendingConnectRef.current || pickerRef.current)
3525
4791
  return;
3526
4792
  const current = config.permissionMode;
3527
4793
  const index = SESSION_MODE_CYCLE.indexOf(current);
@@ -3577,6 +4843,15 @@ function Repl({
3577
4843
  },
3578
4844
  [config, cwd, attachConfirmHandler]
3579
4845
  );
4846
+ const handleConnectSetDefault = useCallback(
4847
+ async (modelName2) => {
4848
+ saveDefaultModel(modelName2);
4849
+ config.defaultModel = modelName2;
4850
+ const switched = await switchModel(modelName2);
4851
+ return `Default model set to "${modelName2}" (saved to config). ${switched}`;
4852
+ },
4853
+ [config, switchModel]
4854
+ );
3580
4855
  const resume = useCallback(
3581
4856
  async (id) => {
3582
4857
  const current = backendRef.current;
@@ -3592,6 +4867,7 @@ function Repl({
3592
4867
  return `Session not found: ${id}`;
3593
4868
  }
3594
4869
  await current.loadMessages(resumed.messages);
4870
+ setTodos(await loadTodos(cwd));
3595
4871
  const store = await SessionStore.open(resolvedId);
3596
4872
  if (store) {
3597
4873
  hydrateSnapshots(await loadSessionSnapshots(store.dir));
@@ -3603,7 +4879,7 @@ function Repl({
3603
4879
  setUsageVersion((v) => v + 1);
3604
4880
  return `Resumed session ${resolvedId} (${resumed.messages.length} messages).`;
3605
4881
  },
3606
- [applyMessages]
4882
+ [applyMessages, cwd]
3607
4883
  );
3608
4884
  const runStream = useCallback(
3609
4885
  async (input, extraImages = []) => {
@@ -3673,9 +4949,9 @@ function Repl({
3673
4949
  );
3674
4950
  }
3675
4951
  }
3676
- streamedRef.current += event.text;
4952
+ streamedRef.current += toTerminalSafe(event.text);
3677
4953
  } else if (event.type === "reasoning") {
3678
- reasoningRef.current += event.text;
4954
+ reasoningRef.current += toTerminalSafe(event.text);
3679
4955
  } else if (event.type === "tool-call") {
3680
4956
  commitStreamed(true);
3681
4957
  toolCardsRef.current.set(event.id, {
@@ -3715,14 +4991,19 @@ function Repl({
3715
4991
  usage.promptTokens += event.usage.promptTokens;
3716
4992
  usage.completionTokens += event.usage.completionTokens;
3717
4993
  usage.totalTokens += event.usage.totalTokens;
4994
+ if (cacheUsageReported(event.usage)) {
4995
+ if (!usage.cache) usage.cache = { cachedTokens: 0, promptTokens: 0 };
4996
+ addToCacheTotals(usage.cache, event.usage);
4997
+ }
3718
4998
  setUsageVersion((v) => v + 1);
3719
4999
  sessionStoreRef.current?.addUsage(event.usage).catch(() => {
3720
5000
  });
3721
5001
  }
3722
5002
  } else if (event.type === "retry") {
5003
+ const wait = event.delayMs !== void 0 && event.delayMs >= 1e3 ? ` in ${Math.round(event.delayMs / 1e3)}s` : "";
3723
5004
  pushMessage(
3724
5005
  "system",
3725
- `Request failed, retrying (${event.attempt}/${event.maxAttempts}): ${event.reason}`
5006
+ `Request failed, retrying (${event.attempt}/${event.maxAttempts})${wait}: ${event.reason}`
3726
5007
  );
3727
5008
  } else if (event.type === "notice") {
3728
5009
  pushMessage("system", event.message);
@@ -3761,9 +5042,9 @@ function Repl({
3761
5042
  elapsedMs: Date.now() - turnStartedAt
3762
5043
  });
3763
5044
  if (finalText.length > 0) {
3764
- pushAssistantChunk(`${finalText}${interrupted ? " [interrupted]" : ""}`, false);
5045
+ pushAssistantChunk(finalText, false, interrupted);
3765
5046
  } else if (interrupted && turnChunksRef.current > 0) {
3766
- pushAssistantChunk("[interrupted]", false);
5047
+ pushAssistantChunk("", false, true);
3767
5048
  }
3768
5049
  for (const card of toolCardsRef.current.values()) {
3769
5050
  pushMessage("tool", formatToolCard(card));
@@ -3992,12 +5273,20 @@ ${lines.join("\n")}`;
3992
5273
  await current.loadMessages(first?.role === "system" ? [first] : []);
3993
5274
  clearSnapshots();
3994
5275
  redrawMessages([]);
5276
+ resetTodos();
5277
+ setTodos([]);
3995
5278
  usageRef.current = emptyUsage();
3996
5279
  budgetWarnedRef.current = false;
3997
5280
  budgetExceededRef.current = false;
3998
5281
  setUsageVersion((v) => v + 1);
3999
5282
  return `Started a new session (${store.id}) with a clean context.`;
4000
5283
  },
5284
+ connect: async () => {
5285
+ if (pendingConnectRef.current) return "The connect wizard is already open.";
5286
+ return new Promise((resolve) => {
5287
+ setPendingConnect({ resolve });
5288
+ });
5289
+ },
4001
5290
  clearSessions: async (all) => {
4002
5291
  const currentId = sessionStoreRef.current?.id;
4003
5292
  const removed = await clearSessions(all ? void 0 : cwd, currentId);
@@ -4051,6 +5340,26 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4051
5340
  if (!(current instanceof AgentLoop)) {
4052
5341
  return "Nothing to undo.";
4053
5342
  }
5343
+ const preview = current.previewLastTurnRetraction();
5344
+ if (preview.removed === 0) {
5345
+ return "Nothing to undo (no conversation turn to retract).";
5346
+ }
5347
+ const diffs = await buildUndoDiffs(
5348
+ preview.turn !== void 0 ? listTurnSnapshots(preview.turn) : [],
5349
+ cwd
5350
+ );
5351
+ const confirmed = await new Promise((resolve) => {
5352
+ setPendingRewind({
5353
+ title: "Undo last turn",
5354
+ confirmLabel: "undo",
5355
+ summary: `${preview.removed} message(s) will be retracted, ${diffs.length} file change(s) reverted.`,
5356
+ diffs,
5357
+ resolve
5358
+ });
5359
+ });
5360
+ if (!confirmed) {
5361
+ return "Undo cancelled.";
5362
+ }
4054
5363
  const { removed, turn } = await current.retractLastTurn();
4055
5364
  if (removed === 0) {
4056
5365
  return "Nothing to undo (no conversation turn to retract).";
@@ -4187,6 +5496,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
4187
5496
  redrawMessages,
4188
5497
  applySessionMode,
4189
5498
  setPendingRewind,
5499
+ setPendingConnect,
4190
5500
  showPicker
4191
5501
  ]);
4192
5502
  const runShellBang = useCallback(
@@ -4291,9 +5601,9 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
4291
5601
  }
4292
5602
  }, [cwd, config, isStreaming, epoch]);
4293
5603
  const sessionCost = sessionCostUsd();
4294
- return /* @__PURE__ */ jsxs12(Box11, { flexDirection: "column", children: [
4295
- /* @__PURE__ */ jsx10(MessageList, { messages }, `messages-${epoch}`),
4296
- (thinking || activity !== null) && /* @__PURE__ */ jsx10(
5604
+ return /* @__PURE__ */ jsxs14(Box13, { flexDirection: "column", children: [
5605
+ /* @__PURE__ */ jsx11(MessageList, { messages }, `messages-${epoch}`),
5606
+ (thinking || activity !== null) && /* @__PURE__ */ jsx11(
4297
5607
  ThinkingIndicator,
4298
5608
  {
4299
5609
  reasoning: thinkingText,
@@ -4301,9 +5611,9 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
4301
5611
  activity: activity ?? void 0
4302
5612
  }
4303
5613
  ),
4304
- !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx10(Text12, { dimColor: true, children: thoughtSummary }),
4305
- streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx10(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
4306
- pending && /* @__PURE__ */ jsx10(
5614
+ !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx11(Text14, { dimColor: true, children: thoughtSummary }),
5615
+ streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx11(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
5616
+ pending && /* @__PURE__ */ jsx11(
4307
5617
  PermissionPrompt,
4308
5618
  {
4309
5619
  request: pending.request,
@@ -4311,9 +5621,18 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
4311
5621
  onDecision: handleDecision
4312
5622
  }
4313
5623
  ),
4314
- planApproval && /* @__PURE__ */ jsx10(ExitPlanPrompt, { onDecision: handlePlanDecision }),
4315
- pendingRewind && /* @__PURE__ */ jsx10(RewindConfirmPrompt, { summary: pendingRewind.summary, onDecision: handleRewindDecision }),
4316
- picker && /* @__PURE__ */ jsx10(
5624
+ planApproval && /* @__PURE__ */ jsx11(ExitPlanPrompt, { onDecision: handlePlanDecision }),
5625
+ pendingRewind && /* @__PURE__ */ jsx11(
5626
+ RewindConfirmPrompt,
5627
+ {
5628
+ summary: pendingRewind.summary,
5629
+ title: pendingRewind.title,
5630
+ confirmLabel: pendingRewind.confirmLabel,
5631
+ diffs: pendingRewind.diffs,
5632
+ onDecision: handleRewindDecision
5633
+ }
5634
+ ),
5635
+ picker && /* @__PURE__ */ jsx11(
4317
5636
  SelectPrompt,
4318
5637
  {
4319
5638
  title: picker.title,
@@ -4322,21 +5641,38 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
4322
5641
  onCancel: handlePickerCancel
4323
5642
  }
4324
5643
  ),
4325
- queueItems.map((item) => /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
5644
+ pendingConnect && /* @__PURE__ */ jsx11(
5645
+ ConnectWizard,
5646
+ {
5647
+ existingProviderNames: config.providers.map((p) => p.name),
5648
+ existingModelNames: config.models.map((m) => m.name),
5649
+ onSave: handleConnectSave,
5650
+ onSetDefault: handleConnectSetDefault,
5651
+ onOpenConfig: handleConnectOpenConfig,
5652
+ onFinish: finishConnect,
5653
+ onCancel: cancelConnect
5654
+ }
5655
+ ),
5656
+ queueItems.slice(0, MAX_VISIBLE_QUEUED).map((item) => /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
4326
5657
  "queued: ",
4327
5658
  item.text.length > 80 ? `${item.text.slice(0, 80)}\u2026` : item.text
4328
5659
  ] }, item.id)),
4329
- pendingImages.map((staged) => /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
5660
+ queueItems.length > MAX_VISIBLE_QUEUED && /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
5661
+ "\u2026 ",
5662
+ queueItems.length - MAX_VISIBLE_QUEUED,
5663
+ " more queued"
5664
+ ] }),
5665
+ pendingImages.map((staged) => /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
4330
5666
  "[image attached: ",
4331
5667
  staged.image.path,
4332
5668
  "]"
4333
5669
  ] }, staged.id)),
4334
- /* @__PURE__ */ jsx10(TodoPanel, { todos }),
4335
- /* @__PURE__ */ jsx10(
5670
+ /* @__PURE__ */ jsx11(TodoPanel, { todos }),
5671
+ /* @__PURE__ */ jsx11(
4336
5672
  InputBox,
4337
5673
  {
4338
5674
  isStreaming,
4339
- disabled: pending !== null || planApproval || pendingRewind !== null || picker !== null,
5675
+ disabled: pending !== null || planApproval || pendingRewind !== null || pendingConnect !== null || picker !== null,
4340
5676
  commands: commandHints,
4341
5677
  cwd,
4342
5678
  refill: inputRefill,
@@ -4347,7 +5683,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
4347
5683
  onPasteImage: handlePasteImage
4348
5684
  }
4349
5685
  ),
4350
- /* @__PURE__ */ jsx10(
5686
+ /* @__PURE__ */ jsx11(
4351
5687
  StatusBar,
4352
5688
  {
4353
5689
  cwd,
@@ -4356,6 +5692,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
4356
5692
  tokens: usageRef.current.totalTokens,
4357
5693
  gitBranch,
4358
5694
  contextPercent,
5695
+ cachePercent: usageRef.current.cache ? cacheHitPercent(usageRef.current.cache) : null,
4359
5696
  sessionCostUsd: sessionCost,
4360
5697
  backgroundTasks: bgLabels
4361
5698
  }
@@ -4367,7 +5704,7 @@ function renderRepl(backend, opts) {
4367
5704
  defaultTaskManager.cleanup();
4368
5705
  });
4369
5706
  return render(
4370
- /* @__PURE__ */ jsx10(
5707
+ /* @__PURE__ */ jsx11(
4371
5708
  Repl,
4372
5709
  {
4373
5710
  backend,
@@ -4384,7 +5721,7 @@ function renderRepl(backend, opts) {
4384
5721
  }
4385
5722
 
4386
5723
  // src/config/loader.ts
4387
- import fs11 from "fs";
5724
+ import fs13 from "fs";
4388
5725
  import { parse as parse2 } from "smol-toml";
4389
5726
  var PartialConfigSchema = ConfigSchema.partial();
4390
5727
  function parseTomlFile(content, filePath) {
@@ -4412,11 +5749,12 @@ function applyOverrides(config, overrides) {
4412
5749
  return merged;
4413
5750
  }
4414
5751
  function readTomlFileSync(filePath) {
4415
- if (!fs11.existsSync(filePath)) return {};
4416
- const content = fs11.readFileSync(filePath, "utf8");
5752
+ if (!fs13.existsSync(filePath)) return {};
5753
+ const content = fs13.readFileSync(filePath, "utf8");
4417
5754
  return parseTomlFile(content, filePath);
4418
5755
  }
4419
5756
  function loadConfigSync(cwd, overrides) {
5757
+ loadEnvFile();
4420
5758
  const global = readTomlFileSync(globalConfigPath());
4421
5759
  const project = readTomlFileSync(projectConfigPath(cwd));
4422
5760
  const merged = applyOverrides(mergeConfig(global, project), overrides);
@@ -4517,14 +5855,17 @@ async function printMode(loop, prompt, cwd, json, imagePaths) {
4517
5855
  case "finish":
4518
5856
  usage.add(event.usage);
4519
5857
  break;
4520
- case "retry":
4521
- if (!json)
5858
+ case "retry": {
5859
+ if (!json) {
5860
+ const wait = event.delayMs !== void 0 && event.delayMs >= 1e3 ? ` in ${Math.round(event.delayMs / 1e3)}s` : "";
4522
5861
  process.stderr.write(
4523
5862
  `
4524
- [retry ${event.attempt}/${event.maxAttempts}] ${event.reason}
5863
+ [retry ${event.attempt}/${event.maxAttempts}${wait}] ${event.reason}
4525
5864
  `
4526
5865
  );
5866
+ }
4527
5867
  break;
5868
+ }
4528
5869
  case "notice":
4529
5870
  if (!json) process.stderr.write(`
4530
5871
  [notice] ${event.message}
@@ -4659,6 +6000,7 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
4659
6000
  }
4660
6001
  if (resumed) {
4661
6002
  await loop.loadMessages(resumed.messages);
6003
+ await loadTodos(cwd);
4662
6004
  if (sessionStore) {
4663
6005
  hydrateSnapshots(await loadSessionSnapshots(sessionStore.dir));
4664
6006
  }