@este.systems/dsc 0.1.7 → 0.2.1

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 (43) 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 +5 -60
  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 +70 -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 +840 -0
  42. package/dist/tui.js.map +1 -0
  43. package/package.json +7 -2
package/dist/tui.js ADDED
@@ -0,0 +1,840 @@
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: formatTaskLabel(name, args) });
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
+ // Flip busy so the StatusBar shows the spinner — compaction is a
353
+ // single-shot API call that can take 5–15s on long sessions and was
354
+ // silent before this.
355
+ setState({ busy: true, task: "compacting" });
356
+ try {
357
+ const result = await compactSession(session, keep, model);
358
+ if (!result) {
359
+ if (!auto)
360
+ info(`nothing to compact (need more than ${keep} user turns)`);
361
+ return;
362
+ }
363
+ session.archivedMessages = [
364
+ ...(session.archivedMessages ?? []),
365
+ ...result.droppedMessages,
366
+ ];
367
+ session.messages = result.remainingMessages;
368
+ messages = session.messages;
369
+ session.compaction = {
370
+ summary: result.summary,
371
+ compacted_at: Date.now(),
372
+ turns_removed: (session.compaction?.turns_removed ?? 0) + result.turnsRemoved,
373
+ };
374
+ stats.prompts += 1;
375
+ recordUsage(stats, result.usage);
376
+ const afterChars = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
377
+ info(`compacted ${result.turnsRemoved} user turn(s); messages ${beforeMessages} → ${messages.length}; chars ${beforeChars} → ${afterChars} + ${result.summary.length} summary`);
378
+ syncStatus();
379
+ await persist();
380
+ }
381
+ catch (e) {
382
+ info(e instanceof DeepSeekError
383
+ ? `error: compaction failed: ${e.message}`
384
+ : `error: compaction failed: ${e.message}`);
385
+ }
386
+ finally {
387
+ setState({ busy: false, task: null });
388
+ }
389
+ };
390
+ // Returns true if the input was a recognized slash command and got handled
391
+ // (or rejected) — caller should not forward it to the agent. Returns false
392
+ // for non-slash input and unknown commands (caller decides what to do).
393
+ const handleSlash = async (line) => {
394
+ if (!line.startsWith("/"))
395
+ return false;
396
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
397
+ const arg = rest.join(" ");
398
+ switch (cmd) {
399
+ case "exit":
400
+ case "quit":
401
+ clearInterval(timerId);
402
+ process.exit(0);
403
+ return true;
404
+ case "help":
405
+ info([
406
+ "/help show this help",
407
+ "/clear start a new session",
408
+ "/list list sessions for this cwd",
409
+ "/resume [n|name|id] resume a session",
410
+ "/save <name> name the current session",
411
+ "/rename <text> set assistant label for this session",
412
+ "/model [name] show or switch model",
413
+ "/yolo toggle approval mode",
414
+ "/reasoning [on|off] toggle reasoning display",
415
+ "/lang [name|off] force the model to reply in a language",
416
+ "/auto-continue [N|off] auto-grant N extra MAX_TOOL_DEPTH budgets",
417
+ "/cost show token usage and cost",
418
+ "/version show version info",
419
+ "/compact [keep] summarize old turns (default keep=4)",
420
+ "/transcript dump full message log",
421
+ "/audit [path|show N] audit log info",
422
+ "/queue [clear] list or clear queued prompts",
423
+ "/exit exit",
424
+ ].join("\n"));
425
+ return true;
426
+ case "clear":
427
+ session = history.newSession(cwd, model);
428
+ messages = session.messages;
429
+ stats = session.stats;
430
+ toolCtx.filesTouched = stats.files_touched;
431
+ toolCtx.sessionId = session.id;
432
+ setState({ history: [], current: null });
433
+ info(`new session started (${session.id})`);
434
+ syncStatus();
435
+ return true;
436
+ case "list": {
437
+ const all = await history.listSessions(cwd);
438
+ if (!all.length) {
439
+ info(`no sessions for ${cwd}`);
440
+ }
441
+ else {
442
+ info(all
443
+ .map((s, i) => {
444
+ const here = s.id === session.id ? "* " : " ";
445
+ const label = s.name ? `${s.name} (${s.model})` : s.model;
446
+ return `${here}${String(i + 1).padStart(2, " ")}. ${label} ${formatRelative(s.updated_at)} (${s.message_count} msgs) ${s.first_user_message || "—"}`;
447
+ })
448
+ .join("\n"));
449
+ }
450
+ return true;
451
+ }
452
+ case "save":
453
+ if (!arg.trim()) {
454
+ info("error: usage: /save <name>");
455
+ }
456
+ else {
457
+ session.name = arg.trim();
458
+ await persist();
459
+ info(`session saved as "${session.name}" (id ${session.id})`);
460
+ }
461
+ return true;
462
+ case "rename": {
463
+ const text = arg.trim();
464
+ if (!text) {
465
+ info(`assistant label: "${session.assistantLabel ?? "assistant:"}"`);
466
+ }
467
+ else if (text === "--reset" || text === "default") {
468
+ delete session.assistantLabel;
469
+ setState({ assistantLabel: "assistant:" });
470
+ await persist();
471
+ info("assistant label reset to default");
472
+ }
473
+ else {
474
+ session.assistantLabel = text;
475
+ setState({ assistantLabel: text });
476
+ await persist();
477
+ info(`assistant label → "${text}"`);
478
+ }
479
+ return true;
480
+ }
481
+ case "resume": {
482
+ const all = await history.listSessions(cwd);
483
+ if (!all.length) {
484
+ info("no sessions to resume");
485
+ return true;
486
+ }
487
+ let target = null;
488
+ if (!arg || arg === "last") {
489
+ target = all[0];
490
+ }
491
+ else if (/^\d+$/.test(arg)) {
492
+ target = all[parseInt(arg, 10) - 1] ?? null;
493
+ if (!target)
494
+ info(`error: no session at index ${arg} (have ${all.length})`);
495
+ }
496
+ else {
497
+ target =
498
+ all.find((s) => s.name === arg) ??
499
+ all.find((s) => s.id === arg) ??
500
+ null;
501
+ if (!target)
502
+ info(`error: no session with name or id ${arg}`);
503
+ }
504
+ if (target) {
505
+ const loaded = await history.loadSession(target.id);
506
+ if (!loaded) {
507
+ info(`error: failed to load session ${target.id}`);
508
+ }
509
+ else {
510
+ session = loaded;
511
+ messages = session.messages;
512
+ stats = session.stats;
513
+ model = session.model;
514
+ toolCtx.filesTouched = stats.files_touched;
515
+ toolCtx.sessionId = session.id;
516
+ const userTurns = messages.filter((m) => m.role === "user").length;
517
+ // Rebuild history view from the resumed session.
518
+ const restored = [];
519
+ for (const m of messages) {
520
+ if (m.role === "system")
521
+ continue;
522
+ restored.push({
523
+ id: `r-${restored.length}`,
524
+ role: m.role,
525
+ content: typeof m.content === "string" ? m.content : "",
526
+ tool_call_id: m.tool_call_id,
527
+ });
528
+ }
529
+ setState({ history: restored, current: null, model });
530
+ syncStatus();
531
+ info(`resumed ${session.id} (${userTurns} turns, model ${model})`);
532
+ }
533
+ }
534
+ return true;
535
+ }
536
+ case "cost":
537
+ info(formatCost(stats, model));
538
+ return true;
539
+ case "model":
540
+ if (!arg) {
541
+ info(`current model: ${model}`);
542
+ }
543
+ else if (!AVAILABLE_MODELS.includes(arg)) {
544
+ info(`error: unknown model: ${arg} (available: ${AVAILABLE_MODELS.join(", ")})`);
545
+ }
546
+ else {
547
+ model = arg;
548
+ syncStatus();
549
+ info(`model → ${model}`);
550
+ await persist();
551
+ }
552
+ return true;
553
+ case "yolo":
554
+ toolCtx.yolo = !toolCtx.yolo;
555
+ setState({ yolo: toolCtx.yolo });
556
+ info(`yolo: ${toolCtx.yolo}`);
557
+ return true;
558
+ case "reasoning": {
559
+ const cur = getState().reasoning;
560
+ const next = arg === "on" ? true : arg === "off" ? false : !cur;
561
+ setState({ reasoning: next });
562
+ info(`reasoning: ${next ? "on" : "off"}`);
563
+ return true;
564
+ }
565
+ case "queue": {
566
+ const sub = arg.trim().toLowerCase();
567
+ if (sub === "clear" || sub === "drop") {
568
+ const n = promptQueue.length;
569
+ promptQueue.length = 0;
570
+ syncStatus();
571
+ info(`cleared ${n} queued prompt(s)`);
572
+ }
573
+ else if (promptQueue.length === 0) {
574
+ info("queue is empty");
575
+ }
576
+ else {
577
+ info(promptQueue
578
+ .map((p, i) => `${String(i + 1).padStart(2, " ")}. ${p}`)
579
+ .join("\n"));
580
+ }
581
+ return true;
582
+ }
583
+ case "lang": {
584
+ const text = arg.trim();
585
+ if (!text) {
586
+ info(`language: ${session.language ? `"${session.language}"` : "off (any language)"}`);
587
+ }
588
+ else if (text === "off" || text === "default" || text === "any") {
589
+ delete session.language;
590
+ setState({ language: undefined });
591
+ await persist();
592
+ info("language directive cleared");
593
+ }
594
+ else {
595
+ session.language = text;
596
+ setState({ language: text });
597
+ await persist();
598
+ info(`language → "${text}" (replies will be exclusively in this language)`);
599
+ }
600
+ return true;
601
+ }
602
+ case "auto-continue": {
603
+ const t = arg.trim();
604
+ if (!t) {
605
+ const n = getState().autoContinue;
606
+ info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
607
+ }
608
+ else if (t === "off" || t === "0" || t === "false") {
609
+ setState({ autoContinue: 0 });
610
+ info("auto-continue: off");
611
+ }
612
+ else {
613
+ const n = parseInt(t, 10);
614
+ if (!Number.isFinite(n) || n < 0) {
615
+ info("error: usage: /auto-continue [N|off]");
616
+ }
617
+ else {
618
+ setState({ autoContinue: n });
619
+ info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
620
+ }
621
+ }
622
+ return true;
623
+ }
624
+ case "version":
625
+ info(formatVersionInfo());
626
+ return true;
627
+ case "audit": {
628
+ const sub = arg.trim();
629
+ if (!sub || sub === "path") {
630
+ info(audit.auditLogPath());
631
+ }
632
+ else if (sub.startsWith("show")) {
633
+ const nRaw = sub.replace(/^show\s*/, "").trim();
634
+ const limit = (() => {
635
+ const n = nRaw ? parseInt(nRaw, 10) : NaN;
636
+ return Number.isFinite(n) && n > 0 ? n : 10;
637
+ })();
638
+ try {
639
+ const text = await fsp.readFile(audit.auditLogPath(), "utf8");
640
+ const lines = text.split("\n").filter((l) => l.length > 0).slice(-limit);
641
+ info(lines.length ? lines.join("\n") : "(empty)");
642
+ }
643
+ catch {
644
+ info("(no audit log yet)");
645
+ }
646
+ }
647
+ else {
648
+ info("error: usage: /audit | /audit path | /audit show [N]");
649
+ }
650
+ return true;
651
+ }
652
+ case "transcript": {
653
+ const archived = session.archivedMessages ?? [];
654
+ if (archived.length === 0 && messages.length === 0) {
655
+ info("(no messages)");
656
+ return true;
657
+ }
658
+ const lines = [];
659
+ const renderMsg = (m, isArchived) => {
660
+ const tag = isArchived ? "archived " : "";
661
+ lines.push(`\n${tag}${m.role}`);
662
+ if (m.tool_calls && m.tool_calls.length) {
663
+ for (const tc of m.tool_calls) {
664
+ lines.push(` → ${tc.function.name}(${truncate(tc.function.arguments, 200)})`);
665
+ }
666
+ }
667
+ if (m.tool_call_id)
668
+ lines.push(` ← tool_call_id: ${m.tool_call_id}`);
669
+ if (typeof m.content === "string" && m.content)
670
+ lines.push(m.content);
671
+ };
672
+ if (archived.length) {
673
+ lines.push(`── archived (${archived.length} messages)`);
674
+ for (const m of archived)
675
+ renderMsg(m, true);
676
+ }
677
+ if (messages.length) {
678
+ lines.push(`── active (${messages.length} messages)`);
679
+ for (const m of messages)
680
+ renderMsg(m, false);
681
+ }
682
+ info(lines.join("\n"));
683
+ return true;
684
+ }
685
+ case "compact": {
686
+ const keepRaw = arg ? parseInt(arg, 10) : NaN;
687
+ const keep = Number.isFinite(keepRaw) ? Math.max(0, keepRaw) : 4;
688
+ await runCompaction(keep, false);
689
+ return true;
690
+ }
691
+ case "edit": {
692
+ // ink owns the terminal — unmount before spawning $EDITOR so the
693
+ // editor gets a clean screen. We also clear `history` before the
694
+ // unmount, otherwise the freshly-rendered <Static> after remount
695
+ // would emit all prior turns to scrollback a second time. The
696
+ // already-printed scrollback above stays untouched.
697
+ const initial = arg ? arg + "\n" : "";
698
+ setState({ history: [] });
699
+ inkInstance?.unmount();
700
+ const draft = openEditor(initial);
701
+ mountApp();
702
+ if (draft === null) {
703
+ info("error: editor failed");
704
+ }
705
+ else if (!draft.trim()) {
706
+ info("(empty draft, not sent)");
707
+ }
708
+ else {
709
+ handleSubmit(draft);
710
+ }
711
+ return true;
712
+ }
713
+ default:
714
+ info(`error: unknown command: /${cmd}`);
715
+ return true;
716
+ }
717
+ };
718
+ const handleSubmit = (text) => {
719
+ // Persist every submitted line — slash commands included, since
720
+ // recalling "/resume 3" via arrow-up is useful.
721
+ if (text.trim()) {
722
+ promptHistory.push(text);
723
+ void replHistory.append(text);
724
+ }
725
+ if (text.startsWith("/")) {
726
+ void handleSlash(text);
727
+ return;
728
+ }
729
+ promptQueue.push(text);
730
+ syncStatus();
731
+ void drain();
732
+ };
733
+ const handleAbort = () => {
734
+ if (pendingAbort && !pendingAbort.signal.aborted) {
735
+ pendingAbort.abort();
736
+ }
737
+ };
738
+ if (cli.prompt) {
739
+ // One-shot: run the agent against the prompt with stdout-mode output
740
+ // (no events, no ink) and exit. Session state still loads + persists so
741
+ // subsequent interactive runs see the new turn.
742
+ clearInterval(timerId);
743
+ messages.push({ role: "user", content: cli.prompt });
744
+ pendingAbort = new AbortController();
745
+ try {
746
+ await runAgent({
747
+ model,
748
+ stats,
749
+ toolCtx,
750
+ messages,
751
+ signal: pendingAbort.signal,
752
+ onTurn: () => void persist(),
753
+ showReasoning: getState().reasoning,
754
+ getSummary: () => session.compaction?.summary,
755
+ assistantLabel: session.assistantLabel,
756
+ maxAutoContinue: getState().autoContinue,
757
+ language: session.language,
758
+ // No events: agent writes its assistant header, content, tool
759
+ // arrows, and notices directly to stdout — same as REPL one-shot.
760
+ });
761
+ }
762
+ catch (e) {
763
+ if (e.name !== "AbortError" && !pendingAbort?.signal.aborted) {
764
+ process.stderr.write(`\n${e.message ?? "error"}\n`);
765
+ }
766
+ }
767
+ await persist();
768
+ process.stdout.write(`\n${formatCost(stats, model)}\n`);
769
+ process.exit(0);
770
+ }
771
+ mountApp();
772
+ }
773
+ function truncate(s, n) {
774
+ return s.length <= n ? s : s.slice(0, n) + "…";
775
+ }
776
+ // Render the "what tool is running" label shown in the status bar (right
777
+ // side) and TaskLine. Without this, we'd dump the raw JSON arguments
778
+ // inline — `bash({"command":"npm test","description":"running tests"})` —
779
+ // which reads like the command leaked into the bar. Per-tool extraction
780
+ // picks the one field a user actually cares about.
781
+ function formatTaskLabel(name, argsJson) {
782
+ let primary;
783
+ try {
784
+ const a = JSON.parse(argsJson);
785
+ const pick = (k) => typeof a[k] === "string" ? a[k] : undefined;
786
+ switch (name) {
787
+ case "bash":
788
+ primary = pick("command");
789
+ break;
790
+ case "read_file":
791
+ case "write_file":
792
+ case "edit_file":
793
+ primary = pick("path");
794
+ break;
795
+ case "grep":
796
+ case "glob":
797
+ primary = pick("pattern");
798
+ break;
799
+ case "web_fetch":
800
+ primary = pick("url");
801
+ break;
802
+ case "web_search":
803
+ primary = pick("query");
804
+ break;
805
+ case "task_create":
806
+ primary = pick("subject");
807
+ break;
808
+ case "task_update": {
809
+ const id = pick("id") ?? String(a.id ?? "");
810
+ const status = pick("status");
811
+ primary = status ? `${id} → ${status}` : id;
812
+ break;
813
+ }
814
+ }
815
+ }
816
+ catch {
817
+ // Falls through to the bare tool name if the args weren't JSON.
818
+ }
819
+ if (!primary)
820
+ return name;
821
+ return `${name}: ${truncate(primary, 60)}`;
822
+ }
823
+ function formatRelative(ts) {
824
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
825
+ if (s < 60)
826
+ return `${s}s ago`;
827
+ const m = Math.floor(s / 60);
828
+ if (m < 60)
829
+ return `${m}m ago`;
830
+ const h = Math.floor(m / 60);
831
+ if (h < 24)
832
+ return `${h}h ago`;
833
+ const d = Math.floor(h / 24);
834
+ return `${d}d ago`;
835
+ }
836
+ main().catch((e) => {
837
+ process.stderr.write(`fatal: ${e.message ?? e}\n`);
838
+ process.exit(1);
839
+ });
840
+ //# sourceMappingURL=tui.js.map