@duetso/agent 0.1.33 → 0.1.35

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 (93) hide show
  1. package/README.md +2 -0
  2. package/dist/package.json +1 -1
  3. package/dist/src/cli/env.d.ts +18 -0
  4. package/dist/src/cli/env.d.ts.map +1 -0
  5. package/dist/src/cli/env.js +114 -0
  6. package/dist/src/cli/env.js.map +1 -0
  7. package/dist/src/cli/help.d.ts +8 -0
  8. package/dist/src/cli/help.d.ts.map +1 -0
  9. package/dist/src/cli/help.js +175 -0
  10. package/dist/src/cli/help.js.map +1 -0
  11. package/dist/src/cli/login.d.ts +13 -0
  12. package/dist/src/cli/login.d.ts.map +1 -0
  13. package/dist/src/cli/login.js +61 -0
  14. package/dist/src/cli/login.js.map +1 -0
  15. package/dist/src/cli/memories-db.d.ts +24 -0
  16. package/dist/src/cli/memories-db.d.ts.map +1 -0
  17. package/dist/src/cli/memories-db.js +74 -0
  18. package/dist/src/cli/memories-db.js.map +1 -0
  19. package/dist/src/cli/memories-tui.d.ts +11 -0
  20. package/dist/src/cli/memories-tui.d.ts.map +1 -0
  21. package/dist/src/cli/memories-tui.js +266 -0
  22. package/dist/src/cli/memories-tui.js.map +1 -0
  23. package/dist/src/cli/memories.d.ts +9 -0
  24. package/dist/src/cli/memories.d.ts.map +1 -0
  25. package/dist/src/cli/memories.js +38 -0
  26. package/dist/src/cli/memories.js.map +1 -0
  27. package/dist/src/cli/package-manager.d.ts +38 -0
  28. package/dist/src/cli/package-manager.d.ts.map +1 -0
  29. package/dist/src/cli/package-manager.js +78 -0
  30. package/dist/src/cli/package-manager.js.map +1 -0
  31. package/dist/src/cli/resume-hint.d.ts +22 -0
  32. package/dist/src/cli/resume-hint.d.ts.map +1 -0
  33. package/dist/src/cli/resume-hint.js +61 -0
  34. package/dist/src/cli/resume-hint.js.map +1 -0
  35. package/dist/src/cli/run.d.ts +43 -0
  36. package/dist/src/cli/run.d.ts.map +1 -0
  37. package/dist/src/cli/run.js +273 -0
  38. package/dist/src/cli/run.js.map +1 -0
  39. package/dist/src/cli/shared.d.ts +55 -0
  40. package/dist/src/cli/shared.d.ts.map +1 -0
  41. package/dist/src/cli/shared.js +125 -0
  42. package/dist/src/cli/shared.js.map +1 -0
  43. package/dist/src/cli/skills.d.ts +10 -0
  44. package/dist/src/cli/skills.d.ts.map +1 -0
  45. package/dist/src/cli/skills.js +42 -0
  46. package/dist/src/cli/skills.js.map +1 -0
  47. package/dist/src/cli/upgrade.d.ts +9 -0
  48. package/dist/src/cli/upgrade.d.ts.map +1 -0
  49. package/dist/src/cli/upgrade.js +52 -0
  50. package/dist/src/cli/upgrade.js.map +1 -0
  51. package/dist/src/cli/version-check.d.ts +22 -0
  52. package/dist/src/cli/version-check.d.ts.map +1 -0
  53. package/dist/src/cli/version-check.js +78 -0
  54. package/dist/src/cli/version-check.js.map +1 -0
  55. package/dist/src/cli.d.ts +20 -63
  56. package/dist/src/cli.d.ts.map +1 -1
  57. package/dist/src/cli.js +38 -887
  58. package/dist/src/cli.js.map +1 -1
  59. package/dist/src/memory/observational-prompts.d.ts.map +1 -1
  60. package/dist/src/memory/observational-prompts.js +11 -7
  61. package/dist/src/memory/observational-prompts.js.map +1 -1
  62. package/dist/src/tui/app.d.ts +7 -47
  63. package/dist/src/tui/app.d.ts.map +1 -1
  64. package/dist/src/tui/app.js +279 -396
  65. package/dist/src/tui/app.js.map +1 -1
  66. package/dist/src/tui/autocomplete.d.ts +91 -0
  67. package/dist/src/tui/autocomplete.d.ts.map +1 -0
  68. package/dist/src/tui/autocomplete.js +177 -0
  69. package/dist/src/tui/autocomplete.js.map +1 -0
  70. package/dist/src/tui/file-index.d.ts +11 -0
  71. package/dist/src/tui/file-index.d.ts.map +1 -0
  72. package/dist/src/tui/file-index.js +75 -0
  73. package/dist/src/tui/file-index.js.map +1 -0
  74. package/dist/src/tui/history.d.ts +50 -0
  75. package/dist/src/tui/history.d.ts.map +1 -0
  76. package/dist/src/tui/history.js +132 -0
  77. package/dist/src/tui/history.js.map +1 -0
  78. package/dist/src/tui/sidebar.d.ts +20 -0
  79. package/dist/src/tui/sidebar.d.ts.map +1 -0
  80. package/dist/src/tui/sidebar.js +118 -0
  81. package/dist/src/tui/sidebar.js.map +1 -0
  82. package/dist/src/tui/theme.d.ts +15 -0
  83. package/dist/src/tui/theme.d.ts.map +1 -0
  84. package/dist/src/tui/theme.js +18 -0
  85. package/dist/src/tui/theme.js.map +1 -0
  86. package/dist/src/turn-runner/prompts.d.ts.map +1 -1
  87. package/dist/src/turn-runner/prompts.js +7 -0
  88. package/dist/src/turn-runner/prompts.js.map +1 -1
  89. package/dist/src/turn-runner/tools.d.ts +15 -1
  90. package/dist/src/turn-runner/tools.d.ts.map +1 -1
  91. package/dist/src/turn-runner/tools.js +42 -9
  92. package/dist/src/turn-runner/tools.js.map +1 -1
  93. package/package.json +1 -1
@@ -1,25 +1,18 @@
1
1
  import { BoxRenderable, createCliRenderer, fg, ScrollBoxRenderable, t, TextRenderable, TextareaRenderable, } from "@opentui/core";
2
2
  import { formatCompactJson } from "../lib/compact-json.js";
3
- const COLORS = {
4
- user: "#7DD3FC",
5
- agent: "#FFFFFF",
6
- reasoning: "#9CA3AF",
7
- tool: "#A78BFA",
8
- system: "#FBBF24",
9
- error: "#F87171",
10
- hint: "#6B7280",
11
- memory: "#6B7280",
12
- status: "#34D399",
13
- border: "#374151",
14
- };
15
- const HINT_IDLE = "Enter: send · Esc: quit · Ctrl+C: force quit";
16
- const HINT_RUNNING = "Enter: steer · Shift+Enter: queue follow-up · Esc: interrupt and quit · Ctrl+C: force quit";
17
- const SKILL_AUTOCOMPLETE_LIMIT = 8;
18
- const SKILL_AUTOCOMPLETE_TOKEN = /^\/([A-Za-z0-9_.-]*)$/;
19
- const SKILL_AUTOCOMPLETE_DESCRIPTION_WIDTH = 72;
20
- const SKILL_AUTOCOMPLETE_DESCRIPTION_LINES = 2;
21
- const QUESTION_OPTION_LIMIT = 8;
22
- const QUESTION_OPTION_DESCRIPTION_WIDTH = 72;
3
+ import { activeFileAutocompleteToken, activeSkillAutocompleteToken, AUTOCOMPLETE_LIMITS, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, skillAutocompleteMatches, } from "./autocomplete.js";
4
+ import { buildFileIndex } from "./file-index.js";
5
+ import { historyDisplayBlocks, limitHistoryDisplayBlocks, startupHeaderLines, } from "./history.js";
6
+ import { createSidebar } from "./sidebar.js";
7
+ import { COLORS, HINT_IDLE, HINT_RUNNING } from "./theme.js";
8
+ // Re-exports preserve the historical `tui/app.js` entry point used by tests
9
+ // and external callers; the implementations live in focused leaf modules.
10
+ export { activeFileAutocompleteToken, activeSkillAutocompleteToken, fileAutocompleteMatches, formatQuestionOptionDescription, formatSkillAutocompleteDescription, moveQuestionOptionSelection, moveSkillAutocompleteSelection, questionPickerAnswerPayload, replaceFileAutocompleteToken, replaceSkillAutocompleteToken, skillAutocompleteMatches, } from "./autocomplete.js";
11
+ export { formatSkillAutocompleteItem } from "./autocomplete.js";
12
+ export { historyDisplayBlocks, limitHistoryDisplayBlocks, startupHeaderLines } from "./history.js";
13
+ const SKILL_AUTOCOMPLETE_LIMIT = AUTOCOMPLETE_LIMITS.skill;
14
+ const FILE_AUTOCOMPLETE_LIMIT = AUTOCOMPLETE_LIMITS.file;
15
+ const QUESTION_OPTION_LIMIT = AUTOCOMPLETE_LIMITS.questionOption;
23
16
  /**
24
17
  * Runs the interactive TUI for a session. Resolves with the most recent
25
18
  * terminal event (if any) when the user exits the UI.
@@ -49,84 +42,7 @@ export async function runTui(input) {
49
42
  flexShrink: 1,
50
43
  height: "100%",
51
44
  });
52
- // Fixed width keeps the sidebar legible on narrow terminals without
53
- // squashing the transcript. The two panels stack vertically inside.
54
- const sidebar = new BoxRenderable(renderer, {
55
- flexDirection: "column",
56
- width: 36,
57
- height: "100%",
58
- flexShrink: 0,
59
- });
60
- const todoPanel = new BoxRenderable(renderer, {
61
- flexDirection: "column",
62
- border: true,
63
- borderColor: COLORS.border,
64
- padding: 1,
65
- flexGrow: 1,
66
- flexShrink: 1,
67
- });
68
- const todoTitle = new TextRenderable(renderer, {
69
- content: "todos",
70
- fg: COLORS.status,
71
- height: 1,
72
- flexShrink: 0,
73
- });
74
- const todoBody = new TextRenderable(renderer, {
75
- content: "(none)",
76
- fg: COLORS.hint,
77
- flexGrow: 1,
78
- flexShrink: 1,
79
- });
80
- todoPanel.add(todoTitle);
81
- todoPanel.add(todoBody);
82
- const smPanel = new BoxRenderable(renderer, {
83
- flexDirection: "column",
84
- border: true,
85
- borderColor: COLORS.border,
86
- padding: 1,
87
- flexGrow: 1,
88
- flexShrink: 1,
89
- });
90
- const smTitle = new TextRenderable(renderer, {
91
- content: "state machine",
92
- fg: COLORS.status,
93
- height: 1,
94
- flexShrink: 0,
95
- });
96
- const smBody = new TextRenderable(renderer, {
97
- content: "(inactive)",
98
- fg: COLORS.hint,
99
- flexGrow: 1,
100
- flexShrink: 1,
101
- });
102
- smPanel.add(smTitle);
103
- smPanel.add(smBody);
104
- const contextPanel = new BoxRenderable(renderer, {
105
- flexDirection: "column",
106
- border: true,
107
- borderColor: COLORS.border,
108
- paddingLeft: 1,
109
- paddingRight: 1,
110
- height: 5,
111
- flexShrink: 0,
112
- });
113
- const contextTitle = new TextRenderable(renderer, {
114
- content: "context",
115
- fg: COLORS.status,
116
- height: 1,
117
- flexShrink: 0,
118
- });
119
- const contextBody = new TextRenderable(renderer, {
120
- content: "(waiting for usage)",
121
- fg: COLORS.hint,
122
- flexGrow: 1,
123
- flexShrink: 1,
124
- });
125
- contextPanel.add(contextTitle);
126
- contextPanel.add(contextBody);
127
- sidebar.add(todoPanel);
128
- sidebar.add(smPanel);
129
- sidebar.add(contextPanel);
45
+ const sidebar = createSidebar(renderer);
130
46
  const transcript = new ScrollBoxRenderable(renderer, {
131
47
  flexGrow: 1,
132
48
  flexShrink: 1,
@@ -176,6 +92,37 @@ export async function runTui(input) {
176
92
  for (const row of skillAutocompleteRows) {
177
93
  skillAutocompletePanel.add(row);
178
94
  }
95
+ // The @-file picker mirrors the slash picker's structure so the renderer
96
+ // logic and key handling can stay parallel between the two pickers.
97
+ const fileAutocompletePanel = new BoxRenderable(renderer, {
98
+ flexDirection: "column",
99
+ border: true,
100
+ borderColor: COLORS.border,
101
+ paddingLeft: 1,
102
+ paddingRight: 1,
103
+ flexShrink: 0,
104
+ });
105
+ fileAutocompletePanel.visible = false;
106
+ const fileAutocompleteTitle = new TextRenderable(renderer, {
107
+ content: "files",
108
+ fg: COLORS.status,
109
+ height: 1,
110
+ flexShrink: 0,
111
+ });
112
+ const fileAutocompleteRows = Array.from({ length: FILE_AUTOCOMPLETE_LIMIT }, () => {
113
+ const row = new TextRenderable(renderer, {
114
+ content: "",
115
+ fg: COLORS.hint,
116
+ height: 1,
117
+ flexShrink: 0,
118
+ });
119
+ row.visible = false;
120
+ return row;
121
+ });
122
+ fileAutocompletePanel.add(fileAutocompleteTitle);
123
+ for (const row of fileAutocompleteRows) {
124
+ fileAutocompletePanel.add(row);
125
+ }
179
126
  const questionPanel = new BoxRenderable(renderer, {
180
127
  flexDirection: "column",
181
128
  border: true,
@@ -239,10 +186,11 @@ export async function runTui(input) {
239
186
  layout.add(status);
240
187
  layout.add(hint);
241
188
  layout.add(skillAutocompletePanel);
189
+ layout.add(fileAutocompletePanel);
242
190
  layout.add(questionPanel);
243
191
  layout.add(inputBox);
244
192
  root.add(layout);
245
- root.add(sidebar);
193
+ root.add(sidebar.view);
246
194
  renderer.root.add(root);
247
195
  inputField.focus();
248
196
  // ---- transcript helpers ----------------------------------------------------
@@ -263,7 +211,6 @@ export async function runTui(input) {
263
211
  function appendLine(content, fg) {
264
212
  if (!content)
265
213
  return;
266
- // ScrollBox children stack vertically; one Text per logical line keeps wrapping simple.
267
214
  const line = new TextRenderable(renderer, { content, fg });
268
215
  transcript.add(line);
269
216
  scrollToBottomSoon();
@@ -312,14 +259,72 @@ export async function runTui(input) {
312
259
  // — swapping the spinner for a check/cross and appending the result —
313
260
  // instead of pushing a separate block.
314
261
  const activeToolBlocks = new Map();
262
+ // Tracks the wall-clock start of the current turn so the status line can
263
+ // surface a live "Ns" / "Nm Ns" elapsed counter while work is in flight.
264
+ let workingStartedAt;
265
+ let workingTicker;
266
+ // Swapped out by memory events so the ticker can keep refreshing while the
267
+ // human-readable phase ("recalling memories…", etc.) stays accurate.
268
+ let workingMessage = "working…";
269
+ function formatElapsed(ms) {
270
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
271
+ if (totalSeconds < 60)
272
+ return `${totalSeconds}s`;
273
+ const minutes = Math.floor(totalSeconds / 60);
274
+ const seconds = totalSeconds % 60;
275
+ return `${minutes}m ${seconds}s`;
276
+ }
277
+ function refreshWorkingStatus() {
278
+ refreshActiveToolBlocks();
279
+ if (workingStartedAt === undefined)
280
+ return;
281
+ const elapsed = formatElapsed(Date.now() - workingStartedAt);
282
+ setStatus(`● ${workingMessage} (${elapsed} · Esc to interrupt, Ctrl+C to force quit)`);
283
+ }
284
+ // Sub-second precision for short tool calls keeps fast operations honest;
285
+ // longer calls fall back to the coarser m/s formatter shared with the
286
+ // working-status counter.
287
+ function formatToolDuration(ms) {
288
+ if (ms < 10_000)
289
+ return `${(ms / 1000).toFixed(1)}s`;
290
+ return formatElapsed(ms);
291
+ }
292
+ function refreshActiveToolBlocks() {
293
+ if (activeToolBlocks.size === 0)
294
+ return;
295
+ for (const block of activeToolBlocks.values()) {
296
+ if (block.startedAt === undefined)
297
+ continue;
298
+ const elapsed = formatToolDuration(Date.now() - block.startedAt);
299
+ const header = `[tool ${block.toolName}] ⏳ ${elapsed}`;
300
+ block.line.content = block.inputBody ? `${header}\n${block.inputBody}` : header;
301
+ }
302
+ }
303
+ function startWorkingTicker() {
304
+ if (workingTicker !== undefined)
305
+ return;
306
+ workingTicker = setInterval(refreshWorkingStatus, 1000);
307
+ }
308
+ function stopWorkingTicker() {
309
+ if (workingTicker !== undefined) {
310
+ clearInterval(workingTicker);
311
+ workingTicker = undefined;
312
+ }
313
+ }
315
314
  function markRunning() {
316
315
  running = true;
317
316
  setHint(true);
318
- setStatus("working… (Esc to interrupt, Ctrl+C to force quit)");
317
+ workingMessage = "working…";
318
+ workingStartedAt = Date.now();
319
+ refreshWorkingStatus();
320
+ startWorkingTicker();
319
321
  }
320
322
  function markIdle() {
321
323
  running = false;
322
324
  setHint(false);
325
+ stopWorkingTicker();
326
+ workingStartedAt = undefined;
327
+ workingMessage = "working…";
323
328
  setStatus("");
324
329
  }
325
330
  function reportError(error) {
@@ -329,80 +334,11 @@ export async function runTui(input) {
329
334
  // ---- session subscription --------------------------------------------------
330
335
  function refreshSidebar() {
331
336
  const state = input.session.getState();
332
- renderTodoSidebar(state?.todos ?? []);
333
- renderStateMachineSidebar(state?.stateMachine);
334
- renderContextUsageSidebar(latestContextUsage);
335
- }
336
- function renderTodoSidebar(todos) {
337
- if (todos.length === 0) {
338
- todoBody.content = "(none)";
339
- todoBody.fg = COLORS.hint;
340
- return;
341
- }
342
- const lines = todos.map((todo) => `${todoStatusGlyph(todo.status)} ${todo.content}`);
343
- todoBody.content = lines.join("\n");
344
- todoBody.fg = COLORS.agent;
345
- }
346
- function todoStatusGlyph(status) {
347
- if (status === "completed")
348
- return "✓";
349
- if (status === "in_progress")
350
- return "●";
351
- if (status === "failed")
352
- return "✗";
353
- return "○";
354
- }
355
- function renderStateMachineSidebar(session) {
356
- if (!session) {
357
- smBody.content = "(inactive)";
358
- smBody.fg = COLORS.hint;
359
- return;
360
- }
361
- const current = session.currentState;
362
- const lines = session.definition.states.map((state) => {
363
- const marker = state.name === current ? "▶" : " ";
364
- return `${marker} ${state.name}`;
365
- });
366
- if (session.terminal) {
367
- lines.push("", `terminal: ${session.terminal.status}`);
368
- }
369
- smBody.content = lines.join("\n");
370
- smBody.fg = COLORS.agent;
371
- }
372
- function renderContextUsageSidebar(usage) {
373
- if (!usage) {
374
- contextBody.content = "(waiting for usage)";
375
- contextBody.fg = COLORS.hint;
376
- return;
377
- }
378
- const usedTokens = usage.usage.totalTokens;
379
- const percent = Math.min(1, usedTokens / usage.contextWindow);
380
- contextBody.content = [
381
- progressBar(percent, 25),
382
- `${formatTokenCount(usedTokens)} / ${formatTokenCount(usage.contextWindow)}`,
383
- ].join("\n");
384
- contextBody.fg = usedTokens >= usage.contextWindow ? COLORS.error : COLORS.agent;
385
- }
386
- function progressBar(value, width) {
387
- const clamped = Math.max(0, Math.min(1, value));
388
- const filled = Math.round(clamped * width);
389
- const empty = width - filled;
390
- return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${`${Math.round(clamped * 100)}%`.padStart(4)}`;
391
- }
392
- function formatTokenCount(tokens) {
393
- if (tokens >= 1_000_000)
394
- return `${formatCompactNumber(tokens / 1_000_000)}m`;
395
- if (tokens >= 1_000)
396
- return `${formatCompactNumber(tokens / 1_000)}k`;
397
- return String(tokens);
398
- }
399
- function formatCompactNumber(value) {
400
- const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
401
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
337
+ sidebar.setTodos(state?.todos ?? []);
338
+ sidebar.setStateMachine(state?.stateMachine);
339
+ sidebar.setContextUsage(latestContextUsage);
402
340
  }
403
341
  const unsubscribe = input.session.subscribe((event) => {
404
- // Sidebar mirrors the runner's authoritative state, so refresh it on
405
- // every event rather than threading specific updates through each branch.
406
342
  refreshSidebar();
407
343
  if (event.type === "step") {
408
344
  renderStep(event.step);
@@ -418,7 +354,7 @@ export async function runTui(input) {
418
354
  }
419
355
  else if (event.type === "context_usage") {
420
356
  latestContextUsage = event;
421
- renderContextUsageSidebar(event);
357
+ sidebar.setContextUsage(event);
422
358
  }
423
359
  else if (event.type === "system") {
424
360
  appendBlock("[system]", event.message, COLORS.system);
@@ -429,6 +365,7 @@ export async function runTui(input) {
429
365
  appendBlock("[question]", event.questions.map((q) => q.question).join("\n"), COLORS.system);
430
366
  showQuestions(event.questions);
431
367
  renderUsage(event.usage);
368
+ renderTurnElapsed();
432
369
  lastTerminal = event;
433
370
  markIdle();
434
371
  }
@@ -436,24 +373,22 @@ export async function runTui(input) {
436
373
  if (event.error) {
437
374
  appendBlock("[error]", event.error, COLORS.error);
438
375
  }
439
- else if (event.result) {
440
- // Result is also normally streamed via text steps; only show if no streaming happened
441
- // for this turn (cheap heuristic: empty transcript-since-last-prompt).
442
- // Always-append is fine too — duplicate text is harmless and clearer for short turns.
443
- }
444
376
  renderUsage(event.usage);
377
+ renderTurnElapsed();
445
378
  lastTerminal = event;
446
379
  markIdle();
447
380
  }
448
381
  else if (event.type === "interrupted") {
449
382
  appendLine("[interrupted]", COLORS.system);
450
383
  renderUsage(event.usage);
384
+ renderTurnElapsed();
451
385
  lastTerminal = event;
452
386
  markIdle();
453
387
  }
454
388
  else if (event.type === "sleep") {
455
389
  appendLine(`[sleeping until ${new Date(event.wakeAt).toLocaleTimeString()}]`, COLORS.system);
456
390
  renderUsage(event.usage);
391
+ renderTurnElapsed();
457
392
  lastTerminal = event;
458
393
  markIdle();
459
394
  }
@@ -487,6 +422,11 @@ export async function runTui(input) {
487
422
  const cost = usage.cost.total === 0 ? "" : ` · Cost: $${usage.cost.total.toFixed(4)}`;
488
423
  appendLine(`[usage] Tokens: ${parts.join(" ")}${cost}`, COLORS.hint);
489
424
  }
425
+ function renderTurnElapsed() {
426
+ if (workingStartedAt === undefined)
427
+ return;
428
+ appendLine(`● turn finished in ${formatElapsed(Date.now() - workingStartedAt)}`, COLORS.status);
429
+ }
490
430
  function renderTodos(todos) {
491
431
  if (todos.length === 0) {
492
432
  appendBlock("[todos]", "No todos", COLORS.hint);
@@ -496,7 +436,10 @@ export async function runTui(input) {
496
436
  }
497
437
  function renderFollowUpQueue(prompts) {
498
438
  if (prompts.length === 0) {
499
- setStatus(running ? "● working… (Esc to interrupt, Ctrl+C to force quit)" : "");
439
+ if (running)
440
+ refreshWorkingStatus();
441
+ else
442
+ setStatus("");
500
443
  return;
501
444
  }
502
445
  setStatus(`queued follow-ups: ${prompts.length}`);
@@ -560,7 +503,9 @@ export async function runTui(input) {
560
503
  const existing = activeToolBlocks.get(step.toolCallId);
561
504
  if (!existing) {
562
505
  const inputBody = step.input === undefined ? "" : formatCompactJson(step.input);
563
- const header = `[tool ${step.toolName}] ⏳`;
506
+ const isLive = step.status === "running" || step.status === "pending";
507
+ const startedAt = isLive ? Date.now() : undefined;
508
+ const header = isLive ? `[tool ${step.toolName}] ⏳ 0.0s` : `[tool ${step.toolName}] ⏳`;
564
509
  const fg = step.status === "error" ? COLORS.error : COLORS.tool;
565
510
  const line = new TextRenderable(renderer, {
566
511
  content: inputBody ? `${header}\n${inputBody}` : header,
@@ -568,7 +513,7 @@ export async function runTui(input) {
568
513
  });
569
514
  beginBlock();
570
515
  transcript.add(line);
571
- const block = { line, toolName: step.toolName, inputBody };
516
+ const block = { line, toolName: step.toolName, inputBody, startedAt };
572
517
  activeToolBlocks.set(step.toolCallId, block);
573
518
  scrollToBottomSoon();
574
519
  // The same event may already carry a terminal status (cached/replayed
@@ -583,7 +528,9 @@ export async function runTui(input) {
583
528
  function finalizeToolCall(step, block) {
584
529
  const isError = step.status === "error";
585
530
  const marker = isError ? "✗" : "✓";
586
- const header = `[tool ${block.toolName}] ${marker}`;
531
+ const header = block.startedAt === undefined
532
+ ? `[tool ${block.toolName}] ${marker}`
533
+ : `[tool ${block.toolName}] ${marker} ${formatToolDuration(Date.now() - block.startedAt)}`;
587
534
  const sections = [block.inputBody ? `${header}\n${block.inputBody}` : header];
588
535
  if (step.output && step.output.length > 0) {
589
536
  const text = textFromContent(step.output);
@@ -608,7 +555,8 @@ export async function runTui(input) {
608
555
  }
609
556
  function renderMemoryStatus(event) {
610
557
  if (event.status === "running") {
611
- setStatus(`● ${event.message} (Esc to interrupt, Ctrl+C to force quit)`);
558
+ workingMessage = event.message;
559
+ refreshWorkingStatus();
612
560
  return;
613
561
  }
614
562
  const body = formatMemoryEventBody(event);
@@ -616,7 +564,8 @@ export async function runTui(input) {
616
564
  appendBlock(`[memory:${event.phase}]`, body, COLORS.memory);
617
565
  }
618
566
  if (running) {
619
- setStatus("working… (Esc to interrupt, Ctrl+C to force quit)");
567
+ workingMessage = "working…";
568
+ refreshWorkingStatus();
620
569
  }
621
570
  }
622
571
  function formatMemoryEventBody(event) {
@@ -635,6 +584,14 @@ export async function runTui(input) {
635
584
  let skillAutocompleteToken;
636
585
  let skillAutocompleteItems = [];
637
586
  let skillAutocompleteSelectedIndex = 0;
587
+ // File index loads lazily after the first @ trigger and never re-runs.
588
+ // Repos large enough to matter would block the first keystroke otherwise;
589
+ // a stale-by-a-few-files index is a fair trade for a snappy first paint.
590
+ let fileAutocompleteAllFiles = [];
591
+ let fileAutocompleteIndexPromise;
592
+ let fileAutocompleteToken;
593
+ let fileAutocompleteItems = [];
594
+ let fileAutocompleteSelectedIndex = 0;
638
595
  let pendingQuestions = [];
639
596
  let questionOptionSelectedIndex = 0;
640
597
  let suppressNextEscapeExit = false;
@@ -644,6 +601,7 @@ export async function runTui(input) {
644
601
  if (closingAfterInterrupt)
645
602
  return;
646
603
  closingAfterInterrupt = true;
604
+ stopWorkingTicker();
647
605
  setStatus("● interrupting…");
648
606
  try {
649
607
  await input.session.interrupt();
@@ -663,6 +621,9 @@ export async function runTui(input) {
663
621
  function skillAutocompleteIsOpen() {
664
622
  return Boolean(skillAutocompleteToken && skillAutocompleteItems.length > 0);
665
623
  }
624
+ function fileAutocompleteIsOpen() {
625
+ return Boolean(fileAutocompleteToken && fileAutocompleteItems.length > 0);
626
+ }
666
627
  function questionPickerIsOpen() {
667
628
  const question = pendingQuestions[0];
668
629
  return Boolean(question && question.options.length > 0);
@@ -677,6 +638,16 @@ export async function runTui(input) {
677
638
  row.content = "";
678
639
  }
679
640
  }
641
+ function hideFileAutocomplete() {
642
+ fileAutocompleteToken = undefined;
643
+ fileAutocompleteItems = [];
644
+ fileAutocompleteSelectedIndex = 0;
645
+ fileAutocompletePanel.visible = false;
646
+ for (const row of fileAutocompleteRows) {
647
+ row.visible = false;
648
+ row.content = "";
649
+ }
650
+ }
680
651
  function hideQuestions() {
681
652
  pendingQuestions = [];
682
653
  questionOptionSelectedIndex = 0;
@@ -731,6 +702,19 @@ export async function runTui(input) {
731
702
  markRunning();
732
703
  return true;
733
704
  }
705
+ async function ensureFileIndex() {
706
+ if (fileAutocompleteAllFiles.length > 0)
707
+ return fileAutocompleteAllFiles;
708
+ if (!fileAutocompleteIndexPromise) {
709
+ fileAutocompleteIndexPromise = buildFileIndex(input.workDir).catch(() => []);
710
+ }
711
+ fileAutocompleteAllFiles = await fileAutocompleteIndexPromise;
712
+ return fileAutocompleteAllFiles;
713
+ }
714
+ function refreshAutocomplete() {
715
+ refreshSkillAutocomplete();
716
+ refreshFileAutocomplete();
717
+ }
734
718
  function refreshSkillAutocomplete() {
735
719
  const token = activeSkillAutocompleteToken(inputField.plainText, inputField.cursorOffset);
736
720
  if (!token) {
@@ -754,6 +738,44 @@ export async function runTui(input) {
754
738
  }
755
739
  renderSkillAutocomplete();
756
740
  }
741
+ function refreshFileAutocomplete() {
742
+ const token = activeFileAutocompleteToken(inputField.plainText, inputField.cursorOffset);
743
+ if (!token) {
744
+ hideFileAutocomplete();
745
+ return;
746
+ }
747
+ // Capture the token id we're looking up so a slow index resolution can
748
+ // tell whether the user has typed past the original query and bail out.
749
+ const targetStart = token.start;
750
+ const targetEnd = token.end;
751
+ const targetQuery = token.query;
752
+ void ensureFileIndex().then((files) => {
753
+ const stillCurrent = fileAutocompleteToken !== undefined
754
+ ? fileAutocompleteToken.start === targetStart &&
755
+ fileAutocompleteToken.end === targetEnd &&
756
+ fileAutocompleteToken.query === targetQuery
757
+ : activeFileAutocompleteToken(inputField.plainText, inputField.cursorOffset)?.query ===
758
+ targetQuery;
759
+ if (!stillCurrent && fileAutocompleteToken === undefined)
760
+ return;
761
+ const items = fileAutocompleteMatches(files, targetQuery);
762
+ if (items.length === 0) {
763
+ hideFileAutocomplete();
764
+ return;
765
+ }
766
+ const previousToken = fileAutocompleteToken;
767
+ fileAutocompleteToken = { start: targetStart, end: targetEnd, query: targetQuery };
768
+ fileAutocompleteItems = items;
769
+ const queryChanged = !previousToken ||
770
+ previousToken.start !== targetStart ||
771
+ previousToken.end !== targetEnd ||
772
+ previousToken.query !== targetQuery;
773
+ if (queryChanged || fileAutocompleteSelectedIndex >= items.length) {
774
+ fileAutocompleteSelectedIndex = 0;
775
+ }
776
+ renderFileAutocomplete();
777
+ });
778
+ }
757
779
  function renderSkillAutocomplete() {
758
780
  skillAutocompletePanel.visible = skillAutocompleteItems.length > 0;
759
781
  for (const [index, row] of skillAutocompleteRows.entries()) {
@@ -774,6 +796,30 @@ export async function runTui(input) {
774
796
  row.visible = true;
775
797
  }
776
798
  }
799
+ function renderFileAutocomplete() {
800
+ fileAutocompletePanel.visible = fileAutocompleteItems.length > 0;
801
+ for (const [index, row] of fileAutocompleteRows.entries()) {
802
+ const item = fileAutocompleteItems[index];
803
+ if (!item) {
804
+ row.visible = false;
805
+ row.content = "";
806
+ continue;
807
+ }
808
+ const selected = index === fileAutocompleteSelectedIndex;
809
+ const nameColor = selected ? COLORS.status : COLORS.user;
810
+ const pathColor = selected ? COLORS.agent : COLORS.hint;
811
+ // Show basename + relative directory side-by-side. The directory
812
+ // portion is the path with the trailing basename removed; for files at
813
+ // the repo root this collapses to "./" so each row has a consistent
814
+ // shape.
815
+ const directory = item.relativePath.includes("/")
816
+ ? item.relativePath.slice(0, item.relativePath.lastIndexOf("/") + 1)
817
+ : "./";
818
+ row.content = t `${fg(nameColor)(item.name)} ${fg(pathColor)(directory)}`;
819
+ row.fg = selected ? COLORS.agent : COLORS.hint;
820
+ row.visible = true;
821
+ }
822
+ }
777
823
  function completeSelectedSkillAutocomplete() {
778
824
  const token = skillAutocompleteToken;
779
825
  const item = skillAutocompleteItems[skillAutocompleteSelectedIndex];
@@ -788,6 +834,20 @@ export async function runTui(input) {
788
834
  hideSkillAutocomplete();
789
835
  return true;
790
836
  }
837
+ function completeSelectedFileAutocomplete() {
838
+ const token = fileAutocompleteToken;
839
+ const item = fileAutocompleteItems[fileAutocompleteSelectedIndex];
840
+ if (!token || !item)
841
+ return false;
842
+ const insertion = inputField.plainText[token.end]?.match(/\s/)
843
+ ? `@${item.relativePath}`
844
+ : `@${item.relativePath} `;
845
+ inputField.setSelection(token.start, token.end);
846
+ inputField.deleteSelection();
847
+ inputField.insertText(insertion);
848
+ hideFileAutocomplete();
849
+ return true;
850
+ }
791
851
  const keyHandler = renderer._keyHandler;
792
852
  keyHandler.onInternal("keypress", (key) => {
793
853
  if (key.name !== "escape")
@@ -802,6 +862,11 @@ export async function runTui(input) {
802
862
  hideSkillAutocomplete();
803
863
  return;
804
864
  }
865
+ if (fileAutocompleteIsOpen()) {
866
+ key.preventDefault();
867
+ hideFileAutocomplete();
868
+ return;
869
+ }
805
870
  if (questionPickerIsOpen()) {
806
871
  key.preventDefault();
807
872
  hideQuestions();
@@ -827,7 +892,7 @@ export async function runTui(input) {
827
892
  key.preventDefault();
828
893
  return;
829
894
  }
830
- if (key.name === "return" || key.name === "enter") {
895
+ if (key.name === "return" || key.name === "enter" || key.name === "tab") {
831
896
  key.preventDefault();
832
897
  completeSelectedSkillAutocomplete();
833
898
  return;
@@ -839,6 +904,31 @@ export async function runTui(input) {
839
904
  return;
840
905
  }
841
906
  }
907
+ if (fileAutocompleteIsOpen()) {
908
+ if (key.name === "up") {
909
+ fileAutocompleteSelectedIndex = moveSkillAutocompleteSelection(fileAutocompleteSelectedIndex, fileAutocompleteItems.length, -1);
910
+ renderFileAutocomplete();
911
+ key.preventDefault();
912
+ return;
913
+ }
914
+ if (key.name === "down") {
915
+ fileAutocompleteSelectedIndex = moveSkillAutocompleteSelection(fileAutocompleteSelectedIndex, fileAutocompleteItems.length, 1);
916
+ renderFileAutocomplete();
917
+ key.preventDefault();
918
+ return;
919
+ }
920
+ if (key.name === "return" || key.name === "enter" || key.name === "tab") {
921
+ key.preventDefault();
922
+ completeSelectedFileAutocomplete();
923
+ return;
924
+ }
925
+ if (key.name === "escape") {
926
+ key.preventDefault();
927
+ suppressNextEscapeExit = true;
928
+ hideFileAutocomplete();
929
+ return;
930
+ }
931
+ }
842
932
  if (questionPickerIsOpen()) {
843
933
  if (key.name === "up") {
844
934
  questionOptionSelectedIndex = moveQuestionOptionSelection(questionOptionSelectedIndex, Math.min(pendingQuestions[0]?.options.length ?? 0, QUESTION_OPTION_LIMIT), -1);
@@ -861,9 +951,6 @@ export async function runTui(input) {
861
951
  }
862
952
  if (key.name === "return" || key.name === "enter") {
863
953
  lastEnterShift = Boolean(key.shift);
864
- // Take over Enter so the textarea does not insert a newline. We submit
865
- // the current buffer contents and reset, regardless of shift state —
866
- // shift only differentiates steer vs. queued follow-up.
867
954
  const value = inputField.plainText.trim();
868
955
  inputField.clear();
869
956
  key.preventDefault();
@@ -881,20 +968,16 @@ export async function runTui(input) {
881
968
  return;
882
969
  }
883
970
  };
884
- inputField.onContentChange = () => refreshSkillAutocomplete();
885
- inputField.onCursorChange = () => refreshSkillAutocomplete();
971
+ inputField.onContentChange = () => refreshAutocomplete();
972
+ inputField.onCursorChange = () => refreshAutocomplete();
886
973
  function submit(message, shiftEnter) {
887
974
  appendBlock("you:", message, COLORS.user);
888
975
  hideQuestions();
889
976
  if (running) {
890
- // Mid-turn: Enter → steer, Shift+Enter → queued follow-up.
891
977
  const behavior = shiftEnter ? "follow_up" : "steer";
892
978
  void input.session.prompt({ message, behavior }).catch(reportError);
893
- // Keep status as "working"; the existing turn continues.
894
979
  return;
895
980
  }
896
- // Idle: dispatch a prompt against the already-set-up session. Setup
897
- // happens before the TUI starts so skills are visible right away.
898
981
  void input.session.prompt({ message, behavior: "follow_up" }).catch(reportError);
899
982
  markRunning();
900
983
  }
@@ -910,7 +993,7 @@ export async function runTui(input) {
910
993
  description: skill.description,
911
994
  path: skill.baseDir,
912
995
  }));
913
- refreshSkillAutocomplete();
996
+ refreshAutocomplete();
914
997
  renderSetupIntro(skills, agentFiles);
915
998
  refreshSidebar();
916
999
  const resumeHistoryLines = input.resumeHistoryLines ?? Number.POSITIVE_INFINITY;
@@ -933,8 +1016,6 @@ export async function runTui(input) {
933
1016
  markRunning();
934
1017
  }
935
1018
  else {
936
- // No initial prompt — wait for the user. Setup already ran above, so
937
- // the skill summary is rendered before the user types.
938
1019
  markIdle();
939
1020
  }
940
1021
  // ---- run renderer until the user quits -------------------------------------
@@ -966,204 +1047,6 @@ export async function runTui(input) {
966
1047
  return COLORS.agent;
967
1048
  }
968
1049
  }
969
- export function historyDisplayBlocks(history) {
970
- const blocks = [];
971
- const activeToolBlockIndexes = new Map();
972
- for (const message of history) {
973
- if (!("role" in message))
974
- continue;
975
- if (message.role === "user") {
976
- const text = userMessageText(message.content);
977
- if (text)
978
- blocks.push({ kind: "user", content: `you:\n${text}` });
979
- }
980
- else if (message.role === "assistant") {
981
- for (const block of message.content) {
982
- if (block.type === "text") {
983
- blocks.push({ kind: "agent", content: block.text });
984
- }
985
- else if (block.type === "thinking") {
986
- const trimmed = block.thinking.trim();
987
- if (trimmed)
988
- blocks.push({ kind: "reasoning", content: `[reasoning]\n${trimmed}` });
989
- }
990
- else if (block.type === "toolCall") {
991
- const input = block.arguments === undefined ? "" : `\n${formatCompactJson(block.arguments)}`;
992
- activeToolBlockIndexes.set(block.id, blocks.length);
993
- blocks.push({ kind: "tool", content: `[tool ${block.name}] ⏳${input}` });
994
- }
995
- }
996
- if (message.errorMessage) {
997
- blocks.push({ kind: "error", content: `[error]\n${message.errorMessage}` });
998
- }
999
- }
1000
- else if (message.role === "toolResult") {
1001
- const text = textFromHistoryContent(message.content);
1002
- const existingIndex = activeToolBlockIndexes.get(message.toolCallId);
1003
- const marker = message.isError ? "✗" : "✓";
1004
- const label = message.isError ? "[error]" : "[result]";
1005
- if (existingIndex !== undefined) {
1006
- const existing = blocks[existingIndex];
1007
- const [, ...inputLines] = existing.content.split("\n");
1008
- const input = inputLines.length > 0 ? `\n${inputLines.join("\n")}` : "";
1009
- existing.kind = message.isError ? "error" : "tool";
1010
- existing.content = text
1011
- ? `[tool ${message.toolName}] ${marker}${input}\n${label}\n${text}`
1012
- : `[tool ${message.toolName}] ${marker}${input}`;
1013
- activeToolBlockIndexes.delete(message.toolCallId);
1014
- }
1015
- else {
1016
- const content = text
1017
- ? `[tool ${message.toolName}] ${marker}\n${label}\n${text}`
1018
- : `[tool ${message.toolName}] ${marker}`;
1019
- blocks.push({ kind: message.isError ? "error" : "tool", content });
1020
- }
1021
- }
1022
- }
1023
- return blocks;
1024
- }
1025
- export function startupHeaderLines(input) {
1026
- const lines = [
1027
- `[duet] v${input.packageVersion}`,
1028
- `[cwd] ${input.workDir}`,
1029
- `[session] ${input.sessionId}`,
1030
- input.modelSource
1031
- ? `[model] ${input.modelName} — ${input.modelSource}`
1032
- : `[model] ${input.modelName}`,
1033
- input.memoryModelSource
1034
- ? `[memory model] ${input.memoryModelName} — ${input.memoryModelSource}`
1035
- : `[memory model] ${input.memoryModelName}`,
1036
- ];
1037
- if (input.newVersionNotice)
1038
- lines.push(input.newVersionNotice);
1039
- return lines;
1040
- }
1041
- export function limitHistoryDisplayBlocks(blocks, maxLines) {
1042
- if (maxLines <= 0)
1043
- return { blocks: [], omittedLines: countHistoryLines(blocks) };
1044
- const selected = [];
1045
- let remaining = maxLines;
1046
- let omittedLines = 0;
1047
- for (let index = blocks.length - 1; index >= 0; index--) {
1048
- const block = blocks[index];
1049
- const lines = block.content.split("\n");
1050
- if (lines.length <= remaining) {
1051
- selected.unshift(block);
1052
- remaining -= lines.length;
1053
- continue;
1054
- }
1055
- if (remaining > 0) {
1056
- selected.unshift({ ...block, content: lines.slice(-remaining).join("\n") });
1057
- omittedLines += lines.length - remaining;
1058
- remaining = 0;
1059
- }
1060
- else {
1061
- omittedLines += lines.length;
1062
- }
1063
- }
1064
- return { blocks: selected, omittedLines };
1065
- }
1066
- export function activeSkillAutocompleteToken(text, cursorOffset) {
1067
- const boundedOffset = Math.max(0, Math.min(cursorOffset, text.length));
1068
- const tokenStart = text.slice(0, boundedOffset).search(/(?:^|\s)\/[^\s]*$/);
1069
- if (tokenStart < 0)
1070
- return undefined;
1071
- const start = text[tokenStart] === "/" ? tokenStart : tokenStart + 1;
1072
- const tokenEnd = text.slice(boundedOffset).search(/\s/);
1073
- const end = tokenEnd < 0 ? text.length : boundedOffset + tokenEnd;
1074
- const token = text.slice(start, end);
1075
- const match = token.match(SKILL_AUTOCOMPLETE_TOKEN);
1076
- if (!match)
1077
- return undefined;
1078
- return { start, end, query: text.slice(start + 1, boundedOffset) };
1079
- }
1080
- export function skillAutocompleteMatches(skills, query, limit = SKILL_AUTOCOMPLETE_LIMIT) {
1081
- const normalizedQuery = query.toLocaleLowerCase();
1082
- return [...skills]
1083
- .filter((skill) => skill.name.toLocaleLowerCase().startsWith(normalizedQuery))
1084
- .sort((a, b) => a.name.localeCompare(b.name))
1085
- .slice(0, limit);
1086
- }
1087
- export function formatSkillAutocompleteItem(item) {
1088
- const path = item.path ? ` (${item.path})` : "";
1089
- const lines = [`/${item.name}${path}`, formatSkillAutocompleteDescription(item.description)];
1090
- return lines.filter((line) => line.length > 0).join("\n");
1091
- }
1092
- export function formatSkillAutocompleteDescription(description) {
1093
- if (!description)
1094
- return "";
1095
- const wrapped = wrapText(description, SKILL_AUTOCOMPLETE_DESCRIPTION_WIDTH);
1096
- const visible = wrapped.slice(0, SKILL_AUTOCOMPLETE_DESCRIPTION_LINES);
1097
- if (wrapped.length > visible.length) {
1098
- const lastIndex = visible.length - 1;
1099
- visible[lastIndex] = `${visible[lastIndex].replace(/\s+$/, "")}...`;
1100
- }
1101
- return visible.join("\n");
1102
- }
1103
- function wrapText(text, width) {
1104
- const words = text.trim().split(/\s+/);
1105
- const lines = [];
1106
- let current = "";
1107
- for (const word of words) {
1108
- if (!current) {
1109
- current = word;
1110
- continue;
1111
- }
1112
- if (current.length + 1 + word.length <= width) {
1113
- current = `${current} ${word}`;
1114
- continue;
1115
- }
1116
- lines.push(current);
1117
- current = word;
1118
- }
1119
- if (current)
1120
- lines.push(current);
1121
- return lines;
1122
- }
1123
- export function moveSkillAutocompleteSelection(selectedIndex, itemCount, direction) {
1124
- if (itemCount <= 0)
1125
- return 0;
1126
- return (selectedIndex + direction + itemCount) % itemCount;
1127
- }
1128
- export function moveQuestionOptionSelection(selectedIndex, itemCount, direction) {
1129
- if (itemCount <= 0)
1130
- return 0;
1131
- return (selectedIndex + direction + itemCount) % itemCount;
1132
- }
1133
- export function questionPickerAnswerPayload(questions, selectedIndex) {
1134
- const firstQuestion = questions[0];
1135
- const selectedOption = firstQuestion?.options[selectedIndex];
1136
- if (!firstQuestion || !selectedOption)
1137
- return undefined;
1138
- return { [firstQuestion.question]: selectedOption.label };
1139
- }
1140
- export function formatQuestionOptionDescription(description) {
1141
- if (!description)
1142
- return "";
1143
- return wrapText(description, QUESTION_OPTION_DESCRIPTION_WIDTH).join("\n");
1144
- }
1145
- export function replaceSkillAutocompleteToken(text, token, skillName) {
1146
- const insertion = text[token.end]?.match(/\s/) ? `/${skillName}` : `/${skillName} `;
1147
- const nextText = `${text.slice(0, token.start)}${insertion}${text.slice(token.end)}`;
1148
- return { text: nextText, cursorOffset: token.start + insertion.length };
1149
- }
1150
- function countHistoryLines(blocks) {
1151
- return blocks.reduce((count, block) => count + block.content.split("\n").length, 0);
1152
- }
1153
- function userMessageText(content) {
1154
- if (typeof content === "string")
1155
- return content;
1156
- return content
1157
- .filter((block) => block.type === "text" && typeof block.text === "string")
1158
- .map((block) => block.text)
1159
- .join("");
1160
- }
1161
- function textFromHistoryContent(content) {
1162
- return content
1163
- .filter((block) => block.type === "text")
1164
- .map((block) => block.text)
1165
- .join("\n");
1166
- }
1167
1050
  function restoreWindowGlobal(previousWindow) {
1168
1051
  // OpenTUI installs `window.requestAnimationFrame` for browser-style
1169
1052
  // animation compatibility. In Bun, the presence of `window` can send fetch