@este.systems/dsc 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +111 -31
  2. package/bin/dsc.mjs +13 -3
  3. package/dist/agent.js +76 -14
  4. package/dist/agent.js.map +1 -1
  5. package/dist/editor.js +37 -0
  6. package/dist/editor.js.map +1 -0
  7. package/dist/index.js +18 -59
  8. package/dist/index.js.map +1 -1
  9. package/dist/prompt.js +11 -5
  10. package/dist/prompt.js.map +1 -1
  11. package/dist/slash.js +59 -0
  12. package/dist/slash.js.map +1 -0
  13. package/dist/store.js +44 -0
  14. package/dist/store.js.map +1 -0
  15. package/dist/tools.js +145 -1
  16. package/dist/tools.js.map +1 -1
  17. package/dist/tui/AgentTaskList.js +31 -0
  18. package/dist/tui/AgentTaskList.js.map +1 -0
  19. package/dist/tui/App.js +53 -0
  20. package/dist/tui/App.js.map +1 -0
  21. package/dist/tui/ApprovalDialog.js +24 -0
  22. package/dist/tui/ApprovalDialog.js.map +1 -0
  23. package/dist/tui/CurrentTurn.js +13 -0
  24. package/dist/tui/CurrentTurn.js.map +1 -0
  25. package/dist/tui/History.js +43 -0
  26. package/dist/tui/History.js.map +1 -0
  27. package/dist/tui/Input.js +162 -0
  28. package/dist/tui/Input.js.map +1 -0
  29. package/dist/tui/Markdown.js +242 -0
  30. package/dist/tui/Markdown.js.map +1 -0
  31. package/dist/tui/PromptInput.js +55 -0
  32. package/dist/tui/PromptInput.js.map +1 -0
  33. package/dist/tui/QueuedPrompts.js +13 -0
  34. package/dist/tui/QueuedPrompts.js.map +1 -0
  35. package/dist/tui/StatusBar.js +50 -0
  36. package/dist/tui/StatusBar.js.map +1 -0
  37. package/dist/tui/TaskLine.js +12 -0
  38. package/dist/tui/TaskLine.js.map +1 -0
  39. package/dist/tui/useStore.js +17 -0
  40. package/dist/tui/useStore.js.map +1 -0
  41. package/dist/tui.js +786 -0
  42. package/dist/tui.js.map +1 -0
  43. package/dist/version.js +25 -0
  44. package/dist/version.js.map +1 -0
  45. package/package.json +7 -2
package/dist/tui.js ADDED
@@ -0,0 +1,786 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { App } from "./tui/App.js";
4
+ import { getState, setState } from "./store.js";
5
+ import { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, computeCostUsd, configPath, hasApiKey, recordUsage, } from "./api.js";
6
+ import { runAgent, formatCost, estimateContextTokens, } from "./agent.js";
7
+ import * as history from "./history.js";
8
+ import * as approval from "./approval.js";
9
+ import * as audit from "./audit.js";
10
+ import * as replHistory from "./repl_history.js";
11
+ import { promises as fsp } from "node:fs";
12
+ import { compactSession } from "./compact.js";
13
+ import { formatVersionInfo } from "./version.js";
14
+ import { openEditor } from "./editor.js";
15
+ const AUTO_COMPACT_AT_TOKENS = Number(process.env.DSC_AUTO_COMPACT ?? "0") || 0;
16
+ const AUTO_COMPACT_KEEP = Number(process.env.DSC_AUTO_COMPACT_KEEP ?? "4") || 4;
17
+ // Minimal arg parsing for the TUI entry. Mirrors the subset of flags the
18
+ // REPL accepts that meaningfully affect a one-shot turn. Anything fancier
19
+ // (e.g. --model, --resume <id>) still has to go through --repl for now.
20
+ function parseArgs(argv) {
21
+ const out = {};
22
+ const positional = [];
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (a === "--version" || a === "-v")
26
+ out.version = true;
27
+ else if (a === "--help" || a === "-h")
28
+ out.help = true;
29
+ else if (a === "--yolo" || a === "-y")
30
+ out.yolo = true;
31
+ else if (a === "--no-resume")
32
+ out.noResume = true;
33
+ else if (a.startsWith("-")) {
34
+ // Unknown flags fall through silently — bin routes --repl-only flags
35
+ // (--model, --resume <id>) to index.ts instead.
36
+ }
37
+ else
38
+ positional.push(a);
39
+ }
40
+ if (positional.length)
41
+ out.prompt = positional.join(" ");
42
+ return out;
43
+ }
44
+ async function main() {
45
+ const cli = parseArgs(process.argv.slice(2));
46
+ if (cli.version) {
47
+ process.stdout.write(formatVersionInfo() + "\n");
48
+ process.exit(0);
49
+ }
50
+ if (cli.help) {
51
+ process.stdout.write([
52
+ "dsc — CLI coding agent for DeepSeek (TUI)",
53
+ "",
54
+ "Usage:",
55
+ " dsc Start the TUI",
56
+ " dsc \"your prompt here\" One-shot: run and exit",
57
+ " dsc --repl Use the readline REPL instead",
58
+ "",
59
+ "Flags handled here:",
60
+ " -y, --yolo Skip approval prompts",
61
+ " --no-resume Don't auto-resume the latest session",
62
+ " -v, --version Print version and exit",
63
+ " -h, --help Show this help",
64
+ "",
65
+ "All other flags (--model, --resume <id>) go through --repl.",
66
+ ].join("\n") + "\n");
67
+ process.exit(0);
68
+ }
69
+ if (!hasApiKey()) {
70
+ process.stderr.write(`No DeepSeek API key found.\n` +
71
+ `Either export DEEPSEEK_API_KEY, or create ${configPath()} containing:\n` +
72
+ ` {"api_key": "sk-..."}\n`);
73
+ process.exit(1);
74
+ }
75
+ const cwd = process.cwd();
76
+ await history.migrateLegacyIfPresent(cwd, DEFAULT_MODEL);
77
+ // Auto-resume most recent for cwd unless --no-resume; otherwise fresh.
78
+ let session = history.newSession(cwd, DEFAULT_MODEL);
79
+ if (!cli.noResume) {
80
+ const target = await history.mostRecentForCwd(cwd);
81
+ if (target) {
82
+ const loaded = await history.loadSession(target.id);
83
+ if (loaded)
84
+ session = loaded;
85
+ }
86
+ }
87
+ // Reassigned by /clear and /resume — keep `let` so handlers can swap them
88
+ // out for the new session's arrays without restarting the process.
89
+ let messages = session.messages;
90
+ let stats = session.stats;
91
+ let model = session.model;
92
+ const initialAutoContinue = Number(process.env.DSC_AUTO_CONTINUE ?? "0") || 0;
93
+ const toolCtx = {
94
+ cwd,
95
+ yolo: !!cli.yolo,
96
+ filesTouched: stats.files_touched,
97
+ sessionId: session.id,
98
+ };
99
+ // Coalescing save (same shape as REPL).
100
+ let savePromise = null;
101
+ let savePending = false;
102
+ const persist = () => {
103
+ savePending = true;
104
+ if (savePromise)
105
+ return savePromise;
106
+ savePromise = (async () => {
107
+ while (savePending) {
108
+ savePending = false;
109
+ try {
110
+ session.model = model;
111
+ session.messages = messages;
112
+ session.stats = stats;
113
+ await history.saveSession(session);
114
+ }
115
+ catch {
116
+ // Swallow — the next turn will retry; no stderr in TUI mode.
117
+ }
118
+ }
119
+ savePromise = null;
120
+ })();
121
+ return savePromise;
122
+ };
123
+ const sessionStart = Date.now();
124
+ const promptQueue = [];
125
+ // Push the freshly-computed numbers into the store; StatusBar reads from
126
+ // there. Called after every onTurn and once per second for the timer.
127
+ const syncStatus = () => {
128
+ setState({
129
+ model,
130
+ contextTokens: estimateContextTokens(messages),
131
+ sessionSeconds: Math.floor((Date.now() - sessionStart) / 1000),
132
+ inTokens: stats.prompt_tokens,
133
+ outTokens: stats.completion_tokens,
134
+ cacheHitTokens: stats.cache_hit_tokens,
135
+ cacheMissTokens: stats.cache_miss_tokens,
136
+ toolCalls: stats.tool_calls_total,
137
+ queue: promptQueue.slice(),
138
+ queueDepth: promptQueue.length,
139
+ compacted: !!session.compaction,
140
+ cost: computeCostUsd(stats, model),
141
+ });
142
+ };
143
+ setState({
144
+ model,
145
+ yolo: toolCtx.yolo,
146
+ assistantLabel: session.assistantLabel ?? "assistant:",
147
+ language: session.language,
148
+ autoContinue: initialAutoContinue,
149
+ });
150
+ // Seed history from the restored session so prior turns are visible.
151
+ if (messages.length) {
152
+ const restored = [];
153
+ for (const m of messages) {
154
+ if (m.role === "system")
155
+ continue;
156
+ restored.push({
157
+ id: `r-${restored.length}`,
158
+ role: m.role,
159
+ content: typeof m.content === "string" ? m.content : "",
160
+ tool_call_id: m.tool_call_id,
161
+ });
162
+ }
163
+ setState({ history: restored });
164
+ }
165
+ syncStatus();
166
+ // 1-second tick so the session timer in StatusBar advances even when idle.
167
+ const timerId = setInterval(syncStatus, 1000);
168
+ // Install the approval asker — routes confirm* calls through the
169
+ // ApprovalDialog component. The diff/preview body printed by confirm*
170
+ // still goes to stdout and will appear above ink's dynamic frame; that's
171
+ // acceptable for a first cut and gets cleaned up in a later commit.
172
+ approval.setAsker((q) => new Promise((resolve) => {
173
+ setState({
174
+ approval: {
175
+ title: "Confirm",
176
+ body: "",
177
+ question: q,
178
+ resolve,
179
+ },
180
+ });
181
+ }));
182
+ // Wire events: agent → store.
183
+ const events = {
184
+ onAssistantStart: (turnId) => {
185
+ setState({
186
+ current: { id: turnId, role: "assistant", content: "", reasoning: "" },
187
+ });
188
+ },
189
+ onAssistantContent: (turnId, chunk) => {
190
+ setState((s) => {
191
+ const cur = s.current && s.current.id === turnId
192
+ ? s.current
193
+ : { id: turnId, role: "assistant", content: "", reasoning: "" };
194
+ return { current: { ...cur, content: (cur.content ?? "") + chunk } };
195
+ });
196
+ },
197
+ onAssistantReasoning: (turnId, chunk) => {
198
+ setState((s) => {
199
+ const cur = s.current && s.current.id === turnId
200
+ ? s.current
201
+ : { id: turnId, role: "assistant", content: "", reasoning: "" };
202
+ return { current: { ...cur, reasoning: (cur.reasoning ?? "") + chunk } };
203
+ });
204
+ },
205
+ onAssistantFinal: (turnId, msg) => {
206
+ // Move from `current` into the static history (so it's selectable).
207
+ // Drop assistant turns that produced neither content nor reasoning
208
+ // (tool-only turns) — the tool messages themselves will appear.
209
+ const has = (msg.content && msg.content.length) || (msg.reasoning && msg.reasoning.length);
210
+ setState((s) => {
211
+ const next = { current: null };
212
+ if (has) {
213
+ const final = {
214
+ id: turnId,
215
+ role: "assistant",
216
+ content: msg.content,
217
+ reasoning: msg.reasoning,
218
+ tool_calls: msg.tool_calls,
219
+ };
220
+ next.history = [...s.history, final];
221
+ }
222
+ return next;
223
+ });
224
+ },
225
+ onToolStart: (_callId, name, args) => {
226
+ setState({ task: `${name}(${truncate(args, 80)})` });
227
+ },
228
+ onToolEnd: (callId, name, content, rejected) => {
229
+ setState((s) => ({
230
+ task: null,
231
+ history: [
232
+ ...s.history,
233
+ {
234
+ id: callId,
235
+ role: "tool",
236
+ content,
237
+ tool_call_id: callId,
238
+ tool_name: rejected ? `${name} (rejected)` : name,
239
+ },
240
+ ],
241
+ }));
242
+ },
243
+ onNotice: (text) => {
244
+ setState((s) => ({
245
+ history: [
246
+ ...s.history,
247
+ { id: `n-${s.history.length}`, role: "system", content: text },
248
+ ],
249
+ }));
250
+ },
251
+ };
252
+ let pendingAbort = null;
253
+ const runTurn = async (userText) => {
254
+ // Push the user message into history immediately so it's visible before
255
+ // the model responds.
256
+ setState((s) => ({
257
+ history: [
258
+ ...s.history,
259
+ { id: `u-${s.history.length}`, role: "user", content: userText },
260
+ ],
261
+ busy: true,
262
+ }));
263
+ messages.push({ role: "user", content: userText });
264
+ pendingAbort = new AbortController();
265
+ try {
266
+ await runAgent({
267
+ model,
268
+ stats,
269
+ toolCtx,
270
+ messages,
271
+ signal: pendingAbort.signal,
272
+ onTurn: () => {
273
+ syncStatus();
274
+ void persist();
275
+ },
276
+ showReasoning: getState().reasoning,
277
+ getSummary: () => session.compaction?.summary,
278
+ assistantLabel: session.assistantLabel,
279
+ maxAutoContinue: getState().autoContinue,
280
+ language: session.language,
281
+ events,
282
+ });
283
+ }
284
+ catch (e) {
285
+ const text = e.name === "AbortError" || pendingAbort?.signal.aborted
286
+ ? "(interrupted)"
287
+ : e instanceof DeepSeekError
288
+ ? `API error ${e.status ?? ""}: ${e.message}`
289
+ : `error: ${e.message ?? e}`;
290
+ setState((s) => ({
291
+ history: [
292
+ ...s.history,
293
+ { id: `e-${s.history.length}`, role: "system", content: text },
294
+ ],
295
+ }));
296
+ }
297
+ finally {
298
+ pendingAbort = null;
299
+ setState({ busy: false, task: null });
300
+ }
301
+ await persist();
302
+ };
303
+ // Drain queued prompts after each turn so back-to-back submissions run in
304
+ // order without overlapping turns.
305
+ let draining = false;
306
+ const drain = async () => {
307
+ if (draining)
308
+ return;
309
+ draining = true;
310
+ try {
311
+ while (promptQueue.length) {
312
+ const next = promptQueue.shift();
313
+ syncStatus();
314
+ await runTurn(next);
315
+ if (AUTO_COMPACT_AT_TOKENS > 0) {
316
+ const ctx = estimateContextTokens(messages);
317
+ if (ctx > AUTO_COMPACT_AT_TOKENS) {
318
+ await runCompaction(AUTO_COMPACT_KEEP, true);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ finally {
324
+ draining = false;
325
+ }
326
+ };
327
+ // Persisted REPL history — both REPL and TUI share this on-disk file so
328
+ // arrow-up recall works across sessions and across the two front-ends.
329
+ const promptHistory = await replHistory.load();
330
+ // Forward declarations so handleSlash can reference the ink instance and
331
+ // remount helper for /edit (which unmounts ink, runs $EDITOR, remounts).
332
+ // These get assigned just before the first render at the bottom of main().
333
+ let inkInstance = null;
334
+ const mountApp = () => {
335
+ inkInstance = render(_jsx(App, { onSubmit: handleSubmit, onAbort: handleAbort, history: promptHistory }));
336
+ };
337
+ // Push a one-line dim system notice into history (used for slash-command
338
+ // output — the dim styling comes from History's role==="system" treatment).
339
+ const info = (text) => {
340
+ setState((s) => ({
341
+ history: [
342
+ ...s.history,
343
+ { id: `i-${s.history.length}`, role: "system", content: text },
344
+ ],
345
+ }));
346
+ };
347
+ const runCompaction = async (keep, auto) => {
348
+ const beforeMessages = messages.length;
349
+ const beforeChars = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
350
+ if (auto)
351
+ info(`── auto-compact (ctx > ${AUTO_COMPACT_AT_TOKENS} tokens)`);
352
+ try {
353
+ const result = await compactSession(session, keep, model);
354
+ if (!result) {
355
+ if (!auto)
356
+ info(`nothing to compact (need more than ${keep} user turns)`);
357
+ return;
358
+ }
359
+ session.archivedMessages = [
360
+ ...(session.archivedMessages ?? []),
361
+ ...result.droppedMessages,
362
+ ];
363
+ session.messages = result.remainingMessages;
364
+ messages = session.messages;
365
+ session.compaction = {
366
+ summary: result.summary,
367
+ compacted_at: Date.now(),
368
+ turns_removed: (session.compaction?.turns_removed ?? 0) + result.turnsRemoved,
369
+ };
370
+ stats.prompts += 1;
371
+ recordUsage(stats, result.usage);
372
+ const afterChars = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
373
+ info(`compacted ${result.turnsRemoved} user turn(s); messages ${beforeMessages} → ${messages.length}; chars ${beforeChars} → ${afterChars} + ${result.summary.length} summary`);
374
+ syncStatus();
375
+ await persist();
376
+ }
377
+ catch (e) {
378
+ info(e instanceof DeepSeekError
379
+ ? `error: compaction failed: ${e.message}`
380
+ : `error: compaction failed: ${e.message}`);
381
+ }
382
+ };
383
+ // Returns true if the input was a recognized slash command and got handled
384
+ // (or rejected) — caller should not forward it to the agent. Returns false
385
+ // for non-slash input and unknown commands (caller decides what to do).
386
+ const handleSlash = async (line) => {
387
+ if (!line.startsWith("/"))
388
+ return false;
389
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
390
+ const arg = rest.join(" ");
391
+ switch (cmd) {
392
+ case "exit":
393
+ case "quit":
394
+ clearInterval(timerId);
395
+ process.exit(0);
396
+ return true;
397
+ case "help":
398
+ info([
399
+ "/help show this help",
400
+ "/clear start a new session",
401
+ "/list list sessions for this cwd",
402
+ "/resume [n|name|id] resume a session",
403
+ "/save <name> name the current session",
404
+ "/rename <text> set assistant label for this session",
405
+ "/model [name] show or switch model",
406
+ "/yolo toggle approval mode",
407
+ "/reasoning [on|off] toggle reasoning display",
408
+ "/lang [name|off] force the model to reply in a language",
409
+ "/auto-continue [N|off] auto-grant N extra MAX_TOOL_DEPTH budgets",
410
+ "/cost show token usage and cost",
411
+ "/version show version info",
412
+ "/compact [keep] summarize old turns (default keep=4)",
413
+ "/transcript dump full message log",
414
+ "/audit [path|show N] audit log info",
415
+ "/queue [clear] list or clear queued prompts",
416
+ "/exit exit",
417
+ ].join("\n"));
418
+ return true;
419
+ case "clear":
420
+ session = history.newSession(cwd, model);
421
+ messages = session.messages;
422
+ stats = session.stats;
423
+ toolCtx.filesTouched = stats.files_touched;
424
+ toolCtx.sessionId = session.id;
425
+ setState({ history: [], current: null });
426
+ info(`new session started (${session.id})`);
427
+ syncStatus();
428
+ return true;
429
+ case "list": {
430
+ const all = await history.listSessions(cwd);
431
+ if (!all.length) {
432
+ info(`no sessions for ${cwd}`);
433
+ }
434
+ else {
435
+ info(all
436
+ .map((s, i) => {
437
+ const here = s.id === session.id ? "* " : " ";
438
+ const label = s.name ? `${s.name} (${s.model})` : s.model;
439
+ return `${here}${String(i + 1).padStart(2, " ")}. ${label} ${formatRelative(s.updated_at)} (${s.message_count} msgs) ${s.first_user_message || "—"}`;
440
+ })
441
+ .join("\n"));
442
+ }
443
+ return true;
444
+ }
445
+ case "save":
446
+ if (!arg.trim()) {
447
+ info("error: usage: /save <name>");
448
+ }
449
+ else {
450
+ session.name = arg.trim();
451
+ await persist();
452
+ info(`session saved as "${session.name}" (id ${session.id})`);
453
+ }
454
+ return true;
455
+ case "rename": {
456
+ const text = arg.trim();
457
+ if (!text) {
458
+ info(`assistant label: "${session.assistantLabel ?? "assistant:"}"`);
459
+ }
460
+ else if (text === "--reset" || text === "default") {
461
+ delete session.assistantLabel;
462
+ setState({ assistantLabel: "assistant:" });
463
+ await persist();
464
+ info("assistant label reset to default");
465
+ }
466
+ else {
467
+ session.assistantLabel = text;
468
+ setState({ assistantLabel: text });
469
+ await persist();
470
+ info(`assistant label → "${text}"`);
471
+ }
472
+ return true;
473
+ }
474
+ case "resume": {
475
+ const all = await history.listSessions(cwd);
476
+ if (!all.length) {
477
+ info("no sessions to resume");
478
+ return true;
479
+ }
480
+ let target = null;
481
+ if (!arg || arg === "last") {
482
+ target = all[0];
483
+ }
484
+ else if (/^\d+$/.test(arg)) {
485
+ target = all[parseInt(arg, 10) - 1] ?? null;
486
+ if (!target)
487
+ info(`error: no session at index ${arg} (have ${all.length})`);
488
+ }
489
+ else {
490
+ target =
491
+ all.find((s) => s.name === arg) ??
492
+ all.find((s) => s.id === arg) ??
493
+ null;
494
+ if (!target)
495
+ info(`error: no session with name or id ${arg}`);
496
+ }
497
+ if (target) {
498
+ const loaded = await history.loadSession(target.id);
499
+ if (!loaded) {
500
+ info(`error: failed to load session ${target.id}`);
501
+ }
502
+ else {
503
+ session = loaded;
504
+ messages = session.messages;
505
+ stats = session.stats;
506
+ model = session.model;
507
+ toolCtx.filesTouched = stats.files_touched;
508
+ toolCtx.sessionId = session.id;
509
+ const userTurns = messages.filter((m) => m.role === "user").length;
510
+ // Rebuild history view from the resumed session.
511
+ const restored = [];
512
+ for (const m of messages) {
513
+ if (m.role === "system")
514
+ continue;
515
+ restored.push({
516
+ id: `r-${restored.length}`,
517
+ role: m.role,
518
+ content: typeof m.content === "string" ? m.content : "",
519
+ tool_call_id: m.tool_call_id,
520
+ });
521
+ }
522
+ setState({ history: restored, current: null, model });
523
+ syncStatus();
524
+ info(`resumed ${session.id} (${userTurns} turns, model ${model})`);
525
+ }
526
+ }
527
+ return true;
528
+ }
529
+ case "cost":
530
+ info(formatCost(stats, model));
531
+ return true;
532
+ case "model":
533
+ if (!arg) {
534
+ info(`current model: ${model}`);
535
+ }
536
+ else if (!AVAILABLE_MODELS.includes(arg)) {
537
+ info(`error: unknown model: ${arg} (available: ${AVAILABLE_MODELS.join(", ")})`);
538
+ }
539
+ else {
540
+ model = arg;
541
+ syncStatus();
542
+ info(`model → ${model}`);
543
+ await persist();
544
+ }
545
+ return true;
546
+ case "yolo":
547
+ toolCtx.yolo = !toolCtx.yolo;
548
+ setState({ yolo: toolCtx.yolo });
549
+ info(`yolo: ${toolCtx.yolo}`);
550
+ return true;
551
+ case "reasoning": {
552
+ const cur = getState().reasoning;
553
+ const next = arg === "on" ? true : arg === "off" ? false : !cur;
554
+ setState({ reasoning: next });
555
+ info(`reasoning: ${next ? "on" : "off"}`);
556
+ return true;
557
+ }
558
+ case "queue": {
559
+ const sub = arg.trim().toLowerCase();
560
+ if (sub === "clear" || sub === "drop") {
561
+ const n = promptQueue.length;
562
+ promptQueue.length = 0;
563
+ syncStatus();
564
+ info(`cleared ${n} queued prompt(s)`);
565
+ }
566
+ else if (promptQueue.length === 0) {
567
+ info("queue is empty");
568
+ }
569
+ else {
570
+ info(promptQueue
571
+ .map((p, i) => `${String(i + 1).padStart(2, " ")}. ${p}`)
572
+ .join("\n"));
573
+ }
574
+ return true;
575
+ }
576
+ case "lang": {
577
+ const text = arg.trim();
578
+ if (!text) {
579
+ info(`language: ${session.language ? `"${session.language}"` : "off (any language)"}`);
580
+ }
581
+ else if (text === "off" || text === "default" || text === "any") {
582
+ delete session.language;
583
+ setState({ language: undefined });
584
+ await persist();
585
+ info("language directive cleared");
586
+ }
587
+ else {
588
+ session.language = text;
589
+ setState({ language: text });
590
+ await persist();
591
+ info(`language → "${text}" (replies will be exclusively in this language)`);
592
+ }
593
+ return true;
594
+ }
595
+ case "auto-continue": {
596
+ const t = arg.trim();
597
+ if (!t) {
598
+ const n = getState().autoContinue;
599
+ info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
600
+ }
601
+ else if (t === "off" || t === "0" || t === "false") {
602
+ setState({ autoContinue: 0 });
603
+ info("auto-continue: off");
604
+ }
605
+ else {
606
+ const n = parseInt(t, 10);
607
+ if (!Number.isFinite(n) || n < 0) {
608
+ info("error: usage: /auto-continue [N|off]");
609
+ }
610
+ else {
611
+ setState({ autoContinue: n });
612
+ info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
613
+ }
614
+ }
615
+ return true;
616
+ }
617
+ case "version":
618
+ info(formatVersionInfo());
619
+ return true;
620
+ case "audit": {
621
+ const sub = arg.trim();
622
+ if (!sub || sub === "path") {
623
+ info(audit.auditLogPath());
624
+ }
625
+ else if (sub.startsWith("show")) {
626
+ const nRaw = sub.replace(/^show\s*/, "").trim();
627
+ const limit = (() => {
628
+ const n = nRaw ? parseInt(nRaw, 10) : NaN;
629
+ return Number.isFinite(n) && n > 0 ? n : 10;
630
+ })();
631
+ try {
632
+ const text = await fsp.readFile(audit.auditLogPath(), "utf8");
633
+ const lines = text.split("\n").filter((l) => l.length > 0).slice(-limit);
634
+ info(lines.length ? lines.join("\n") : "(empty)");
635
+ }
636
+ catch {
637
+ info("(no audit log yet)");
638
+ }
639
+ }
640
+ else {
641
+ info("error: usage: /audit | /audit path | /audit show [N]");
642
+ }
643
+ return true;
644
+ }
645
+ case "transcript": {
646
+ const archived = session.archivedMessages ?? [];
647
+ if (archived.length === 0 && messages.length === 0) {
648
+ info("(no messages)");
649
+ return true;
650
+ }
651
+ const lines = [];
652
+ const renderMsg = (m, isArchived) => {
653
+ const tag = isArchived ? "archived " : "";
654
+ lines.push(`\n${tag}${m.role}`);
655
+ if (m.tool_calls && m.tool_calls.length) {
656
+ for (const tc of m.tool_calls) {
657
+ lines.push(` → ${tc.function.name}(${truncate(tc.function.arguments, 200)})`);
658
+ }
659
+ }
660
+ if (m.tool_call_id)
661
+ lines.push(` ← tool_call_id: ${m.tool_call_id}`);
662
+ if (typeof m.content === "string" && m.content)
663
+ lines.push(m.content);
664
+ };
665
+ if (archived.length) {
666
+ lines.push(`── archived (${archived.length} messages)`);
667
+ for (const m of archived)
668
+ renderMsg(m, true);
669
+ }
670
+ if (messages.length) {
671
+ lines.push(`── active (${messages.length} messages)`);
672
+ for (const m of messages)
673
+ renderMsg(m, false);
674
+ }
675
+ info(lines.join("\n"));
676
+ return true;
677
+ }
678
+ case "compact": {
679
+ const keepRaw = arg ? parseInt(arg, 10) : NaN;
680
+ const keep = Number.isFinite(keepRaw) ? Math.max(0, keepRaw) : 4;
681
+ await runCompaction(keep, false);
682
+ return true;
683
+ }
684
+ case "edit": {
685
+ // ink owns the terminal — unmount before spawning $EDITOR so the
686
+ // editor gets a clean screen. We also clear `history` before the
687
+ // unmount, otherwise the freshly-rendered <Static> after remount
688
+ // would emit all prior turns to scrollback a second time. The
689
+ // already-printed scrollback above stays untouched.
690
+ const initial = arg ? arg + "\n" : "";
691
+ setState({ history: [] });
692
+ inkInstance?.unmount();
693
+ const draft = openEditor(initial);
694
+ mountApp();
695
+ if (draft === null) {
696
+ info("error: editor failed");
697
+ }
698
+ else if (!draft.trim()) {
699
+ info("(empty draft, not sent)");
700
+ }
701
+ else {
702
+ handleSubmit(draft);
703
+ }
704
+ return true;
705
+ }
706
+ default:
707
+ info(`error: unknown command: /${cmd}`);
708
+ return true;
709
+ }
710
+ };
711
+ const handleSubmit = (text) => {
712
+ // Persist every submitted line — slash commands included, since
713
+ // recalling "/resume 3" via arrow-up is useful.
714
+ if (text.trim()) {
715
+ promptHistory.push(text);
716
+ void replHistory.append(text);
717
+ }
718
+ if (text.startsWith("/")) {
719
+ void handleSlash(text);
720
+ return;
721
+ }
722
+ promptQueue.push(text);
723
+ syncStatus();
724
+ void drain();
725
+ };
726
+ const handleAbort = () => {
727
+ if (pendingAbort && !pendingAbort.signal.aborted) {
728
+ pendingAbort.abort();
729
+ }
730
+ };
731
+ if (cli.prompt) {
732
+ // One-shot: run the agent against the prompt with stdout-mode output
733
+ // (no events, no ink) and exit. Session state still loads + persists so
734
+ // subsequent interactive runs see the new turn.
735
+ clearInterval(timerId);
736
+ messages.push({ role: "user", content: cli.prompt });
737
+ pendingAbort = new AbortController();
738
+ try {
739
+ await runAgent({
740
+ model,
741
+ stats,
742
+ toolCtx,
743
+ messages,
744
+ signal: pendingAbort.signal,
745
+ onTurn: () => void persist(),
746
+ showReasoning: getState().reasoning,
747
+ getSummary: () => session.compaction?.summary,
748
+ assistantLabel: session.assistantLabel,
749
+ maxAutoContinue: getState().autoContinue,
750
+ language: session.language,
751
+ // No events: agent writes its assistant header, content, tool
752
+ // arrows, and notices directly to stdout — same as REPL one-shot.
753
+ });
754
+ }
755
+ catch (e) {
756
+ if (e.name !== "AbortError" && !pendingAbort?.signal.aborted) {
757
+ process.stderr.write(`\n${e.message ?? "error"}\n`);
758
+ }
759
+ }
760
+ await persist();
761
+ process.stdout.write(`\n${formatCost(stats, model)}\n`);
762
+ process.exit(0);
763
+ }
764
+ mountApp();
765
+ }
766
+ function truncate(s, n) {
767
+ return s.length <= n ? s : s.slice(0, n) + "…";
768
+ }
769
+ function formatRelative(ts) {
770
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
771
+ if (s < 60)
772
+ return `${s}s ago`;
773
+ const m = Math.floor(s / 60);
774
+ if (m < 60)
775
+ return `${m}m ago`;
776
+ const h = Math.floor(m / 60);
777
+ if (h < 24)
778
+ return `${h}h ago`;
779
+ const d = Math.floor(h / 24);
780
+ return `${d}d ago`;
781
+ }
782
+ main().catch((e) => {
783
+ process.stderr.write(`fatal: ${e.message ?? e}\n`);
784
+ process.exit(1);
785
+ });
786
+ //# sourceMappingURL=tui.js.map