@duetso/agent 0.1.34 → 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 (92) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/cli/env.d.ts +18 -0
  3. package/dist/src/cli/env.d.ts.map +1 -0
  4. package/dist/src/cli/env.js +114 -0
  5. package/dist/src/cli/env.js.map +1 -0
  6. package/dist/src/cli/help.d.ts +8 -0
  7. package/dist/src/cli/help.d.ts.map +1 -0
  8. package/dist/src/cli/help.js +175 -0
  9. package/dist/src/cli/help.js.map +1 -0
  10. package/dist/src/cli/login.d.ts +13 -0
  11. package/dist/src/cli/login.d.ts.map +1 -0
  12. package/dist/src/cli/login.js +61 -0
  13. package/dist/src/cli/login.js.map +1 -0
  14. package/dist/src/cli/memories-db.d.ts +24 -0
  15. package/dist/src/cli/memories-db.d.ts.map +1 -0
  16. package/dist/src/cli/memories-db.js +74 -0
  17. package/dist/src/cli/memories-db.js.map +1 -0
  18. package/dist/src/cli/memories-tui.d.ts +11 -0
  19. package/dist/src/cli/memories-tui.d.ts.map +1 -0
  20. package/dist/src/cli/memories-tui.js +266 -0
  21. package/dist/src/cli/memories-tui.js.map +1 -0
  22. package/dist/src/cli/memories.d.ts +9 -0
  23. package/dist/src/cli/memories.d.ts.map +1 -0
  24. package/dist/src/cli/memories.js +38 -0
  25. package/dist/src/cli/memories.js.map +1 -0
  26. package/dist/src/cli/package-manager.d.ts +38 -0
  27. package/dist/src/cli/package-manager.d.ts.map +1 -0
  28. package/dist/src/cli/package-manager.js +78 -0
  29. package/dist/src/cli/package-manager.js.map +1 -0
  30. package/dist/src/cli/resume-hint.d.ts +22 -0
  31. package/dist/src/cli/resume-hint.d.ts.map +1 -0
  32. package/dist/src/cli/resume-hint.js +61 -0
  33. package/dist/src/cli/resume-hint.js.map +1 -0
  34. package/dist/src/cli/run.d.ts +43 -0
  35. package/dist/src/cli/run.d.ts.map +1 -0
  36. package/dist/src/cli/run.js +273 -0
  37. package/dist/src/cli/run.js.map +1 -0
  38. package/dist/src/cli/shared.d.ts +55 -0
  39. package/dist/src/cli/shared.d.ts.map +1 -0
  40. package/dist/src/cli/shared.js +125 -0
  41. package/dist/src/cli/shared.js.map +1 -0
  42. package/dist/src/cli/skills.d.ts +10 -0
  43. package/dist/src/cli/skills.d.ts.map +1 -0
  44. package/dist/src/cli/skills.js +42 -0
  45. package/dist/src/cli/skills.js.map +1 -0
  46. package/dist/src/cli/upgrade.d.ts +9 -0
  47. package/dist/src/cli/upgrade.d.ts.map +1 -0
  48. package/dist/src/cli/upgrade.js +52 -0
  49. package/dist/src/cli/upgrade.js.map +1 -0
  50. package/dist/src/cli/version-check.d.ts +22 -0
  51. package/dist/src/cli/version-check.d.ts.map +1 -0
  52. package/dist/src/cli/version-check.js +78 -0
  53. package/dist/src/cli/version-check.js.map +1 -0
  54. package/dist/src/cli.d.ts +20 -63
  55. package/dist/src/cli.d.ts.map +1 -1
  56. package/dist/src/cli.js +38 -887
  57. package/dist/src/cli.js.map +1 -1
  58. package/dist/src/memory/observational-prompts.d.ts.map +1 -1
  59. package/dist/src/memory/observational-prompts.js +11 -7
  60. package/dist/src/memory/observational-prompts.js.map +1 -1
  61. package/dist/src/tui/app.d.ts +7 -47
  62. package/dist/src/tui/app.d.ts.map +1 -1
  63. package/dist/src/tui/app.js +204 -389
  64. package/dist/src/tui/app.js.map +1 -1
  65. package/dist/src/tui/autocomplete.d.ts +91 -0
  66. package/dist/src/tui/autocomplete.d.ts.map +1 -0
  67. package/dist/src/tui/autocomplete.js +177 -0
  68. package/dist/src/tui/autocomplete.js.map +1 -0
  69. package/dist/src/tui/file-index.d.ts +11 -0
  70. package/dist/src/tui/file-index.d.ts.map +1 -0
  71. package/dist/src/tui/file-index.js +75 -0
  72. package/dist/src/tui/file-index.js.map +1 -0
  73. package/dist/src/tui/history.d.ts +50 -0
  74. package/dist/src/tui/history.d.ts.map +1 -0
  75. package/dist/src/tui/history.js +132 -0
  76. package/dist/src/tui/history.js.map +1 -0
  77. package/dist/src/tui/sidebar.d.ts +20 -0
  78. package/dist/src/tui/sidebar.d.ts.map +1 -0
  79. package/dist/src/tui/sidebar.js +118 -0
  80. package/dist/src/tui/sidebar.js.map +1 -0
  81. package/dist/src/tui/theme.d.ts +15 -0
  82. package/dist/src/tui/theme.d.ts.map +1 -0
  83. package/dist/src/tui/theme.js +18 -0
  84. package/dist/src/tui/theme.js.map +1 -0
  85. package/dist/src/turn-runner/prompts.d.ts.map +1 -1
  86. package/dist/src/turn-runner/prompts.js +7 -0
  87. package/dist/src/turn-runner/prompts.js.map +1 -1
  88. package/dist/src/turn-runner/tools.d.ts +15 -1
  89. package/dist/src/turn-runner/tools.d.ts.map +1 -1
  90. package/dist/src/turn-runner/tools.js +42 -9
  91. package/dist/src/turn-runner/tools.js.map +1 -1
  92. 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();
@@ -387,80 +334,11 @@ export async function runTui(input) {
387
334
  // ---- session subscription --------------------------------------------------
388
335
  function refreshSidebar() {
389
336
  const state = input.session.getState();
390
- renderTodoSidebar(state?.todos ?? []);
391
- renderStateMachineSidebar(state?.stateMachine);
392
- renderContextUsageSidebar(latestContextUsage);
393
- }
394
- function renderTodoSidebar(todos) {
395
- if (todos.length === 0) {
396
- todoBody.content = "(none)";
397
- todoBody.fg = COLORS.hint;
398
- return;
399
- }
400
- const lines = todos.map((todo) => `${todoStatusGlyph(todo.status)} ${todo.content}`);
401
- todoBody.content = lines.join("\n");
402
- todoBody.fg = COLORS.agent;
403
- }
404
- function todoStatusGlyph(status) {
405
- if (status === "completed")
406
- return "✓";
407
- if (status === "in_progress")
408
- return "●";
409
- if (status === "failed")
410
- return "✗";
411
- return "○";
412
- }
413
- function renderStateMachineSidebar(session) {
414
- if (!session) {
415
- smBody.content = "(inactive)";
416
- smBody.fg = COLORS.hint;
417
- return;
418
- }
419
- const current = session.currentState;
420
- const lines = session.definition.states.map((state) => {
421
- const marker = state.name === current ? "▶" : " ";
422
- return `${marker} ${state.name}`;
423
- });
424
- if (session.terminal) {
425
- lines.push("", `terminal: ${session.terminal.status}`);
426
- }
427
- smBody.content = lines.join("\n");
428
- smBody.fg = COLORS.agent;
429
- }
430
- function renderContextUsageSidebar(usage) {
431
- if (!usage) {
432
- contextBody.content = "(waiting for usage)";
433
- contextBody.fg = COLORS.hint;
434
- return;
435
- }
436
- const usedTokens = usage.usage.totalTokens;
437
- const percent = Math.min(1, usedTokens / usage.contextWindow);
438
- contextBody.content = [
439
- progressBar(percent, 25),
440
- `${formatTokenCount(usedTokens)} / ${formatTokenCount(usage.contextWindow)}`,
441
- ].join("\n");
442
- contextBody.fg = usedTokens >= usage.contextWindow ? COLORS.error : COLORS.agent;
443
- }
444
- function progressBar(value, width) {
445
- const clamped = Math.max(0, Math.min(1, value));
446
- const filled = Math.round(clamped * width);
447
- const empty = width - filled;
448
- return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${`${Math.round(clamped * 100)}%`.padStart(4)}`;
449
- }
450
- function formatTokenCount(tokens) {
451
- if (tokens >= 1_000_000)
452
- return `${formatCompactNumber(tokens / 1_000_000)}m`;
453
- if (tokens >= 1_000)
454
- return `${formatCompactNumber(tokens / 1_000)}k`;
455
- return String(tokens);
456
- }
457
- function formatCompactNumber(value) {
458
- const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
459
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
337
+ sidebar.setTodos(state?.todos ?? []);
338
+ sidebar.setStateMachine(state?.stateMachine);
339
+ sidebar.setContextUsage(latestContextUsage);
460
340
  }
461
341
  const unsubscribe = input.session.subscribe((event) => {
462
- // Sidebar mirrors the runner's authoritative state, so refresh it on
463
- // every event rather than threading specific updates through each branch.
464
342
  refreshSidebar();
465
343
  if (event.type === "step") {
466
344
  renderStep(event.step);
@@ -476,7 +354,7 @@ export async function runTui(input) {
476
354
  }
477
355
  else if (event.type === "context_usage") {
478
356
  latestContextUsage = event;
479
- renderContextUsageSidebar(event);
357
+ sidebar.setContextUsage(event);
480
358
  }
481
359
  else if (event.type === "system") {
482
360
  appendBlock("[system]", event.message, COLORS.system);
@@ -487,6 +365,7 @@ export async function runTui(input) {
487
365
  appendBlock("[question]", event.questions.map((q) => q.question).join("\n"), COLORS.system);
488
366
  showQuestions(event.questions);
489
367
  renderUsage(event.usage);
368
+ renderTurnElapsed();
490
369
  lastTerminal = event;
491
370
  markIdle();
492
371
  }
@@ -494,24 +373,22 @@ export async function runTui(input) {
494
373
  if (event.error) {
495
374
  appendBlock("[error]", event.error, COLORS.error);
496
375
  }
497
- else if (event.result) {
498
- // Result is also normally streamed via text steps; only show if no streaming happened
499
- // for this turn (cheap heuristic: empty transcript-since-last-prompt).
500
- // Always-append is fine too — duplicate text is harmless and clearer for short turns.
501
- }
502
376
  renderUsage(event.usage);
377
+ renderTurnElapsed();
503
378
  lastTerminal = event;
504
379
  markIdle();
505
380
  }
506
381
  else if (event.type === "interrupted") {
507
382
  appendLine("[interrupted]", COLORS.system);
508
383
  renderUsage(event.usage);
384
+ renderTurnElapsed();
509
385
  lastTerminal = event;
510
386
  markIdle();
511
387
  }
512
388
  else if (event.type === "sleep") {
513
389
  appendLine(`[sleeping until ${new Date(event.wakeAt).toLocaleTimeString()}]`, COLORS.system);
514
390
  renderUsage(event.usage);
391
+ renderTurnElapsed();
515
392
  lastTerminal = event;
516
393
  markIdle();
517
394
  }
@@ -545,6 +422,11 @@ export async function runTui(input) {
545
422
  const cost = usage.cost.total === 0 ? "" : ` · Cost: $${usage.cost.total.toFixed(4)}`;
546
423
  appendLine(`[usage] Tokens: ${parts.join(" ")}${cost}`, COLORS.hint);
547
424
  }
425
+ function renderTurnElapsed() {
426
+ if (workingStartedAt === undefined)
427
+ return;
428
+ appendLine(`● turn finished in ${formatElapsed(Date.now() - workingStartedAt)}`, COLORS.status);
429
+ }
548
430
  function renderTodos(todos) {
549
431
  if (todos.length === 0) {
550
432
  appendBlock("[todos]", "No todos", COLORS.hint);
@@ -702,6 +584,14 @@ export async function runTui(input) {
702
584
  let skillAutocompleteToken;
703
585
  let skillAutocompleteItems = [];
704
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;
705
595
  let pendingQuestions = [];
706
596
  let questionOptionSelectedIndex = 0;
707
597
  let suppressNextEscapeExit = false;
@@ -731,6 +621,9 @@ export async function runTui(input) {
731
621
  function skillAutocompleteIsOpen() {
732
622
  return Boolean(skillAutocompleteToken && skillAutocompleteItems.length > 0);
733
623
  }
624
+ function fileAutocompleteIsOpen() {
625
+ return Boolean(fileAutocompleteToken && fileAutocompleteItems.length > 0);
626
+ }
734
627
  function questionPickerIsOpen() {
735
628
  const question = pendingQuestions[0];
736
629
  return Boolean(question && question.options.length > 0);
@@ -745,6 +638,16 @@ export async function runTui(input) {
745
638
  row.content = "";
746
639
  }
747
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
+ }
748
651
  function hideQuestions() {
749
652
  pendingQuestions = [];
750
653
  questionOptionSelectedIndex = 0;
@@ -799,6 +702,19 @@ export async function runTui(input) {
799
702
  markRunning();
800
703
  return true;
801
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
+ }
802
718
  function refreshSkillAutocomplete() {
803
719
  const token = activeSkillAutocompleteToken(inputField.plainText, inputField.cursorOffset);
804
720
  if (!token) {
@@ -822,6 +738,44 @@ export async function runTui(input) {
822
738
  }
823
739
  renderSkillAutocomplete();
824
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
+ }
825
779
  function renderSkillAutocomplete() {
826
780
  skillAutocompletePanel.visible = skillAutocompleteItems.length > 0;
827
781
  for (const [index, row] of skillAutocompleteRows.entries()) {
@@ -842,6 +796,30 @@ export async function runTui(input) {
842
796
  row.visible = true;
843
797
  }
844
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
+ }
845
823
  function completeSelectedSkillAutocomplete() {
846
824
  const token = skillAutocompleteToken;
847
825
  const item = skillAutocompleteItems[skillAutocompleteSelectedIndex];
@@ -856,6 +834,20 @@ export async function runTui(input) {
856
834
  hideSkillAutocomplete();
857
835
  return true;
858
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
+ }
859
851
  const keyHandler = renderer._keyHandler;
860
852
  keyHandler.onInternal("keypress", (key) => {
861
853
  if (key.name !== "escape")
@@ -870,6 +862,11 @@ export async function runTui(input) {
870
862
  hideSkillAutocomplete();
871
863
  return;
872
864
  }
865
+ if (fileAutocompleteIsOpen()) {
866
+ key.preventDefault();
867
+ hideFileAutocomplete();
868
+ return;
869
+ }
873
870
  if (questionPickerIsOpen()) {
874
871
  key.preventDefault();
875
872
  hideQuestions();
@@ -895,7 +892,7 @@ export async function runTui(input) {
895
892
  key.preventDefault();
896
893
  return;
897
894
  }
898
- if (key.name === "return" || key.name === "enter") {
895
+ if (key.name === "return" || key.name === "enter" || key.name === "tab") {
899
896
  key.preventDefault();
900
897
  completeSelectedSkillAutocomplete();
901
898
  return;
@@ -907,6 +904,31 @@ export async function runTui(input) {
907
904
  return;
908
905
  }
909
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
+ }
910
932
  if (questionPickerIsOpen()) {
911
933
  if (key.name === "up") {
912
934
  questionOptionSelectedIndex = moveQuestionOptionSelection(questionOptionSelectedIndex, Math.min(pendingQuestions[0]?.options.length ?? 0, QUESTION_OPTION_LIMIT), -1);
@@ -929,9 +951,6 @@ export async function runTui(input) {
929
951
  }
930
952
  if (key.name === "return" || key.name === "enter") {
931
953
  lastEnterShift = Boolean(key.shift);
932
- // Take over Enter so the textarea does not insert a newline. We submit
933
- // the current buffer contents and reset, regardless of shift state —
934
- // shift only differentiates steer vs. queued follow-up.
935
954
  const value = inputField.plainText.trim();
936
955
  inputField.clear();
937
956
  key.preventDefault();
@@ -949,20 +968,16 @@ export async function runTui(input) {
949
968
  return;
950
969
  }
951
970
  };
952
- inputField.onContentChange = () => refreshSkillAutocomplete();
953
- inputField.onCursorChange = () => refreshSkillAutocomplete();
971
+ inputField.onContentChange = () => refreshAutocomplete();
972
+ inputField.onCursorChange = () => refreshAutocomplete();
954
973
  function submit(message, shiftEnter) {
955
974
  appendBlock("you:", message, COLORS.user);
956
975
  hideQuestions();
957
976
  if (running) {
958
- // Mid-turn: Enter → steer, Shift+Enter → queued follow-up.
959
977
  const behavior = shiftEnter ? "follow_up" : "steer";
960
978
  void input.session.prompt({ message, behavior }).catch(reportError);
961
- // Keep status as "working"; the existing turn continues.
962
979
  return;
963
980
  }
964
- // Idle: dispatch a prompt against the already-set-up session. Setup
965
- // happens before the TUI starts so skills are visible right away.
966
981
  void input.session.prompt({ message, behavior: "follow_up" }).catch(reportError);
967
982
  markRunning();
968
983
  }
@@ -978,7 +993,7 @@ export async function runTui(input) {
978
993
  description: skill.description,
979
994
  path: skill.baseDir,
980
995
  }));
981
- refreshSkillAutocomplete();
996
+ refreshAutocomplete();
982
997
  renderSetupIntro(skills, agentFiles);
983
998
  refreshSidebar();
984
999
  const resumeHistoryLines = input.resumeHistoryLines ?? Number.POSITIVE_INFINITY;
@@ -1001,8 +1016,6 @@ export async function runTui(input) {
1001
1016
  markRunning();
1002
1017
  }
1003
1018
  else {
1004
- // No initial prompt — wait for the user. Setup already ran above, so
1005
- // the skill summary is rendered before the user types.
1006
1019
  markIdle();
1007
1020
  }
1008
1021
  // ---- run renderer until the user quits -------------------------------------
@@ -1034,204 +1047,6 @@ export async function runTui(input) {
1034
1047
  return COLORS.agent;
1035
1048
  }
1036
1049
  }
1037
- export function historyDisplayBlocks(history) {
1038
- const blocks = [];
1039
- const activeToolBlockIndexes = new Map();
1040
- for (const message of history) {
1041
- if (!("role" in message))
1042
- continue;
1043
- if (message.role === "user") {
1044
- const text = userMessageText(message.content);
1045
- if (text)
1046
- blocks.push({ kind: "user", content: `you:\n${text}` });
1047
- }
1048
- else if (message.role === "assistant") {
1049
- for (const block of message.content) {
1050
- if (block.type === "text") {
1051
- blocks.push({ kind: "agent", content: block.text });
1052
- }
1053
- else if (block.type === "thinking") {
1054
- const trimmed = block.thinking.trim();
1055
- if (trimmed)
1056
- blocks.push({ kind: "reasoning", content: `[reasoning]\n${trimmed}` });
1057
- }
1058
- else if (block.type === "toolCall") {
1059
- const input = block.arguments === undefined ? "" : `\n${formatCompactJson(block.arguments)}`;
1060
- activeToolBlockIndexes.set(block.id, blocks.length);
1061
- blocks.push({ kind: "tool", content: `[tool ${block.name}] ⏳${input}` });
1062
- }
1063
- }
1064
- if (message.errorMessage) {
1065
- blocks.push({ kind: "error", content: `[error]\n${message.errorMessage}` });
1066
- }
1067
- }
1068
- else if (message.role === "toolResult") {
1069
- const text = textFromHistoryContent(message.content);
1070
- const existingIndex = activeToolBlockIndexes.get(message.toolCallId);
1071
- const marker = message.isError ? "✗" : "✓";
1072
- const label = message.isError ? "[error]" : "[result]";
1073
- if (existingIndex !== undefined) {
1074
- const existing = blocks[existingIndex];
1075
- const [, ...inputLines] = existing.content.split("\n");
1076
- const input = inputLines.length > 0 ? `\n${inputLines.join("\n")}` : "";
1077
- existing.kind = message.isError ? "error" : "tool";
1078
- existing.content = text
1079
- ? `[tool ${message.toolName}] ${marker}${input}\n${label}\n${text}`
1080
- : `[tool ${message.toolName}] ${marker}${input}`;
1081
- activeToolBlockIndexes.delete(message.toolCallId);
1082
- }
1083
- else {
1084
- const content = text
1085
- ? `[tool ${message.toolName}] ${marker}\n${label}\n${text}`
1086
- : `[tool ${message.toolName}] ${marker}`;
1087
- blocks.push({ kind: message.isError ? "error" : "tool", content });
1088
- }
1089
- }
1090
- }
1091
- return blocks;
1092
- }
1093
- export function startupHeaderLines(input) {
1094
- const lines = [
1095
- `[duet] v${input.packageVersion}`,
1096
- `[cwd] ${input.workDir}`,
1097
- `[session] ${input.sessionId}`,
1098
- input.modelSource
1099
- ? `[model] ${input.modelName} — ${input.modelSource}`
1100
- : `[model] ${input.modelName}`,
1101
- input.memoryModelSource
1102
- ? `[memory model] ${input.memoryModelName} — ${input.memoryModelSource}`
1103
- : `[memory model] ${input.memoryModelName}`,
1104
- ];
1105
- if (input.newVersionNotice)
1106
- lines.push(input.newVersionNotice);
1107
- return lines;
1108
- }
1109
- export function limitHistoryDisplayBlocks(blocks, maxLines) {
1110
- if (maxLines <= 0)
1111
- return { blocks: [], omittedLines: countHistoryLines(blocks) };
1112
- const selected = [];
1113
- let remaining = maxLines;
1114
- let omittedLines = 0;
1115
- for (let index = blocks.length - 1; index >= 0; index--) {
1116
- const block = blocks[index];
1117
- const lines = block.content.split("\n");
1118
- if (lines.length <= remaining) {
1119
- selected.unshift(block);
1120
- remaining -= lines.length;
1121
- continue;
1122
- }
1123
- if (remaining > 0) {
1124
- selected.unshift({ ...block, content: lines.slice(-remaining).join("\n") });
1125
- omittedLines += lines.length - remaining;
1126
- remaining = 0;
1127
- }
1128
- else {
1129
- omittedLines += lines.length;
1130
- }
1131
- }
1132
- return { blocks: selected, omittedLines };
1133
- }
1134
- export function activeSkillAutocompleteToken(text, cursorOffset) {
1135
- const boundedOffset = Math.max(0, Math.min(cursorOffset, text.length));
1136
- const tokenStart = text.slice(0, boundedOffset).search(/(?:^|\s)\/[^\s]*$/);
1137
- if (tokenStart < 0)
1138
- return undefined;
1139
- const start = text[tokenStart] === "/" ? tokenStart : tokenStart + 1;
1140
- const tokenEnd = text.slice(boundedOffset).search(/\s/);
1141
- const end = tokenEnd < 0 ? text.length : boundedOffset + tokenEnd;
1142
- const token = text.slice(start, end);
1143
- const match = token.match(SKILL_AUTOCOMPLETE_TOKEN);
1144
- if (!match)
1145
- return undefined;
1146
- return { start, end, query: text.slice(start + 1, boundedOffset) };
1147
- }
1148
- export function skillAutocompleteMatches(skills, query, limit = SKILL_AUTOCOMPLETE_LIMIT) {
1149
- const normalizedQuery = query.toLocaleLowerCase();
1150
- return [...skills]
1151
- .filter((skill) => skill.name.toLocaleLowerCase().startsWith(normalizedQuery))
1152
- .sort((a, b) => a.name.localeCompare(b.name))
1153
- .slice(0, limit);
1154
- }
1155
- export function formatSkillAutocompleteItem(item) {
1156
- const path = item.path ? ` (${item.path})` : "";
1157
- const lines = [`/${item.name}${path}`, formatSkillAutocompleteDescription(item.description)];
1158
- return lines.filter((line) => line.length > 0).join("\n");
1159
- }
1160
- export function formatSkillAutocompleteDescription(description) {
1161
- if (!description)
1162
- return "";
1163
- const wrapped = wrapText(description, SKILL_AUTOCOMPLETE_DESCRIPTION_WIDTH);
1164
- const visible = wrapped.slice(0, SKILL_AUTOCOMPLETE_DESCRIPTION_LINES);
1165
- if (wrapped.length > visible.length) {
1166
- const lastIndex = visible.length - 1;
1167
- visible[lastIndex] = `${visible[lastIndex].replace(/\s+$/, "")}...`;
1168
- }
1169
- return visible.join("\n");
1170
- }
1171
- function wrapText(text, width) {
1172
- const words = text.trim().split(/\s+/);
1173
- const lines = [];
1174
- let current = "";
1175
- for (const word of words) {
1176
- if (!current) {
1177
- current = word;
1178
- continue;
1179
- }
1180
- if (current.length + 1 + word.length <= width) {
1181
- current = `${current} ${word}`;
1182
- continue;
1183
- }
1184
- lines.push(current);
1185
- current = word;
1186
- }
1187
- if (current)
1188
- lines.push(current);
1189
- return lines;
1190
- }
1191
- export function moveSkillAutocompleteSelection(selectedIndex, itemCount, direction) {
1192
- if (itemCount <= 0)
1193
- return 0;
1194
- return (selectedIndex + direction + itemCount) % itemCount;
1195
- }
1196
- export function moveQuestionOptionSelection(selectedIndex, itemCount, direction) {
1197
- if (itemCount <= 0)
1198
- return 0;
1199
- return (selectedIndex + direction + itemCount) % itemCount;
1200
- }
1201
- export function questionPickerAnswerPayload(questions, selectedIndex) {
1202
- const firstQuestion = questions[0];
1203
- const selectedOption = firstQuestion?.options[selectedIndex];
1204
- if (!firstQuestion || !selectedOption)
1205
- return undefined;
1206
- return { [firstQuestion.question]: selectedOption.label };
1207
- }
1208
- export function formatQuestionOptionDescription(description) {
1209
- if (!description)
1210
- return "";
1211
- return wrapText(description, QUESTION_OPTION_DESCRIPTION_WIDTH).join("\n");
1212
- }
1213
- export function replaceSkillAutocompleteToken(text, token, skillName) {
1214
- const insertion = text[token.end]?.match(/\s/) ? `/${skillName}` : `/${skillName} `;
1215
- const nextText = `${text.slice(0, token.start)}${insertion}${text.slice(token.end)}`;
1216
- return { text: nextText, cursorOffset: token.start + insertion.length };
1217
- }
1218
- function countHistoryLines(blocks) {
1219
- return blocks.reduce((count, block) => count + block.content.split("\n").length, 0);
1220
- }
1221
- function userMessageText(content) {
1222
- if (typeof content === "string")
1223
- return content;
1224
- return content
1225
- .filter((block) => block.type === "text" && typeof block.text === "string")
1226
- .map((block) => block.text)
1227
- .join("");
1228
- }
1229
- function textFromHistoryContent(content) {
1230
- return content
1231
- .filter((block) => block.type === "text")
1232
- .map((block) => block.text)
1233
- .join("\n");
1234
- }
1235
1050
  function restoreWindowGlobal(previousWindow) {
1236
1051
  // OpenTUI installs `window.requestAnimationFrame` for browser-style
1237
1052
  // animation compatibility. In Bun, the presence of `window` can send fetch