@cryer/star-cli 0.2.0 → 0.2.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
@@ -4,6 +4,7 @@ import {
4
4
  SKIP_DIRS,
5
5
  TodoStore,
6
6
  WORKING_DIFF_MAX_LINES,
7
+ appendUserMemory,
7
8
  bashTool,
8
9
  buildAllowRule,
9
10
  buildCommitPrompt,
@@ -12,25 +13,32 @@ import {
12
13
  collectWorkingDiff,
13
14
  compactMessages,
14
15
  createDefaultRegistry,
16
+ createIgnorePredicate,
15
17
  defaultTaskManager,
18
+ discoverSkills,
16
19
  estimateTokens,
17
20
  formatTaskFinished,
18
21
  formatTaskList,
19
22
  formatTaskStarted,
20
23
  formatTodos,
21
24
  getGitSummary,
25
+ globalConfigPath,
22
26
  hydrateSnapshots,
23
27
  isAllowedByRules,
24
28
  isDangerousCommand,
25
29
  isGitRepo,
26
30
  isSensitivePath,
27
31
  listSnapshots,
32
+ projectConfigPath,
28
33
  reconcileToolCalls,
29
34
  resolveShell,
30
35
  rewindToSnapshot,
36
+ sessionsDir,
37
+ starHome,
31
38
  summarizeMessages,
32
- undoTurnSnapshots
33
- } from "./chunk-RLQXGX3N.js";
39
+ undoTurnSnapshots,
40
+ userMemoryPath
41
+ } from "./chunk-GN236FD4.js";
34
42
 
35
43
  // src/main.tsx
36
44
  import { Command } from "commander";
@@ -264,32 +272,17 @@ var UsageTracker = class {
264
272
  };
265
273
 
266
274
  // src/cli/repl.tsx
267
- import { Box as Box9, Text as Text10, render, useApp, useInput as useInput5 } from "ink";
268
- import { useCallback, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
275
+ import { Box as Box10, Text as Text11, render, useApp, useInput as useInput6 } from "ink";
276
+ import { useCallback, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState3 } from "react";
269
277
 
270
278
  // src/config/save.ts
271
279
  import fs from "fs";
272
- import path3 from "path";
273
- import { parse, stringify } from "smol-toml";
274
-
275
- // src/config/paths.ts
276
- import os from "os";
277
280
  import path2 from "path";
278
- function starHome() {
279
- return process.env.STAR_HOME ?? path2.join(os.homedir(), ".star-cli");
280
- }
281
- function globalConfigPath() {
282
- return path2.join(starHome(), "config.toml");
283
- }
284
- function sessionsDir() {
285
- return path2.join(starHome(), "sessions");
286
- }
287
- function projectConfigPath(cwd) {
288
- return path2.join(cwd, ".star", "config.toml");
289
- }
290
-
291
- // src/config/save.ts
281
+ import { parse, stringify } from "smol-toml";
292
282
  async function addAllowRule(rule) {
283
+ return addPermissionRule("allow", rule);
284
+ }
285
+ async function addPermissionRule(kind, rule) {
293
286
  const filePath = globalConfigPath();
294
287
  let raw = {};
295
288
  try {
@@ -299,11 +292,11 @@ async function addAllowRule(rule) {
299
292
  if (error.code !== "ENOENT") throw error;
300
293
  }
301
294
  const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
302
- const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
303
- if (allow.includes(rule)) return false;
304
- permissions.allow = [...allow, rule];
295
+ const rules = Array.isArray(permissions[kind]) ? permissions[kind] : [];
296
+ if (rules.includes(rule)) return false;
297
+ permissions[kind] = [...rules, rule];
305
298
  raw.permissions = permissions;
306
- await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
299
+ await fs.promises.mkdir(path2.dirname(filePath), { recursive: true });
307
300
  await fs.promises.writeFile(filePath, stringify(raw), "utf8");
308
301
  return true;
309
302
  }
@@ -317,10 +310,80 @@ async function savePermissionMode(mode) {
317
310
  if (error.code !== "ENOENT") throw error;
318
311
  }
319
312
  raw.permissionMode = mode;
320
- await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
313
+ await fs.promises.mkdir(path2.dirname(filePath), { recursive: true });
321
314
  await fs.promises.writeFile(filePath, stringify(raw), "utf8");
322
315
  }
323
316
 
317
+ // src/config/schema.ts
318
+ import { z } from "zod";
319
+ var ProviderConfigSchema = z.object({
320
+ name: z.string(),
321
+ protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
322
+ baseURL: z.string(),
323
+ apiKeyEnv: z.string().optional(),
324
+ apiKey: z.string().optional(),
325
+ headers: z.record(z.string()).optional()
326
+ });
327
+ var ModelConfigSchema = z.object({
328
+ name: z.string(),
329
+ provider: z.string(),
330
+ model: z.string(),
331
+ maxTokens: z.number().int().positive().optional(),
332
+ // Per-model context window; overrides the top-level contextMaxTokens for
333
+ // compaction and the status bar when set.
334
+ contextMaxTokens: z.number().int().positive().optional(),
335
+ // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
336
+ // the model is treated as unpriced.
337
+ promptPrice: z.number().nonnegative().optional(),
338
+ completionPrice: z.number().nonnegative().optional()
339
+ });
340
+ var PermissionsConfigSchema = z.object({
341
+ allow: z.array(z.string()).default([]),
342
+ deny: z.array(z.string()).default([])
343
+ });
344
+ var HookConfigSchema = z.object({
345
+ event: z.enum(["PreToolUse", "PostToolUse", "Stop"]),
346
+ matcher: z.string().refine(
347
+ (pattern) => {
348
+ try {
349
+ new RegExp(pattern);
350
+ return true;
351
+ } catch {
352
+ return false;
353
+ }
354
+ },
355
+ { message: "invalid regular expression" }
356
+ ).optional(),
357
+ command: z.string().min(1),
358
+ timeoutSec: z.number().positive().default(30)
359
+ });
360
+ var ConfigSchema = z.object({
361
+ defaultModel: z.string().default(""),
362
+ permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
363
+ providers: z.array(ProviderConfigSchema).default([]),
364
+ models: z.array(ModelConfigSchema).default([]),
365
+ maxSteps: z.number().int().positive().default(50),
366
+ contextMaxTokens: z.number().int().positive().default(1e5),
367
+ // Seconds without any stream part before a stalled response is ended
368
+ // gracefully (some relays never send the terminal chunks). Applies once
369
+ // streaming has started; the first part gets a longer, fixed allowance.
370
+ streamIdleTimeoutSec: z.number().positive().default(20),
371
+ contextCompaction: z.enum(["summary", "truncate"]).default("summary"),
372
+ // Optional per-session dollar budget; when set, the REPL stops the session
373
+ // once the estimated cost for the session reaches this amount.
374
+ sessionBudgetUsd: z.number().positive().optional(),
375
+ // Terminal bell (REPL only): ring when a turn takes longer than
376
+ // notifyBellThresholdSec, and when a background task finishes.
377
+ // STAR_NO_NOTIFY=1 disables without touching the config.
378
+ notifyBell: z.boolean().default(true),
379
+ notifyBellThresholdSec: z.number().positive().default(10),
380
+ permissions: PermissionsConfigSchema.default({ allow: [], deny: [] }),
381
+ hooks: z.array(HookConfigSchema).default([])
382
+ });
383
+ function contextWindowTokens(config, modelName) {
384
+ return config.models.find((m) => m.name === modelName)?.contextMaxTokens ?? config.contextMaxTokens;
385
+ }
386
+
324
387
  // src/llm/provider.ts
325
388
  import { createAnthropic } from "@ai-sdk/anthropic";
326
389
  import { createOpenAI } from "@ai-sdk/openai";
@@ -385,15 +448,15 @@ function createModel(config, modelName) {
385
448
 
386
449
  // src/session/checkpoints.ts
387
450
  import fs2 from "fs/promises";
388
- import path4 from "path";
451
+ import path3 from "path";
389
452
  function checkpointsDir(sessionDir) {
390
- return path4.join(sessionDir, "checkpoints");
453
+ return path3.join(sessionDir, "checkpoints");
391
454
  }
392
455
  function indexPath(sessionDir) {
393
- return path4.join(checkpointsDir(sessionDir), "index.json");
456
+ return path3.join(checkpointsDir(sessionDir), "index.json");
394
457
  }
395
458
  function checkpointContentPath(sessionDir, id) {
396
- return path4.join(checkpointsDir(sessionDir), `${id}.snapshot`);
459
+ return path3.join(checkpointsDir(sessionDir), `${id}.snapshot`);
397
460
  }
398
461
  async function listCheckpointRecords(sessionDir) {
399
462
  let raw;
@@ -441,11 +504,11 @@ async function loadSessionSnapshots(sessionDir) {
441
504
 
442
505
  // src/session/clear.ts
443
506
  import fs4 from "fs/promises";
444
- import path6 from "path";
507
+ import path5 from "path";
445
508
 
446
509
  // src/session/store.ts
447
510
  import fs3 from "fs/promises";
448
- import path5 from "path";
511
+ import path4 from "path";
449
512
  function generateId(now) {
450
513
  const pad = (n) => String(n).padStart(2, "0");
451
514
  const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
@@ -467,13 +530,13 @@ var SessionStore = class _SessionStore {
467
530
  initialized = false;
468
531
  constructor(id) {
469
532
  this.id = id;
470
- this.dir = path5.join(sessionsDir(), id);
533
+ this.dir = path4.join(sessionsDir(), id);
471
534
  }
472
535
  metaPath() {
473
- return path5.join(this.dir, "meta.json");
536
+ return path4.join(this.dir, "meta.json");
474
537
  }
475
538
  messagesPath() {
476
- return path5.join(this.dir, "messages.jsonl");
539
+ return path4.join(this.dir, "messages.jsonl");
477
540
  }
478
541
  static async create(cwd, model) {
479
542
  const store = new _SessionStore(generateId(/* @__PURE__ */ new Date()));
@@ -507,7 +570,7 @@ var SessionStore = class _SessionStore {
507
570
  const metas = [];
508
571
  for (const entry of entries) {
509
572
  try {
510
- const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
573
+ const raw = await fs3.readFile(path4.join(sessionsDir(), entry, "meta.json"), "utf8");
511
574
  const meta = JSON.parse(raw);
512
575
  if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
513
576
  } catch {
@@ -634,7 +697,7 @@ async function clearSessions(cwd, excludeId) {
634
697
  let removed2 = 0;
635
698
  for (const meta of metas) {
636
699
  if (meta.id === excludeId) continue;
637
- await fs4.rm(path6.join(sessionsDir(), meta.id), { recursive: true, force: true });
700
+ await fs4.rm(path5.join(sessionsDir(), meta.id), { recursive: true, force: true });
638
701
  removed2 += 1;
639
702
  }
640
703
  return removed2;
@@ -648,7 +711,7 @@ async function clearSessions(cwd, excludeId) {
648
711
  let removed = 0;
649
712
  for (const entry of entries) {
650
713
  if (entry === excludeId) continue;
651
- await fs4.rm(path6.join(sessionsDir(), entry), { recursive: true, force: true });
714
+ await fs4.rm(path5.join(sessionsDir(), entry), { recursive: true, force: true });
652
715
  removed += 1;
653
716
  }
654
717
  return removed;
@@ -656,11 +719,11 @@ async function clearSessions(cwd, excludeId) {
656
719
 
657
720
  // src/session/list.ts
658
721
  import fs5 from "fs/promises";
659
- import path7 from "path";
722
+ import path6 from "path";
660
723
  async function countMessages(id) {
661
724
  let raw;
662
725
  try {
663
- raw = await fs5.readFile(path7.join(sessionsDir(), id, "messages.jsonl"), "utf8");
726
+ raw = await fs5.readFile(path6.join(sessionsDir(), id, "messages.jsonl"), "utf8");
664
727
  } catch {
665
728
  return 0;
666
729
  }
@@ -744,10 +807,19 @@ function computeCostUsd(usage, model) {
744
807
  }
745
808
  function estimateCost(usage, modelName, model) {
746
809
  const cost = computeCostUsd(usage, model);
747
- if (cost === null || model === void 0) {
810
+ if (cost === null) {
811
+ if (model !== void 0) {
812
+ const missing = [
813
+ model.promptPrice === void 0 ? "promptPrice" : null,
814
+ model.completionPrice === void 0 ? "completionPrice" : null
815
+ ].filter((f) => f !== null);
816
+ if (missing.length === 1) {
817
+ return `Estimated cost: unknown (${model.name} is missing ${missing[0]} \u2014 both prices are needed)`;
818
+ }
819
+ }
748
820
  return `Estimated cost: unknown (no price configured for ${modelName})`;
749
821
  }
750
- return `Estimated cost: $${formatDollars(cost)} (${model.name} @ $${model.promptPrice}/M prompt, $${model.completionPrice}/M completion)`;
822
+ return `Estimated cost: $${formatDollars(cost)} (${model?.name} @ $${model?.promptPrice}/M prompt, $${model?.completionPrice}/M completion)`;
751
823
  }
752
824
 
753
825
  // src/session/usage.ts
@@ -909,11 +981,11 @@ function formatUsageDashboard(stats, models, options = {}) {
909
981
 
910
982
  // src/version.ts
911
983
  import fs6 from "fs";
912
- import path8 from "path";
984
+ import path7 from "path";
913
985
  import { fileURLToPath } from "url";
914
986
  function readPackageVersion() {
915
987
  try {
916
- const pkgPath = path8.join(path8.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
988
+ const pkgPath = path7.join(path7.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
917
989
  const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf8"));
918
990
  if (typeof pkg === "object" && pkg !== null) {
919
991
  const version = pkg.version;
@@ -938,8 +1010,8 @@ function budgetState(costUsd, budgetUsd) {
938
1010
  import { execFile } from "child_process";
939
1011
  import { randomUUID } from "crypto";
940
1012
  import { readFile as readFile2, unlink } from "fs/promises";
941
- import os2 from "os";
942
- import path9 from "path";
1013
+ import os from "os";
1014
+ import path8 from "path";
943
1015
  import { promisify } from "util";
944
1016
  var CLIPBOARD_TIMEOUT_MS = 5e3;
945
1017
  var MAX_CLIPBOARD_IMAGE_BYTES = 10 * 1024 * 1024;
@@ -1013,7 +1085,7 @@ async function commandExists(command) {
1013
1085
  }
1014
1086
  }
1015
1087
  async function readClipboardImage() {
1016
- const tempFile = path9.join(os2.tmpdir(), `star-clipboard-${randomUUID()}.png`);
1088
+ const tempFile = path8.join(os.tmpdir(), `star-clipboard-${randomUUID()}.png`);
1017
1089
  const hasPngpaste = process.platform === "darwin" ? await commandExists("pngpaste") : false;
1018
1090
  const plan = clipboardImagePlan(process.platform, tempFile, hasPngpaste);
1019
1091
  if (!plan) return null;
@@ -1059,7 +1131,7 @@ async function copyText(text) {
1059
1131
 
1060
1132
  // src/cli/commands/actions.ts
1061
1133
  import fs7 from "fs/promises";
1062
- import path10 from "path";
1134
+ import path9 from "path";
1063
1135
  var MIN_COMPACT_MESSAGES = 4;
1064
1136
  async function compactSession(opts) {
1065
1137
  const { backend, sessionStore, config, model } = opts;
@@ -1120,9 +1192,9 @@ async function exportSession(opts) {
1120
1192
  return "Nothing to export: the session has no messages.";
1121
1193
  }
1122
1194
  const sessionId = sessionStore?.id ?? `unknown-${Date.now()}`;
1123
- const target = arg ? path10.resolve(cwd, arg) : path10.resolve(cwd, `star-session-${sessionId}.md`);
1195
+ const target = arg ? path9.resolve(cwd, arg) : path9.resolve(cwd, `star-session-${sessionId}.md`);
1124
1196
  const existed = await fileExists(target);
1125
- await fs7.mkdir(path10.dirname(target), { recursive: true });
1197
+ await fs7.mkdir(path9.dirname(target), { recursive: true });
1126
1198
  await fs7.writeFile(target, renderSessionMarkdown(sessionId, messages));
1127
1199
  return existed ? `Exported ${messages.length} messages to ${target} (overwrote existing file).` : `Exported ${messages.length} messages to ${target}.`;
1128
1200
  }
@@ -1188,9 +1260,64 @@ function stringifyPart(value) {
1188
1260
  return typeof value === "string" ? value : JSON.stringify(value, null, 2);
1189
1261
  }
1190
1262
 
1263
+ // src/cli/commands/builtin.ts
1264
+ import { readFileSync } from "fs";
1265
+
1266
+ // src/session/search.ts
1267
+ var SNIPPET_LENGTH = 100;
1268
+ function flattenMessage(message) {
1269
+ const content = message.content;
1270
+ if (typeof content === "string") return content;
1271
+ if (!Array.isArray(content)) return "";
1272
+ const parts = [];
1273
+ for (const part of content) {
1274
+ if (part.type === "text") {
1275
+ parts.push(part.text);
1276
+ } else if (part.type === "tool-call") {
1277
+ parts.push(JSON.stringify(part.args));
1278
+ } else if (part.type === "tool-result") {
1279
+ parts.push(typeof part.result === "string" ? part.result : JSON.stringify(part.result));
1280
+ }
1281
+ }
1282
+ return parts.join("\n");
1283
+ }
1284
+ function makeSnippet(text, query) {
1285
+ const flat = text.replace(/\s+/g, " ").trim();
1286
+ const index = flat.toLowerCase().indexOf(query.toLowerCase());
1287
+ if (index === -1 || flat.length <= SNIPPET_LENGTH) return flat.slice(0, SNIPPET_LENGTH);
1288
+ const side = Math.max(0, Math.floor((SNIPPET_LENGTH - query.length) / 2));
1289
+ let start = Math.max(0, index - side);
1290
+ const end = Math.min(flat.length, start + SNIPPET_LENGTH);
1291
+ start = Math.max(0, end - SNIPPET_LENGTH);
1292
+ return `${start > 0 ? "\u2026" : ""}${flat.slice(start, end)}${end < flat.length ? "\u2026" : ""}`;
1293
+ }
1294
+ async function searchSessions(query, limit = 10) {
1295
+ const hits = [];
1296
+ const needle = query.toLowerCase();
1297
+ for (const meta of await SessionStore.list()) {
1298
+ if (hits.length >= limit) break;
1299
+ const store = await SessionStore.open(meta.id);
1300
+ if (!store) continue;
1301
+ const text = (await store.messages()).map(flattenMessage).filter((part) => part.length > 0).join("\n");
1302
+ if (text.toLowerCase().includes(needle)) {
1303
+ hits.push({ meta, snippet: makeSnippet(text, query) });
1304
+ }
1305
+ }
1306
+ return hits;
1307
+ }
1308
+ function formatSearchResults(hits, query) {
1309
+ if (hits.length === 0) return `No sessions matching "${query}".`;
1310
+ const lines = hits.map(({ meta, snippet }) => {
1311
+ const title = meta.title || "(untitled)";
1312
+ return `${shortSessionId(meta.id)} ${title} (${relativeTime(meta.updatedAt)})
1313
+ ${snippet}`;
1314
+ });
1315
+ return [...lines, "Resume with /resume <id>"].join("\n");
1316
+ }
1317
+
1191
1318
  // src/cli/diff-preview.ts
1192
1319
  import { readFile as readFile3 } from "fs/promises";
1193
- import path11 from "path";
1320
+ import path10 from "path";
1194
1321
  var CONTEXT_LINES = 2;
1195
1322
  var MAX_HUNK_LINES = 15;
1196
1323
  var MAX_TOTAL_LINES = 12;
@@ -1351,7 +1478,7 @@ async function generateDiffPreview(toolName, args, cwd) {
1351
1478
  if (toolName !== "edit_file" && toolName !== "write_file") return null;
1352
1479
  const filePath = getStringArg(args, "path");
1353
1480
  if (filePath === void 0) return null;
1354
- const absolute = path11.resolve(cwd, filePath);
1481
+ const absolute = path10.resolve(cwd, filePath);
1355
1482
  let existing;
1356
1483
  try {
1357
1484
  existing = await readFile3(absolute, "utf8");
@@ -1375,31 +1502,41 @@ async function generateDiffPreview(toolName, args, cwd) {
1375
1502
 
1376
1503
  // src/cli/commands/builtin.ts
1377
1504
  function registerBuiltinCommands(registry) {
1378
- registry.register({
1505
+ const register = (command) => registry.register(command);
1506
+ register({
1379
1507
  name: "help",
1380
1508
  description: "List available commands",
1381
1509
  usage: "/help",
1510
+ category: "General",
1382
1511
  run(_args, ctx) {
1383
- const lines = registry.list().map((cmd) => {
1512
+ const groups = /* @__PURE__ */ new Map();
1513
+ for (const cmd of registry.list()) {
1514
+ const category = cmd.category ?? "Other";
1384
1515
  const usage = cmd.usage ?? `/${cmd.name}`;
1385
- return `${usage} - ${cmd.description}`;
1386
- });
1516
+ const lines = groups.get(category) ?? [];
1517
+ lines.push(` ${usage} - ${cmd.description}`);
1518
+ groups.set(category, lines);
1519
+ }
1520
+ const sections = [...groups.entries()].map(([name, lines]) => [name, ...lines].join("\n"));
1387
1521
  ctx.addSystemMessage(`Available commands:
1388
- ${lines.join("\n")}`);
1522
+
1523
+ ${sections.join("\n\n")}`);
1389
1524
  }
1390
1525
  });
1391
- registry.register({
1526
+ register({
1392
1527
  name: "clear",
1393
1528
  description: "Clear message history",
1394
1529
  usage: "/clear",
1530
+ category: "General",
1395
1531
  run(_args, ctx) {
1396
1532
  ctx.clearMessages();
1397
1533
  }
1398
1534
  });
1399
- registry.register({
1535
+ register({
1400
1536
  name: "new",
1401
1537
  description: "Start a new session with a clean context",
1402
1538
  usage: "/new",
1539
+ category: "Sessions",
1403
1540
  async run(_args, ctx) {
1404
1541
  if (!ctx.newSession) {
1405
1542
  ctx.addSystemMessage("/new: this context cannot start a new session.");
@@ -1408,10 +1545,11 @@ ${lines.join("\n")}`);
1408
1545
  ctx.addSystemMessage(await ctx.newSession());
1409
1546
  }
1410
1547
  });
1411
- registry.register({
1548
+ register({
1412
1549
  name: "clear-sessions",
1413
1550
  description: "Delete stored sessions: this directory by default, every session with --all (the current session is kept)",
1414
1551
  usage: "/clear-sessions [--all]",
1552
+ category: "Sessions",
1415
1553
  async run(args, ctx) {
1416
1554
  const arg = args.trim();
1417
1555
  if (arg !== "" && arg !== "--all") {
@@ -1427,157 +1565,260 @@ ${lines.join("\n")}`);
1427
1565
  ctx.addSystemMessage(await ctx.clearSessions(arg === "--all"));
1428
1566
  }
1429
1567
  });
1430
- registry.register({
1568
+ register({
1431
1569
  name: "exit",
1432
1570
  description: "Exit the application",
1433
1571
  usage: "/exit",
1572
+ category: "General",
1434
1573
  run(_args, ctx) {
1435
1574
  ctx.exit();
1436
1575
  }
1437
1576
  });
1438
- registry.register({
1577
+ register({
1439
1578
  name: "q",
1440
1579
  description: "Exit the application (alias of /exit)",
1441
1580
  usage: "/q",
1581
+ category: "General",
1442
1582
  run(_args, ctx) {
1443
1583
  ctx.exit();
1444
1584
  }
1445
1585
  });
1446
- registry.register({
1586
+ register({
1447
1587
  name: "model",
1448
- description: "List available models or switch the current model",
1588
+ description: "Switch the current model (opens a picker when no name is given)",
1449
1589
  usage: "/model [name]",
1590
+ category: "Settings",
1450
1591
  async run(args, ctx) {
1451
1592
  if (!args) {
1452
- ctx.addSystemMessage(ctx.listModels());
1593
+ ctx.addSystemMessage(await ctx.pickModel());
1453
1594
  } else {
1454
1595
  ctx.addSystemMessage(await ctx.switchModel(args));
1455
1596
  }
1456
1597
  }
1457
1598
  });
1458
- registry.register({
1599
+ register({
1459
1600
  name: "resume",
1460
- description: "List sessions or resume a session by id",
1601
+ description: "Resume a session by id, or pick one from a list (--all for every directory)",
1461
1602
  usage: "/resume [sessionId | --all]",
1603
+ category: "Sessions",
1462
1604
  async run(args, ctx) {
1463
1605
  const arg = args.trim();
1464
1606
  if (arg === "--all") {
1465
- ctx.addSystemMessage(await ctx.listSessions(true));
1607
+ ctx.addSystemMessage(await ctx.pickSession(true));
1466
1608
  } else if (!arg) {
1467
- ctx.addSystemMessage(await ctx.listSessions());
1609
+ ctx.addSystemMessage(await ctx.pickSession(false));
1468
1610
  } else {
1469
1611
  ctx.addSystemMessage(await ctx.resumeSession(arg));
1470
1612
  }
1471
1613
  }
1472
1614
  });
1473
- registry.register({
1615
+ register({
1616
+ name: "fork",
1617
+ description: "Fork the current session into a new one and switch to it",
1618
+ usage: "/fork",
1619
+ category: "Sessions",
1620
+ async run(_args, ctx) {
1621
+ ctx.addSystemMessage(await ctx.forkSession());
1622
+ }
1623
+ });
1624
+ register({
1625
+ name: "search",
1626
+ description: "Full-text search across all stored sessions",
1627
+ usage: "/search <query>",
1628
+ category: "Sessions",
1629
+ async run(args, ctx) {
1630
+ const query = args.trim();
1631
+ if (!query) {
1632
+ ctx.addSystemMessage("Usage: /search <query>");
1633
+ return;
1634
+ }
1635
+ ctx.addSystemMessage(formatSearchResults(await searchSessions(query), query));
1636
+ }
1637
+ });
1638
+ register({
1474
1639
  name: "todo",
1475
1640
  description: "Show the current todo list",
1476
1641
  usage: "/todo",
1642
+ category: "Info",
1477
1643
  async run(_args, ctx) {
1478
1644
  ctx.addSystemMessage(await ctx.showTodos());
1479
1645
  }
1480
1646
  });
1481
- registry.register({
1647
+ register({
1482
1648
  name: "tasks",
1483
1649
  description: "List background shell tasks",
1484
1650
  usage: "/tasks",
1651
+ category: "Info",
1485
1652
  run(_args, ctx) {
1486
1653
  ctx.addSystemMessage(ctx.listTasks());
1487
1654
  }
1488
1655
  });
1489
- registry.register({
1656
+ register({
1490
1657
  name: "cost",
1491
1658
  description: "Show API token usage for this session",
1492
1659
  usage: "/cost",
1660
+ category: "Info",
1493
1661
  run(_args, ctx) {
1494
1662
  ctx.addSystemMessage(ctx.showUsage());
1495
1663
  }
1496
1664
  });
1497
- registry.register({
1665
+ register({
1498
1666
  name: "usage",
1499
1667
  description: "Show token usage aggregated across all sessions",
1500
1668
  usage: "/usage",
1669
+ category: "Info",
1501
1670
  async run(_args, ctx) {
1502
1671
  ctx.addSystemMessage(await ctx.showGlobalUsage());
1503
1672
  }
1504
1673
  });
1505
- registry.register({
1674
+ register({
1506
1675
  name: "config",
1507
1676
  description: "Show the current configuration",
1508
1677
  usage: "/config",
1678
+ category: "Info",
1509
1679
  run(_args, ctx) {
1510
1680
  ctx.addSystemMessage(ctx.describeConfig());
1511
1681
  }
1512
1682
  });
1513
- registry.register({
1683
+ register({
1514
1684
  name: "compact",
1515
1685
  description: "Compact the conversation history to free up context",
1516
1686
  usage: "/compact",
1687
+ category: "Sessions",
1517
1688
  async run(_args, ctx) {
1518
1689
  ctx.addSystemMessage(await ctx.compactContext());
1519
1690
  }
1520
1691
  });
1521
- registry.register({
1692
+ register({
1522
1693
  name: "export",
1523
1694
  description: "Export the current session to a Markdown file",
1524
1695
  usage: "/export [path]",
1696
+ category: "Sessions",
1525
1697
  async run(args, ctx) {
1526
1698
  ctx.addSystemMessage(await ctx.exportSession(args));
1527
1699
  }
1528
1700
  });
1529
- registry.register({
1701
+ register({
1530
1702
  name: "permission",
1531
- description: "Show or set the global permission mode (ask | auto | readonly | yolo)",
1703
+ description: "Set the permission mode (opens a picker when no mode is given)",
1532
1704
  usage: "/permission [ask|auto|readonly|yolo]",
1705
+ category: "Settings",
1533
1706
  async run(args, ctx) {
1534
- ctx.addSystemMessage(await ctx.permissionMode(args.trim()));
1707
+ const arg = args.trim();
1708
+ if (!arg) {
1709
+ ctx.addSystemMessage(await ctx.pickPermissionMode());
1710
+ } else {
1711
+ ctx.addSystemMessage(await ctx.permissionMode(arg));
1712
+ }
1535
1713
  }
1536
1714
  });
1537
- registry.register({
1715
+ register({
1538
1716
  name: "plan",
1539
1717
  description: "Toggle plan mode: read-only research, then approve the plan before executing",
1540
1718
  usage: "/plan",
1719
+ category: "Settings",
1541
1720
  async run(_args, ctx) {
1542
1721
  ctx.addSystemMessage(await ctx.planMode());
1543
1722
  }
1544
1723
  });
1545
- registry.register({
1724
+ register({
1725
+ name: "memory",
1726
+ description: "Show user memory (MEMORY.md), or add an entry",
1727
+ usage: "/memory [add <text>]",
1728
+ category: "Settings",
1729
+ run(args, ctx) {
1730
+ const arg = args.trim();
1731
+ if (arg === "add" || arg.startsWith("add ")) {
1732
+ const text = arg.slice(3).trim();
1733
+ if (!text) {
1734
+ ctx.addSystemMessage("Usage: /memory add <text>");
1735
+ return;
1736
+ }
1737
+ appendUserMemory(text);
1738
+ ctx.addSystemMessage(`Saved to user memory: - ${text}`);
1739
+ return;
1740
+ }
1741
+ if (arg) {
1742
+ ctx.addSystemMessage(`Unknown argument "${arg}". Usage: /memory [add <text>]`);
1743
+ return;
1744
+ }
1745
+ const file = userMemoryPath();
1746
+ let content = "";
1747
+ try {
1748
+ content = readFileSync(file, "utf8").trim();
1749
+ } catch {
1750
+ content = "";
1751
+ }
1752
+ if (!content) {
1753
+ ctx.addSystemMessage(
1754
+ `No user memory yet (${file}).
1755
+ Add entries with /memory add <text> or ask the agent to remember something.`
1756
+ );
1757
+ return;
1758
+ }
1759
+ ctx.addSystemMessage(`User memory (${file}):
1760
+ ${content}`);
1761
+ }
1762
+ });
1763
+ register({
1546
1764
  name: "undo",
1547
1765
  description: "Undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages",
1548
1766
  usage: "/undo",
1767
+ category: "Changes",
1549
1768
  async run(_args, ctx) {
1550
1769
  ctx.addSystemMessage(await ctx.undo());
1551
1770
  }
1552
1771
  });
1553
- registry.register({
1772
+ register({
1554
1773
  name: "rewind",
1555
1774
  description: "List file-change checkpoints, or rewind to just before one: restore files and retract the conversation",
1556
1775
  usage: "/rewind [n]",
1776
+ category: "Changes",
1557
1777
  async run(args, ctx) {
1558
1778
  ctx.addSystemMessage(await ctx.rewind(args.trim()));
1559
1779
  }
1560
1780
  });
1561
- registry.register({
1781
+ register({
1562
1782
  name: "init",
1563
1783
  description: "Generate an AGENTS.md for the current project",
1564
1784
  usage: "/init [force]",
1785
+ category: "Settings",
1565
1786
  async run(args, ctx) {
1566
1787
  ctx.addSystemMessage(await ctx.initProject(args));
1567
1788
  }
1568
1789
  });
1569
- registry.register({
1790
+ register({
1570
1791
  name: "doctor",
1571
1792
  description: "Run environment and configuration checks",
1572
1793
  usage: "/doctor",
1794
+ category: "Info",
1573
1795
  async run(_args, ctx) {
1574
1796
  ctx.addSystemMessage(await ctx.runDoctor());
1575
1797
  }
1576
1798
  });
1577
- registry.register({
1799
+ register({
1800
+ name: "skills",
1801
+ description: "List available skills (project scope overrides user scope)",
1802
+ usage: "/skills",
1803
+ category: "Info",
1804
+ run(_args, ctx) {
1805
+ const skills = discoverSkills(ctx.cwd ?? process.cwd());
1806
+ if (skills.length === 0) {
1807
+ ctx.addSystemMessage(
1808
+ "No skills found. Add a SKILL.md under .star/skills/<name>/ (project) or ~/.star-cli/skills/<name>/ (user)."
1809
+ );
1810
+ return;
1811
+ }
1812
+ const lines = skills.map((s) => `${s.name} [${s.scope}] \u2014 ${s.description}`);
1813
+ ctx.addSystemMessage(`Available skills:
1814
+ ${lines.join("\n")}`);
1815
+ }
1816
+ });
1817
+ register({
1578
1818
  name: "commit",
1579
1819
  description: "Analyze uncommitted changes and create a git commit (Conventional Commits)",
1580
1820
  usage: "/commit [instructions]",
1821
+ category: "Changes",
1581
1822
  async run(args, ctx) {
1582
1823
  const cwd = ctx.cwd ?? process.cwd();
1583
1824
  if (!isGitRepo(cwd)) {
@@ -1596,10 +1837,11 @@ ${lines.join("\n")}`);
1596
1837
  ctx.addSystemMessage("/commit: this context cannot submit prompts to the model.");
1597
1838
  }
1598
1839
  });
1599
- registry.register({
1840
+ register({
1600
1841
  name: "copy",
1601
1842
  description: "Copy the last assistant reply to the clipboard (`all` for the whole conversation)",
1602
1843
  usage: "/copy [all]",
1844
+ category: "Sessions",
1603
1845
  async run(args, ctx) {
1604
1846
  const arg = args.trim();
1605
1847
  if (arg !== "" && arg !== "all") {
@@ -1624,10 +1866,11 @@ ${lines.join("\n")}`);
1624
1866
  );
1625
1867
  }
1626
1868
  });
1627
- registry.register({
1869
+ register({
1628
1870
  name: "diff",
1629
1871
  description: "Show uncommitted changes (git status + colored diff)",
1630
1872
  usage: "/diff",
1873
+ category: "Changes",
1631
1874
  run(_args, ctx) {
1632
1875
  const cwd = ctx.cwd ?? process.cwd();
1633
1876
  if (!isGitRepo(cwd)) {
@@ -1663,15 +1906,15 @@ ${result.diff}${note ? `
1663
1906
  }
1664
1907
 
1665
1908
  // src/cli/commands/custom.ts
1666
- import { readFileSync, readdirSync } from "fs";
1667
- import path12 from "path";
1909
+ import { readFileSync as readFileSync2, readdirSync } from "fs";
1910
+ import path11 from "path";
1668
1911
  var NAME_PATTERN = /^[a-z0-9-]+$/;
1669
1912
  var DESCRIPTION_PATTERN = /^<!--\s*description:\s*(.+?)\s*-->\s*$/;
1670
1913
  var DEFAULT_DESCRIPTION = "Custom prompt command";
1671
1914
  function parseCommandFile(filePath, name) {
1672
1915
  let raw;
1673
1916
  try {
1674
- raw = readFileSync(filePath, "utf8");
1917
+ raw = readFileSync2(filePath, "utf8");
1675
1918
  } catch {
1676
1919
  return null;
1677
1920
  }
@@ -1699,14 +1942,14 @@ function scanDir(dir, into) {
1699
1942
  if (!entry.endsWith(".md")) continue;
1700
1943
  const name = entry.slice(0, -".md".length);
1701
1944
  if (!NAME_PATTERN.test(name)) continue;
1702
- const parsed = parseCommandFile(path12.join(dir, entry), name);
1945
+ const parsed = parseCommandFile(path11.join(dir, entry), name);
1703
1946
  if (parsed) into.set(name, parsed);
1704
1947
  }
1705
1948
  }
1706
1949
  function loadCustomCommands(cwd, home = starHome()) {
1707
1950
  const byName = /* @__PURE__ */ new Map();
1708
- scanDir(path12.join(home, "commands"), byName);
1709
- scanDir(path12.join(cwd, ".star", "commands"), byName);
1951
+ scanDir(path11.join(home, "commands"), byName);
1952
+ scanDir(path11.join(cwd, ".star", "commands"), byName);
1710
1953
  return [...byName.values()];
1711
1954
  }
1712
1955
  function registerCustomCommands(registry, cwd, home) {
@@ -1717,6 +1960,7 @@ function registerCustomCommands(registry, cwd, home) {
1717
1960
  name: command.name,
1718
1961
  description: command.description,
1719
1962
  usage: `/${command.name} [args]`,
1963
+ category: "Custom",
1720
1964
  run(args, ctx) {
1721
1965
  const prompt = command.template.split("$ARGUMENTS").join(args);
1722
1966
  if (ctx.submitPrompt) {
@@ -1732,7 +1976,7 @@ function registerCustomCommands(registry, cwd, home) {
1732
1976
 
1733
1977
  // src/cli/commands/doctor.ts
1734
1978
  import fs8 from "fs/promises";
1735
- import path13 from "path";
1979
+ import path12 from "path";
1736
1980
  var MIN_NODE_MAJOR = 20;
1737
1981
  var REGISTRY_URL = "https://registry.npmjs.org/";
1738
1982
  var REGISTRY_TIMEOUT_MS = 5e3;
@@ -1784,7 +2028,7 @@ async function runDoctor(opts) {
1784
2028
  detail: `${spec.label} (${spec.shell})`
1785
2029
  });
1786
2030
  }
1787
- const configPath = path13.join(home, "config.toml");
2031
+ const configPath = path12.join(home, "config.toml");
1788
2032
  const configExists = await pathExists(configPath);
1789
2033
  checks.push({
1790
2034
  name: "STAR_HOME / config",
@@ -1821,12 +2065,12 @@ async function runDoctor(opts) {
1821
2065
  });
1822
2066
  }
1823
2067
  }
1824
- const sessions = path13.join(home, "sessions");
2068
+ const sessions = path12.join(home, "sessions");
1825
2069
  let writable = false;
1826
2070
  let writeError = "";
1827
2071
  try {
1828
2072
  await fs8.mkdir(sessions, { recursive: true });
1829
- const probe = path13.join(sessions, `.doctor-probe-${process.pid}`);
2073
+ const probe = path12.join(sessions, `.doctor-probe-${process.pid}`);
1830
2074
  await fs8.writeFile(probe, "ok");
1831
2075
  await fs8.unlink(probe);
1832
2076
  writable = true;
@@ -1864,7 +2108,7 @@ function formatDoctorReport(report) {
1864
2108
 
1865
2109
  // src/cli/commands/init-project.ts
1866
2110
  import fs9 from "fs/promises";
1867
- import path14 from "path";
2111
+ import path13 from "path";
1868
2112
  import { generateText } from "ai";
1869
2113
  var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", "coverage"]);
1870
2114
  var MAX_ENTRIES = 20;
@@ -1879,7 +2123,7 @@ async function fileExists2(target) {
1879
2123
  }
1880
2124
  async function readPackageJson(cwd) {
1881
2125
  try {
1882
- const raw = await fs9.readFile(path14.join(cwd, "package.json"), "utf8");
2126
+ const raw = await fs9.readFile(path13.join(cwd, "package.json"), "utf8");
1883
2127
  const parsed = JSON.parse(raw);
1884
2128
  if (typeof parsed !== "object" || parsed === null) return null;
1885
2129
  return parsed;
@@ -1896,10 +2140,10 @@ function stringRecord(value) {
1896
2140
  return out;
1897
2141
  }
1898
2142
  async function detectPackageManager(cwd) {
1899
- if (await fileExists2(path14.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
1900
- if (await fileExists2(path14.join(cwd, "yarn.lock"))) return "yarn";
1901
- if (await fileExists2(path14.join(cwd, "package-lock.json"))) return "npm";
1902
- if (await fileExists2(path14.join(cwd, "bun.lockb"))) return "bun";
2143
+ if (await fileExists2(path13.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2144
+ if (await fileExists2(path13.join(cwd, "yarn.lock"))) return "yarn";
2145
+ if (await fileExists2(path13.join(cwd, "package-lock.json"))) return "npm";
2146
+ if (await fileExists2(path13.join(cwd, "bun.lockb"))) return "bun";
1903
2147
  return null;
1904
2148
  }
1905
2149
  function inferCommands(pm, scripts) {
@@ -1933,7 +2177,7 @@ async function listEntries(cwd) {
1933
2177
  async function readReadmeHead(cwd) {
1934
2178
  for (const name of ["README.md", "readme.md", "README"]) {
1935
2179
  try {
1936
- const raw = await fs9.readFile(path14.join(cwd, name), "utf8");
2180
+ const raw = await fs9.readFile(path13.join(cwd, name), "utf8");
1937
2181
  return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).slice(0, README_HEAD_LINES);
1938
2182
  } catch {
1939
2183
  }
@@ -1946,14 +2190,14 @@ async function scanProject(cwd) {
1946
2190
  const scripts = stringRecord(pkg?.scripts);
1947
2191
  return {
1948
2192
  name: typeof pkg?.name === "string" ? pkg.name : null,
1949
- dirName: path14.basename(cwd),
2193
+ dirName: path13.basename(cwd),
1950
2194
  packageManager,
1951
2195
  scripts,
1952
2196
  dependencies: Object.keys(stringRecord(pkg?.dependencies)),
1953
2197
  devDependencies: Object.keys(stringRecord(pkg?.devDependencies)),
1954
2198
  commands: inferCommands(packageManager, scripts),
1955
- hasTsconfig: await fileExists2(path14.join(cwd, "tsconfig.json")),
1956
- isGitRepo: await fileExists2(path14.join(cwd, ".git")),
2199
+ hasTsconfig: await fileExists2(path13.join(cwd, "tsconfig.json")),
2200
+ isGitRepo: await fileExists2(path13.join(cwd, ".git")),
1957
2201
  readmeHead: await readReadmeHead(cwd),
1958
2202
  entries: await listEntries(cwd)
1959
2203
  };
@@ -2020,7 +2264,7 @@ Write the AGENTS.md.`
2020
2264
  }
2021
2265
  async function initProject(opts) {
2022
2266
  const { cwd, args, model } = opts;
2023
- const target = path14.join(cwd, "AGENTS.md");
2267
+ const target = path13.join(cwd, "AGENTS.md");
2024
2268
  const force = args.split(/\s+/).includes("force");
2025
2269
  if (!force && await fileExists2(target)) {
2026
2270
  return {
@@ -2096,7 +2340,7 @@ function parseSlashCommand(input) {
2096
2340
  }
2097
2341
 
2098
2342
  // src/cli/commands/rewind.ts
2099
- import path15 from "path";
2343
+ import path14 from "path";
2100
2344
  function planRewind(snapshots, id) {
2101
2345
  const target = snapshots.find((snapshot) => snapshot.id === id);
2102
2346
  if (!target) return null;
@@ -2110,7 +2354,7 @@ function planRewind(snapshots, id) {
2110
2354
  return { target, affected, messageIndex };
2111
2355
  }
2112
2356
  function displayPath(filePath, cwd) {
2113
- const relative = path15.relative(cwd, filePath);
2357
+ const relative = path14.relative(cwd, filePath);
2114
2358
  return relative !== "" && !relative.startsWith("..") ? relative : filePath;
2115
2359
  }
2116
2360
  function formatTime(timestamp) {
@@ -2133,6 +2377,33 @@ function formatCheckpointList(snapshots, cwd) {
2133
2377
  ].join("\n");
2134
2378
  }
2135
2379
 
2380
+ // src/cli/commands/suggest.ts
2381
+ var MAX_SUGGESTIONS = 5;
2382
+ function isSubsequence(query, target) {
2383
+ let i = 0;
2384
+ for (const ch of target) {
2385
+ if (ch === query[i]) i += 1;
2386
+ if (i === query.length) return true;
2387
+ }
2388
+ return query.length === 0;
2389
+ }
2390
+ function filterCommands(input, commands) {
2391
+ if (!input.startsWith("/")) return [];
2392
+ if (/\s/.test(input)) return [];
2393
+ const prefix = input.slice(1).toLowerCase();
2394
+ const byName = (a, b) => a.name.localeCompare(b.name);
2395
+ const prefixed = commands.filter((cmd) => cmd.name.toLowerCase().startsWith(prefix)).sort(byName);
2396
+ const fuzzy = commands.filter(
2397
+ (cmd) => !cmd.name.toLowerCase().startsWith(prefix) && isSubsequence(prefix, cmd.name.toLowerCase())
2398
+ ).sort(byName);
2399
+ return [...prefixed, ...fuzzy].slice(0, MAX_SUGGESTIONS);
2400
+ }
2401
+ function didYouMeanSuffix(names) {
2402
+ if (names.length === 0) return "";
2403
+ const listed = names.slice(0, 3).map((name) => `/${name}`);
2404
+ return ` Did you mean: ${listed.join(", ")}?`;
2405
+ }
2406
+
2136
2407
  // src/cli/components/ExitPlanPrompt.tsx
2137
2408
  import { Box, Text, useInput } from "ink";
2138
2409
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -2153,18 +2424,9 @@ function ExitPlanPrompt({ onDecision }) {
2153
2424
  import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
2154
2425
  import { useEffect, useRef, useState } from "react";
2155
2426
 
2156
- // src/cli/commands/suggest.ts
2157
- var MAX_SUGGESTIONS = 5;
2158
- function filterCommands(input, commands) {
2159
- if (!input.startsWith("/")) return [];
2160
- if (/\s/.test(input)) return [];
2161
- const prefix = input.slice(1).toLowerCase();
2162
- return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(prefix)).sort((a, b) => a.name.localeCompare(b.name)).slice(0, MAX_SUGGESTIONS);
2163
- }
2164
-
2165
2427
  // src/cli/path-suggest.ts
2166
2428
  import { readdir } from "fs/promises";
2167
- import path16 from "path";
2429
+ import path15 from "path";
2168
2430
  var MAX_SCAN_ENTRIES = 2e3;
2169
2431
  function extractAtToken(value, cursor) {
2170
2432
  const before = value.slice(0, cursor);
@@ -2179,9 +2441,10 @@ async function suggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSu
2179
2441
  const dirPart = slash >= 0 ? normalized.slice(0, slash + 1) : "";
2180
2442
  const basePrefix = slash >= 0 ? normalized.slice(slash + 1) : normalized;
2181
2443
  if (dirPart.split("/").some((segment) => SKIP_DIRS.has(segment))) return [];
2182
- const dirAbs = path16.resolve(cwd, dirPart === "" ? "." : dirPart);
2444
+ const dirAbs = path15.resolve(cwd, dirPart === "" ? "." : dirPart);
2183
2445
  const entries = await readdir(dirAbs, { withFileTypes: true }).catch(() => null);
2184
2446
  if (!entries) return [];
2447
+ const ignore = createIgnorePredicate(cwd);
2185
2448
  const lower = basePrefix.toLowerCase();
2186
2449
  const matches = [];
2187
2450
  let scanned = 0;
@@ -2193,6 +2456,7 @@ async function suggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSu
2193
2456
  if (isDir && SKIP_DIRS.has(entry.name)) continue;
2194
2457
  if (entry.name.startsWith(".") && !basePrefix.startsWith(".")) continue;
2195
2458
  if (!entry.name.toLowerCase().startsWith(lower)) continue;
2459
+ if (ignore?.(path15.join(dirAbs, entry.name), isDir)) continue;
2196
2460
  matches.push({ path: `${dirPart}${entry.name}${isDir ? "/" : ""}`, isDir });
2197
2461
  }
2198
2462
  matches.sort((a, b) => a.path.localeCompare(b.path));
@@ -2207,6 +2471,7 @@ function InputBox({
2207
2471
  commands,
2208
2472
  cwd,
2209
2473
  refill,
2474
+ initialHistory,
2210
2475
  onSubmit,
2211
2476
  onInterrupt,
2212
2477
  onExit,
@@ -2214,7 +2479,7 @@ function InputBox({
2214
2479
  }) {
2215
2480
  const [value, setValue] = useState("");
2216
2481
  const [cursor, setCursor] = useState(0);
2217
- const [history, setHistory] = useState([]);
2482
+ const [history, setHistory] = useState(() => initialHistory ?? []);
2218
2483
  const [highlight, setHighlight] = useState(0);
2219
2484
  const [suggestionsDismissed, setSuggestionsDismissed] = useState(false);
2220
2485
  const [pathSuggestions, setPathSuggestions] = useState([]);
@@ -2396,12 +2661,13 @@ function InputBox({
2396
2661
  /* @__PURE__ */ jsx2(Text2, { inverse: true, children: at }),
2397
2662
  /* @__PURE__ */ jsx2(Text2, { children: after })
2398
2663
  ] }),
2399
- suggestions.length > 0 && /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, children: suggestions.map(
2400
- (cmd, index) => index === activeIndex ? /* @__PURE__ */ jsx2(Text2, { bold: true, inverse: true, children: `/${cmd.name} - ${cmd.description}` }, cmd.name) : /* @__PURE__ */ jsxs2(Text2, { children: [
2401
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: `/${cmd.name}` }),
2664
+ suggestions.length > 0 && /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, children: suggestions.map((cmd, index) => {
2665
+ const label = cmd.usage ?? `/${cmd.name}`;
2666
+ return index === activeIndex ? /* @__PURE__ */ jsx2(Text2, { bold: true, inverse: true, children: `${label} - ${cmd.description}` }, cmd.name) : /* @__PURE__ */ jsxs2(Text2, { children: [
2667
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: label }),
2402
2668
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: ` - ${cmd.description}` })
2403
- ] }, cmd.name)
2404
- ) }),
2669
+ ] }, cmd.name);
2670
+ }) }),
2405
2671
  showPathSuggestions && /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, children: pathSuggestions.map(
2406
2672
  (sugg, index) => index === activeIndex ? /* @__PURE__ */ jsx2(Text2, { bold: true, inverse: true, children: `@${sugg.path}` }, sugg.path) : /* @__PURE__ */ jsxs2(Text2, { children: [
2407
2673
  /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: `@${sugg.path}` }),
@@ -2509,10 +2775,72 @@ function RewindConfirmPrompt({ summary, onDecision }) {
2509
2775
  ] });
2510
2776
  }
2511
2777
 
2778
+ // src/cli/components/SelectPrompt.tsx
2779
+ import { Box as Box6, Text as Text7, useInput as useInput5 } from "ink";
2780
+ import { useState as useState2 } from "react";
2781
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
2782
+ var SELECT_PROMPT_MAX_VISIBLE = 9;
2783
+ function SelectPrompt({ title, options, onSelect, onCancel }) {
2784
+ const [index, setIndex] = useState2(0);
2785
+ useInput5((input, key) => {
2786
+ if (key.escape) {
2787
+ onCancel();
2788
+ return;
2789
+ }
2790
+ if (key.return) {
2791
+ const option = options[index];
2792
+ if (option) onSelect(option.value);
2793
+ return;
2794
+ }
2795
+ if (key.downArrow || input === "j") {
2796
+ setIndex((i) => Math.min(i + 1, options.length - 1));
2797
+ } else if (key.upArrow || input === "k") {
2798
+ setIndex((i) => Math.max(i - 1, 0));
2799
+ }
2800
+ });
2801
+ const maxStart = Math.max(0, options.length - SELECT_PROMPT_MAX_VISIBLE);
2802
+ const start = Math.min(Math.max(0, index - Math.floor(SELECT_PROMPT_MAX_VISIBLE / 2)), maxStart);
2803
+ const visible = options.slice(start, start + SELECT_PROMPT_MAX_VISIBLE);
2804
+ const above = start;
2805
+ const below = options.length - start - visible.length;
2806
+ return /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
2807
+ /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: title }),
2808
+ above > 0 && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2809
+ "\u2026 ",
2810
+ above,
2811
+ " more above"
2812
+ ] }),
2813
+ visible.map((option, i) => {
2814
+ const active = start + i === index;
2815
+ return /* @__PURE__ */ jsxs7(Text7, { children: [
2816
+ /* @__PURE__ */ jsxs7(Text7, { bold: active, inverse: active, children: [
2817
+ active ? "\u276F " : " ",
2818
+ option.label
2819
+ ] }),
2820
+ option.hint && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2821
+ " (",
2822
+ option.hint,
2823
+ ")"
2824
+ ] }),
2825
+ option.description && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2826
+ " \u2014 ",
2827
+ option.description
2828
+ ] })
2829
+ ] }, option.value);
2830
+ }),
2831
+ below > 0 && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2832
+ "\u2026 ",
2833
+ below,
2834
+ " more below"
2835
+ ] }),
2836
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "[\u2191/\u2193 or j/k] move [Enter] select [Esc] cancel" })
2837
+ ] });
2838
+ }
2839
+
2512
2840
  // src/cli/components/StatusBar.tsx
2513
- import { Box as Box6, Text as Text7, useStdout } from "ink";
2841
+ import { Box as Box7, Text as Text8, useStdout } from "ink";
2514
2842
  import { memo as memo2 } from "react";
2515
- import { jsxs as jsxs7 } from "react/jsx-runtime";
2843
+ import { jsxs as jsxs8 } from "react/jsx-runtime";
2516
2844
  var BG_LABEL_MAX = 40;
2517
2845
  var WIDE_COLUMNS = 120;
2518
2846
  function cwdLabel(cwd, wide) {
@@ -2534,36 +2862,36 @@ var StatusBar = memo2(function StatusBar2({
2534
2862
  const wide = (stdout?.columns ?? 80) >= WIDE_COLUMNS;
2535
2863
  const joined = backgroundTasks.join(", ");
2536
2864
  const labels = joined.length > BG_LABEL_MAX ? `${joined.slice(0, BG_LABEL_MAX)}\u2026` : joined;
2537
- return /* @__PURE__ */ jsxs7(Box6, { justifyContent: "space-between", children: [
2538
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2865
+ return /* @__PURE__ */ jsxs8(Box7, { justifyContent: "space-between", children: [
2866
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2539
2867
  cwdLabel(cwd, wide),
2540
2868
  gitBranch ? ` [${gitBranch}]` : ""
2541
2869
  ] }),
2542
- backgroundTasks.length > 0 && /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
2870
+ backgroundTasks.length > 0 && /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2543
2871
  "bg: ",
2544
2872
  backgroundTasks.length,
2545
2873
  " (",
2546
2874
  labels,
2547
2875
  ")"
2548
2876
  ] }),
2549
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2877
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2550
2878
  "model: ",
2551
2879
  model
2552
2880
  ] }),
2553
- contextPercent != null && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2881
+ contextPercent != null && /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2554
2882
  "ctx: ",
2555
2883
  contextPercent,
2556
2884
  "%"
2557
2885
  ] }),
2558
- sessionCostUsd != null && sessionCostUsd > 0 && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2886
+ sessionCostUsd != null && sessionCostUsd > 0 && /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2559
2887
  "$",
2560
2888
  formatDollars(sessionCostUsd)
2561
2889
  ] }),
2562
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2890
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2563
2891
  tokens2,
2564
2892
  " tokens"
2565
2893
  ] }),
2566
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2894
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2567
2895
  "mode: ",
2568
2896
  permissionMode
2569
2897
  ] })
@@ -2571,22 +2899,22 @@ var StatusBar = memo2(function StatusBar2({
2571
2899
  });
2572
2900
 
2573
2901
  // src/cli/components/StreamingMessage.tsx
2574
- import { Box as Box7, Text as Text8 } from "ink";
2902
+ import { Box as Box8, Text as Text9 } from "ink";
2575
2903
  import { memo as memo3 } from "react";
2576
- import { jsx as jsx7, jsxs as jsxs8 } from "react/jsx-runtime";
2904
+ import { jsx as jsx8, jsxs as jsxs9 } from "react/jsx-runtime";
2577
2905
  var StreamingMessage = memo3(function StreamingMessage2({
2578
2906
  text,
2579
2907
  continuation
2580
2908
  }) {
2581
- return /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", marginBottom: 1, children: [
2582
- !continuation && /* @__PURE__ */ jsx7(Text8, { bold: true, color: "green", children: "star" }),
2583
- /* @__PURE__ */ jsx7(Text8, { color: "green", children: text })
2909
+ return /* @__PURE__ */ jsxs9(Box8, { flexDirection: "column", marginBottom: 1, children: [
2910
+ !continuation && /* @__PURE__ */ jsx8(Text9, { bold: true, color: "green", children: "star" }),
2911
+ /* @__PURE__ */ jsx8(Text9, { color: "green", children: text })
2584
2912
  ] });
2585
2913
  });
2586
2914
 
2587
2915
  // src/cli/components/ThinkingIndicator.tsx
2588
- import { Box as Box8, Text as Text9 } from "ink";
2589
- import { jsxs as jsxs9 } from "react/jsx-runtime";
2916
+ import { Box as Box9, Text as Text10 } from "ink";
2917
+ import { jsxs as jsxs10 } from "react/jsx-runtime";
2590
2918
  var SPINNER_FRAMES = ["-", "\\", "|", "/"];
2591
2919
  var REASONING_TAIL_LENGTH = 200;
2592
2920
  var THOUGHT_SUMMARY_LENGTH = 100;
@@ -2602,13 +2930,13 @@ function ThinkingIndicator({
2602
2930
  }) {
2603
2931
  const tail = reasoning ? truncateTail(reasoning, REASONING_TAIL_LENGTH) : "";
2604
2932
  const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
2605
- return /* @__PURE__ */ jsxs9(Box8, { flexDirection: "column", children: [
2606
- /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2933
+ return /* @__PURE__ */ jsxs10(Box9, { flexDirection: "column", children: [
2934
+ /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2607
2935
  spinner,
2608
2936
  " ",
2609
2937
  activity ?? "star is thinking\u2026"
2610
2938
  ] }),
2611
- tail !== "" && /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2939
+ tail !== "" && /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2612
2940
  " ",
2613
2941
  tail
2614
2942
  ] })
@@ -2630,6 +2958,51 @@ function isDoubleEscape(previous, now, windowMs = DOUBLE_ESC_WINDOW_MS) {
2630
2958
  return previous !== null && now - previous <= windowMs;
2631
2959
  }
2632
2960
 
2961
+ // src/cli/history.ts
2962
+ import fs10 from "fs";
2963
+ import path16 from "path";
2964
+ var DEFAULT_LIMIT = 500;
2965
+ var MAX_FILE_LINES = 1e3;
2966
+ var TRIM_TO_LINES = 500;
2967
+ function historyPath() {
2968
+ return path16.join(starHome(), "history");
2969
+ }
2970
+ function readLines(file) {
2971
+ const raw = fs10.readFileSync(file, "utf8");
2972
+ return raw.split("\n").map((line) => line.replace(/\r$/, "")).filter((line) => line.trim().length > 0);
2973
+ }
2974
+ function loadHistory(limit = DEFAULT_LIMIT) {
2975
+ try {
2976
+ return readLines(historyPath()).slice(-limit);
2977
+ } catch {
2978
+ return [];
2979
+ }
2980
+ }
2981
+ function appendHistory(entry) {
2982
+ const line = entry.replace(/[\r\n]+/g, " ").trim();
2983
+ if (line.length === 0) return;
2984
+ try {
2985
+ const file = historyPath();
2986
+ let existing = [];
2987
+ try {
2988
+ existing = readLines(file);
2989
+ } catch {
2990
+ }
2991
+ if (existing[existing.length - 1] === line) return;
2992
+ if (existing.length >= MAX_FILE_LINES) {
2993
+ const kept = [...existing.slice(-(TRIM_TO_LINES - 1)), line];
2994
+ fs10.mkdirSync(path16.dirname(file), { recursive: true });
2995
+ fs10.writeFileSync(file, `${kept.join("\n")}
2996
+ `);
2997
+ return;
2998
+ }
2999
+ fs10.mkdirSync(path16.dirname(file), { recursive: true });
3000
+ fs10.appendFileSync(file, `${line}
3001
+ `);
3002
+ } catch {
3003
+ }
3004
+ }
3005
+
2633
3006
  // src/cli/notify.ts
2634
3007
  var BELL = "\x07";
2635
3008
  function shouldNotifyBell(input, isTTY) {
@@ -2745,7 +3118,7 @@ async function checkForUpdate(currentVersion, fetcher = defaultFetcher2) {
2745
3118
  }
2746
3119
 
2747
3120
  // src/cli/repl.tsx
2748
- import { jsx as jsx8, jsxs as jsxs10 } from "react/jsx-runtime";
3121
+ import { jsx as jsx9, jsxs as jsxs11 } from "react/jsx-runtime";
2749
3122
  var CLEAR_TERMINAL = process.platform === "win32" ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H";
2750
3123
  function emptyUsage() {
2751
3124
  return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -2757,6 +3130,16 @@ var SESSION_MODE_CYCLE = ["ask", "auto", "readonly", "plan"];
2757
3130
  function formatUsage(usage) {
2758
3131
  return `API usage this session: ${usage.requests} requests, ${usage.promptTokens} prompt + ${usage.completionTokens} completion = ${usage.totalTokens} tokens`;
2759
3132
  }
3133
+ var SESSION_PICKER_CAP = 20;
3134
+ async function sessionPreview(id) {
3135
+ const store = await SessionStore.open(id);
3136
+ if (!store) return null;
3137
+ const first = (await store.messages()).find((m) => m.role === "user");
3138
+ if (!first) return null;
3139
+ const text = coreMessageText(first).replace(/\s+/g, " ").trim();
3140
+ if (!text) return null;
3141
+ return text.length > 60 ? `${text.slice(0, 60)}\u2026` : text;
3142
+ }
2760
3143
  function Repl({
2761
3144
  backend,
2762
3145
  model,
@@ -2768,23 +3151,24 @@ function Repl({
2768
3151
  initialUsage
2769
3152
  }) {
2770
3153
  const { exit } = useApp();
2771
- const [initialDisplay] = useState2(() => buildDisplayMessages(initialMessages ?? []));
2772
- const [messages, setMessages] = useState2(initialDisplay);
2773
- const [epoch, setEpoch] = useState2(0);
2774
- const [streamingText, setStreamingText] = useState2(null);
2775
- const [isStreaming, setIsStreaming] = useState2(false);
2776
- const [thinking, setThinking] = useState2(false);
2777
- const [thinkingText, setThinkingText] = useState2("");
2778
- const [thoughtSummary, setThoughtSummary] = useState2(null);
2779
- const [, setUsageVersion] = useState2(0);
2780
- const [modelName, setModelName] = useState2(model);
2781
- const [pending, setPending] = useState2(null);
2782
- const [spinnerTick, setSpinnerTick] = useState2(0);
2783
- const [activity, setActivity] = useState2(null);
2784
- const [bgLabels, setBgLabels] = useState2(() => runningTaskLabels());
2785
- const [permissionModeState, setPermissionModeState] = useState2(permissionMode);
2786
- const [planApproval, setPlanApprovalState] = useState2(false);
2787
- const [pendingRewind, setPendingRewindState] = useState2(null);
3154
+ const [initialDisplay] = useState3(() => buildDisplayMessages(initialMessages ?? []));
3155
+ const [messages, setMessages] = useState3(initialDisplay);
3156
+ const [epoch, setEpoch] = useState3(0);
3157
+ const [streamingText, setStreamingText] = useState3(null);
3158
+ const [isStreaming, setIsStreaming] = useState3(false);
3159
+ const [thinking, setThinking] = useState3(false);
3160
+ const [thinkingText, setThinkingText] = useState3("");
3161
+ const [thoughtSummary, setThoughtSummary] = useState3(null);
3162
+ const [, setUsageVersion] = useState3(0);
3163
+ const [modelName, setModelName] = useState3(model);
3164
+ const [pending, setPending] = useState3(null);
3165
+ const [spinnerTick, setSpinnerTick] = useState3(0);
3166
+ const [activity, setActivity] = useState3(null);
3167
+ const [bgLabels, setBgLabels] = useState3(() => runningTaskLabels());
3168
+ const [permissionModeState, setPermissionModeState] = useState3(permissionMode);
3169
+ const [planApproval, setPlanApprovalState] = useState3(false);
3170
+ const [pendingRewind, setPendingRewindState] = useState3(null);
3171
+ const [picker, setPickerState] = useState3(null);
2788
3172
  const backendRef = useRef2(backend);
2789
3173
  const nextIdRef = useRef2(initialDisplay.length);
2790
3174
  const abortRef = useRef2(null);
@@ -2799,21 +3183,23 @@ function Repl({
2799
3183
  const prevModeRef = useRef2(null);
2800
3184
  const planApprovalRef = useRef2(false);
2801
3185
  const pendingRewindRef = useRef2(null);
3186
+ const pickerRef = useRef2(null);
2802
3187
  const modelNameRef = useRef2(model);
2803
3188
  const sessionStoreRef = useRef2(sessionStore);
2804
3189
  const usageRef = useRef2(initialUsage ? { ...initialUsage } : emptyUsage());
2805
3190
  const turnChunksRef = useRef2(0);
2806
3191
  const messagesRef = useRef2(initialDisplay);
2807
- const [queueItems, setQueueItems] = useState2([]);
3192
+ const [queueItems, setQueueItems] = useState3([]);
2808
3193
  const queueRef = useRef2(new PromptQueue());
2809
- const [pendingImages, setPendingImages] = useState2([]);
3194
+ const [pendingImages, setPendingImages] = useState3([]);
2810
3195
  const pendingImagesRef = useRef2([]);
2811
3196
  const imageSeqRef = useRef2(1);
2812
- const [inputRefill, setInputRefill] = useState2();
3197
+ const [inputRefill, setInputRefill] = useState3();
2813
3198
  const refillSeqRef = useRef2(0);
2814
3199
  const lastEscRef = useRef2(null);
2815
- const [gitBranch, setGitBranch] = useState2(null);
2816
- const [contextPercent, setContextPercent] = useState2(null);
3200
+ const [gitBranch, setGitBranch] = useState3(null);
3201
+ const [contextPercent, setContextPercent] = useState3(null);
3202
+ const [initialHistory] = useState3(() => loadHistory());
2817
3203
  const budgetWarnedRef = useRef2(false);
2818
3204
  const budgetExceededRef = useRef2(false);
2819
3205
  const runStreamRef = useRef2(
@@ -2863,6 +3249,34 @@ function Repl({
2863
3249
  pendingRewindRef.current = p;
2864
3250
  setPendingRewindState(p);
2865
3251
  }, []);
3252
+ const setPicker = useCallback((p) => {
3253
+ pickerRef.current = p;
3254
+ setPickerState(p);
3255
+ }, []);
3256
+ const showPicker = useCallback(
3257
+ (title, options) => {
3258
+ if (pickerRef.current) return Promise.resolve(null);
3259
+ return new Promise((resolve) => {
3260
+ setPicker({ title, options, resolve });
3261
+ });
3262
+ },
3263
+ [setPicker]
3264
+ );
3265
+ const handlePickerSelect = useCallback(
3266
+ (value) => {
3267
+ const p = pickerRef.current;
3268
+ if (!p) return;
3269
+ setPicker(null);
3270
+ p.resolve(value);
3271
+ },
3272
+ [setPicker]
3273
+ );
3274
+ const handlePickerCancel = useCallback(() => {
3275
+ const p = pickerRef.current;
3276
+ if (!p) return;
3277
+ setPicker(null);
3278
+ p.resolve(null);
3279
+ }, [setPicker]);
2866
3280
  const stageClipboardImage = useCallback((img) => {
2867
3281
  pendingImagesRef.current = [
2868
3282
  ...pendingImagesRef.current,
@@ -3003,9 +3417,9 @@ function Repl({
3003
3417
  }
3004
3418
  exit();
3005
3419
  }, [exit, pushMessage]);
3006
- useInput5((_input, key) => {
3420
+ useInput6((_input, key) => {
3007
3421
  if (key.escape) {
3008
- const busy = abortRef.current !== null || pendingRef.current !== null || planApprovalRef.current || pendingRewindRef.current !== null;
3422
+ const busy = abortRef.current !== null || pendingRef.current !== null || planApprovalRef.current || pendingRewindRef.current !== null || pickerRef.current !== null;
3009
3423
  if (busy) {
3010
3424
  lastEscRef.current = null;
3011
3425
  interrupt();
@@ -3022,7 +3436,7 @@ function Repl({
3022
3436
  }
3023
3437
  lastEscRef.current = null;
3024
3438
  if (key.shift && key.tab) {
3025
- if (abortRef.current || pendingRef.current || planApprovalRef.current || pendingRewindRef.current)
3439
+ if (abortRef.current || pendingRef.current || planApprovalRef.current || pendingRewindRef.current || pickerRef.current)
3026
3440
  return;
3027
3441
  const current = config.permissionMode;
3028
3442
  const index = SESSION_MODE_CYCLE.indexOf(current);
@@ -3060,7 +3474,8 @@ function Repl({
3060
3474
  registry: createDefaultRegistry(),
3061
3475
  config,
3062
3476
  cwd,
3063
- sessionStore: sessionStoreRef.current
3477
+ sessionStore: sessionStoreRef.current,
3478
+ contextMaxTokens: contextWindowTokens(config, name)
3064
3479
  });
3065
3480
  const prev = backendRef.current;
3066
3481
  if (prev instanceof AgentLoop) {
@@ -3378,6 +3793,97 @@ ${lines.join("\n")}`;
3378
3793
  return formatSessionEntries(entries, { showCwd: all });
3379
3794
  },
3380
3795
  resumeSession: resume,
3796
+ pickModel: async () => {
3797
+ const models = listModels(config);
3798
+ if (models.length === 0) return "No models configured.";
3799
+ const options = models.map((m) => {
3800
+ const parts = [`${m.provider}/${m.model}`, `${contextWindowTokens(config, m.name)} ctx`];
3801
+ if (m.promptPrice !== void 0 && m.completionPrice !== void 0) {
3802
+ parts.push(`$${m.promptPrice}/$${m.completionPrice} per 1M tokens`);
3803
+ }
3804
+ return {
3805
+ value: m.name,
3806
+ label: m.name,
3807
+ description: parts.join(" \xB7 "),
3808
+ hint: m.name === modelNameRef.current ? "current" : void 0
3809
+ };
3810
+ });
3811
+ const picked = await showPicker("Select a model", options);
3812
+ if (!picked || picked === modelNameRef.current) {
3813
+ return `Model unchanged \u2014 still "${modelNameRef.current}".`;
3814
+ }
3815
+ return switchModel(picked);
3816
+ },
3817
+ pickPermissionMode: async () => {
3818
+ const current = config.permissionMode;
3819
+ const descriptions = {
3820
+ ask: "reads allowed; writes/exec ask for confirmation",
3821
+ auto: "everything allowed except hard-denied dangerous commands/paths",
3822
+ readonly: "read-only; all writes/exec denied",
3823
+ yolo: "allow everything, never ask (disables ALL safety checks)",
3824
+ plan: "read-only research, then approve a plan before executing (this session only)"
3825
+ };
3826
+ const options = ["ask", "auto", "readonly", "yolo", "plan"].map((m) => ({
3827
+ value: m,
3828
+ label: m,
3829
+ description: descriptions[m],
3830
+ hint: m === current ? "current" : void 0
3831
+ }));
3832
+ const picked = await showPicker("Select a permission mode", options);
3833
+ if (!picked || picked === current) {
3834
+ return `Permission mode unchanged \u2014 still ${current}.`;
3835
+ }
3836
+ if (picked === "plan") return ctx.planMode();
3837
+ return ctx.permissionMode(picked);
3838
+ },
3839
+ pickSession: async (all) => {
3840
+ const entries = (await listSessionEntries(all ? void 0 : cwd)).slice(
3841
+ 0,
3842
+ SESSION_PICKER_CAP
3843
+ );
3844
+ if (entries.length === 0) {
3845
+ return all ? "No sessions found." : "No sessions found for this directory.";
3846
+ }
3847
+ const options = await Promise.all(
3848
+ entries.map(async ({ meta, messageCount }) => {
3849
+ const parts = [`${messageCount} messages \xB7 ${relativeTime(meta.updatedAt)}`];
3850
+ const preview = await sessionPreview(meta.id);
3851
+ if (preview) parts.push(preview);
3852
+ if (all) parts.push(`[${meta.cwd}]`);
3853
+ return {
3854
+ value: meta.id,
3855
+ label: meta.title || shortSessionId(meta.id),
3856
+ description: parts.join(" \xB7 "),
3857
+ hint: meta.id === sessionStoreRef.current?.id ? "current" : void 0
3858
+ };
3859
+ })
3860
+ );
3861
+ const picked = await showPicker(
3862
+ all ? "Resume a session (all directories)" : "Resume a session",
3863
+ options
3864
+ );
3865
+ if (!picked) return "Session unchanged \u2014 picker cancelled.";
3866
+ return resume(picked);
3867
+ },
3868
+ forkSession: async () => {
3869
+ const current = backendRef.current;
3870
+ const store = sessionStoreRef.current;
3871
+ if (!(current instanceof AgentLoop) || !store) {
3872
+ return "Nothing to fork \u2014 no active session.";
3873
+ }
3874
+ const messages2 = await store.messages();
3875
+ if (messages2.length === 0) {
3876
+ return "Nothing to fork \u2014 the current session has no messages yet.";
3877
+ }
3878
+ const fork = await SessionStore.create(cwd, modelNameRef.current);
3879
+ await fork.replaceMessages(messages2);
3880
+ const oldMeta = await store.meta();
3881
+ const title = `Fork of ${oldMeta.title || shortSessionId(oldMeta.id)}`;
3882
+ await fork.setTitle(title);
3883
+ current.setSessionStore(fork);
3884
+ sessionStoreRef.current = fork;
3885
+ return `Forked into new session ${fork.id} ("${title}") and switched to it. Checkpoints and rewind history do not carry over.`;
3886
+ },
3381
3887
  newSession: async () => {
3382
3888
  const current = backendRef.current;
3383
3889
  if (!(current instanceof AgentLoop)) {
@@ -3581,7 +4087,8 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
3581
4087
  applyMessages,
3582
4088
  redrawMessages,
3583
4089
  applySessionMode,
3584
- setPendingRewind
4090
+ setPendingRewind,
4091
+ showPicker
3585
4092
  ]);
3586
4093
  const runShellBang = useCallback(
3587
4094
  async (raw) => {
@@ -3621,6 +4128,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
3621
4128
  );
3622
4129
  const handleSubmit = useCallback(
3623
4130
  (text) => {
4131
+ appendHistory(text);
3624
4132
  if (text.startsWith("!")) {
3625
4133
  if (abortRef.current) {
3626
4134
  queueRef.current.enqueue({ text, images: [] });
@@ -3635,7 +4143,11 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
3635
4143
  if (!parsed) return;
3636
4144
  const command = registry.get(parsed.name);
3637
4145
  if (!command) {
3638
- pushMessage("system", `Unknown command: /${parsed.name} (try /help)`);
4146
+ const suggestions = registry.complete(parsed.name).map((cmd) => cmd.name);
4147
+ pushMessage(
4148
+ "system",
4149
+ `Unknown command: /${parsed.name} (try /help)${didYouMeanSuffix(suggestions)}`
4150
+ );
3639
4151
  return;
3640
4152
  }
3641
4153
  void command.run(parsed.args, registry.ctx);
@@ -3661,24 +4173,28 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
3661
4173
  [registry, pushMessage, runStream, runShellBang, config, clearPendingImages, sessionCostUsd]
3662
4174
  );
3663
4175
  const commandHints = useMemo(
3664
- () => registry.list().map((cmd) => ({ name: cmd.name, description: cmd.description })),
4176
+ () => registry.list().map((cmd) => ({
4177
+ name: cmd.name,
4178
+ description: cmd.description,
4179
+ usage: cmd.usage ?? `/${cmd.name}`
4180
+ })),
3665
4181
  [registry]
3666
4182
  );
3667
4183
  useEffect2(() => {
3668
4184
  setGitBranch(getGitSummary(cwd)?.branch ?? null);
3669
4185
  const current = backendRef.current;
3670
4186
  if (current instanceof AgentLoop) {
3671
- setContextPercent(
3672
- Math.round(estimateTokens([...current.getMessages()]) / config.contextMaxTokens * 100)
3673
- );
4187
+ const window_ = contextWindowTokens(config, modelNameRef.current);
4188
+ const pct = estimateTokens([...current.getMessages()]) / window_ * 100;
4189
+ setContextPercent(pct < 10 ? Math.round(pct * 10) / 10 : Math.round(pct));
3674
4190
  } else {
3675
4191
  setContextPercent(null);
3676
4192
  }
3677
4193
  }, [cwd, config, isStreaming, epoch]);
3678
4194
  const sessionCost = sessionCostUsd();
3679
- return /* @__PURE__ */ jsxs10(Box9, { flexDirection: "column", children: [
3680
- /* @__PURE__ */ jsx8(MessageList, { messages }, `messages-${epoch}`),
3681
- (thinking || activity !== null) && /* @__PURE__ */ jsx8(
4195
+ return /* @__PURE__ */ jsxs11(Box10, { flexDirection: "column", children: [
4196
+ /* @__PURE__ */ jsx9(MessageList, { messages }, `messages-${epoch}`),
4197
+ (thinking || activity !== null) && /* @__PURE__ */ jsx9(
3682
4198
  ThinkingIndicator,
3683
4199
  {
3684
4200
  reasoning: thinkingText,
@@ -3686,9 +4202,9 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
3686
4202
  activity: activity ?? void 0
3687
4203
  }
3688
4204
  ),
3689
- !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx8(Text10, { dimColor: true, children: thoughtSummary }),
3690
- streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx8(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
3691
- pending && /* @__PURE__ */ jsx8(
4205
+ !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx9(Text11, { dimColor: true, children: thoughtSummary }),
4206
+ streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx9(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
4207
+ pending && /* @__PURE__ */ jsx9(
3692
4208
  PermissionPrompt,
3693
4209
  {
3694
4210
  request: pending.request,
@@ -3696,32 +4212,42 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
3696
4212
  onDecision: handleDecision
3697
4213
  }
3698
4214
  ),
3699
- planApproval && /* @__PURE__ */ jsx8(ExitPlanPrompt, { onDecision: handlePlanDecision }),
3700
- pendingRewind && /* @__PURE__ */ jsx8(RewindConfirmPrompt, { summary: pendingRewind.summary, onDecision: handleRewindDecision }),
3701
- queueItems.map((item) => /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
4215
+ planApproval && /* @__PURE__ */ jsx9(ExitPlanPrompt, { onDecision: handlePlanDecision }),
4216
+ pendingRewind && /* @__PURE__ */ jsx9(RewindConfirmPrompt, { summary: pendingRewind.summary, onDecision: handleRewindDecision }),
4217
+ picker && /* @__PURE__ */ jsx9(
4218
+ SelectPrompt,
4219
+ {
4220
+ title: picker.title,
4221
+ options: picker.options,
4222
+ onSelect: handlePickerSelect,
4223
+ onCancel: handlePickerCancel
4224
+ }
4225
+ ),
4226
+ queueItems.map((item) => /* @__PURE__ */ jsxs11(Text11, { dimColor: true, children: [
3702
4227
  "queued: ",
3703
4228
  item.text.length > 80 ? `${item.text.slice(0, 80)}\u2026` : item.text
3704
4229
  ] }, item.id)),
3705
- pendingImages.map((staged) => /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
4230
+ pendingImages.map((staged) => /* @__PURE__ */ jsxs11(Text11, { dimColor: true, children: [
3706
4231
  "[image attached: ",
3707
4232
  staged.image.path,
3708
4233
  "]"
3709
4234
  ] }, staged.id)),
3710
- /* @__PURE__ */ jsx8(
4235
+ /* @__PURE__ */ jsx9(
3711
4236
  InputBox,
3712
4237
  {
3713
4238
  isStreaming,
3714
- disabled: pending !== null || planApproval || pendingRewind !== null,
4239
+ disabled: pending !== null || planApproval || pendingRewind !== null || picker !== null,
3715
4240
  commands: commandHints,
3716
4241
  cwd,
3717
4242
  refill: inputRefill,
4243
+ initialHistory,
3718
4244
  onSubmit: handleSubmit,
3719
4245
  onInterrupt: interrupt,
3720
4246
  onExit: handleExit,
3721
4247
  onPasteImage: handlePasteImage
3722
4248
  }
3723
4249
  ),
3724
- /* @__PURE__ */ jsx8(
4250
+ /* @__PURE__ */ jsx9(
3725
4251
  StatusBar,
3726
4252
  {
3727
4253
  cwd,
@@ -3741,7 +4267,7 @@ function renderRepl(backend, opts) {
3741
4267
  defaultTaskManager.cleanup();
3742
4268
  });
3743
4269
  return render(
3744
- /* @__PURE__ */ jsx8(
4270
+ /* @__PURE__ */ jsx9(
3745
4271
  Repl,
3746
4272
  {
3747
4273
  backend,
@@ -3758,71 +4284,8 @@ function renderRepl(backend, opts) {
3758
4284
  }
3759
4285
 
3760
4286
  // src/config/loader.ts
3761
- import fs10 from "fs";
4287
+ import fs11 from "fs";
3762
4288
  import { parse as parse2 } from "smol-toml";
3763
-
3764
- // src/config/schema.ts
3765
- import { z } from "zod";
3766
- var ProviderConfigSchema = z.object({
3767
- name: z.string(),
3768
- protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
3769
- baseURL: z.string(),
3770
- apiKeyEnv: z.string().optional(),
3771
- apiKey: z.string().optional(),
3772
- headers: z.record(z.string()).optional()
3773
- });
3774
- var ModelConfigSchema = z.object({
3775
- name: z.string(),
3776
- provider: z.string(),
3777
- model: z.string(),
3778
- maxTokens: z.number().int().positive().optional(),
3779
- promptPrice: z.number().nonnegative().optional(),
3780
- completionPrice: z.number().nonnegative().optional()
3781
- });
3782
- var PermissionsConfigSchema = z.object({
3783
- allow: z.array(z.string()).default([])
3784
- });
3785
- var HookConfigSchema = z.object({
3786
- event: z.enum(["PreToolUse", "PostToolUse", "Stop"]),
3787
- matcher: z.string().refine(
3788
- (pattern) => {
3789
- try {
3790
- new RegExp(pattern);
3791
- return true;
3792
- } catch {
3793
- return false;
3794
- }
3795
- },
3796
- { message: "invalid regular expression" }
3797
- ).optional(),
3798
- command: z.string().min(1),
3799
- timeoutSec: z.number().positive().default(30)
3800
- });
3801
- var ConfigSchema = z.object({
3802
- defaultModel: z.string().default(""),
3803
- permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
3804
- providers: z.array(ProviderConfigSchema).default([]),
3805
- models: z.array(ModelConfigSchema).default([]),
3806
- maxSteps: z.number().int().positive().default(50),
3807
- contextMaxTokens: z.number().int().positive().default(1e5),
3808
- // Seconds without any stream part before a stalled response is ended
3809
- // gracefully (some relays never send the terminal chunks). Applies once
3810
- // streaming has started; the first part gets a longer, fixed allowance.
3811
- streamIdleTimeoutSec: z.number().positive().default(20),
3812
- contextCompaction: z.enum(["summary", "truncate"]).default("summary"),
3813
- // Optional per-session dollar budget; when set, the REPL stops the session
3814
- // once the estimated cost for the session reaches this amount.
3815
- sessionBudgetUsd: z.number().positive().optional(),
3816
- // Terminal bell (REPL only): ring when a turn takes longer than
3817
- // notifyBellThresholdSec, and when a background task finishes.
3818
- // STAR_NO_NOTIFY=1 disables without touching the config.
3819
- notifyBell: z.boolean().default(true),
3820
- notifyBellThresholdSec: z.number().positive().default(10),
3821
- permissions: PermissionsConfigSchema.default({ allow: [] }),
3822
- hooks: z.array(HookConfigSchema).default([])
3823
- });
3824
-
3825
- // src/config/loader.ts
3826
4289
  var PartialConfigSchema = ConfigSchema.partial();
3827
4290
  function parseTomlFile(content, filePath) {
3828
4291
  let raw;
@@ -3849,8 +4312,8 @@ function applyOverrides(config, overrides) {
3849
4312
  return merged;
3850
4313
  }
3851
4314
  function readTomlFileSync(filePath) {
3852
- if (!fs10.existsSync(filePath)) return {};
3853
- const content = fs10.readFileSync(filePath, "utf8");
4315
+ if (!fs11.existsSync(filePath)) return {};
4316
+ const content = fs11.readFileSync(filePath, "utf8");
3854
4317
  return parseTomlFile(content, filePath);
3855
4318
  }
3856
4319
  function loadConfigSync(cwd, overrides) {
@@ -3874,7 +4337,8 @@ async function createLoop(config, modelName, cwd, sessionStore) {
3874
4337
  config,
3875
4338
  cwd,
3876
4339
  system: SYSTEM_PROMPT,
3877
- sessionStore
4340
+ sessionStore,
4341
+ contextMaxTokens: contextWindowTokens(config, modelName)
3878
4342
  });
3879
4343
  }
3880
4344
  async function printMode(loop, prompt, cwd, json, imagePaths) {