@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.
- package/README.md +2 -0
- package/dist/package.json +1 -1
- package/dist/src/cli/env.d.ts +18 -0
- package/dist/src/cli/env.d.ts.map +1 -0
- package/dist/src/cli/env.js +114 -0
- package/dist/src/cli/env.js.map +1 -0
- package/dist/src/cli/help.d.ts +8 -0
- package/dist/src/cli/help.d.ts.map +1 -0
- package/dist/src/cli/help.js +175 -0
- package/dist/src/cli/help.js.map +1 -0
- package/dist/src/cli/login.d.ts +13 -0
- package/dist/src/cli/login.d.ts.map +1 -0
- package/dist/src/cli/login.js +61 -0
- package/dist/src/cli/login.js.map +1 -0
- package/dist/src/cli/memories-db.d.ts +24 -0
- package/dist/src/cli/memories-db.d.ts.map +1 -0
- package/dist/src/cli/memories-db.js +74 -0
- package/dist/src/cli/memories-db.js.map +1 -0
- package/dist/src/cli/memories-tui.d.ts +11 -0
- package/dist/src/cli/memories-tui.d.ts.map +1 -0
- package/dist/src/cli/memories-tui.js +266 -0
- package/dist/src/cli/memories-tui.js.map +1 -0
- package/dist/src/cli/memories.d.ts +9 -0
- package/dist/src/cli/memories.d.ts.map +1 -0
- package/dist/src/cli/memories.js +38 -0
- package/dist/src/cli/memories.js.map +1 -0
- package/dist/src/cli/package-manager.d.ts +38 -0
- package/dist/src/cli/package-manager.d.ts.map +1 -0
- package/dist/src/cli/package-manager.js +78 -0
- package/dist/src/cli/package-manager.js.map +1 -0
- package/dist/src/cli/resume-hint.d.ts +22 -0
- package/dist/src/cli/resume-hint.d.ts.map +1 -0
- package/dist/src/cli/resume-hint.js +61 -0
- package/dist/src/cli/resume-hint.js.map +1 -0
- package/dist/src/cli/run.d.ts +43 -0
- package/dist/src/cli/run.d.ts.map +1 -0
- package/dist/src/cli/run.js +273 -0
- package/dist/src/cli/run.js.map +1 -0
- package/dist/src/cli/shared.d.ts +55 -0
- package/dist/src/cli/shared.d.ts.map +1 -0
- package/dist/src/cli/shared.js +125 -0
- package/dist/src/cli/shared.js.map +1 -0
- package/dist/src/cli/skills.d.ts +10 -0
- package/dist/src/cli/skills.d.ts.map +1 -0
- package/dist/src/cli/skills.js +42 -0
- package/dist/src/cli/skills.js.map +1 -0
- package/dist/src/cli/upgrade.d.ts +9 -0
- package/dist/src/cli/upgrade.d.ts.map +1 -0
- package/dist/src/cli/upgrade.js +52 -0
- package/dist/src/cli/upgrade.js.map +1 -0
- package/dist/src/cli/version-check.d.ts +22 -0
- package/dist/src/cli/version-check.d.ts.map +1 -0
- package/dist/src/cli/version-check.js +78 -0
- package/dist/src/cli/version-check.js.map +1 -0
- package/dist/src/cli.d.ts +20 -63
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +38 -887
- package/dist/src/cli.js.map +1 -1
- package/dist/src/memory/observational-prompts.d.ts.map +1 -1
- package/dist/src/memory/observational-prompts.js +11 -7
- package/dist/src/memory/observational-prompts.js.map +1 -1
- package/dist/src/tui/app.d.ts +7 -47
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +279 -396
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/autocomplete.d.ts +91 -0
- package/dist/src/tui/autocomplete.d.ts.map +1 -0
- package/dist/src/tui/autocomplete.js +177 -0
- package/dist/src/tui/autocomplete.js.map +1 -0
- package/dist/src/tui/file-index.d.ts +11 -0
- package/dist/src/tui/file-index.d.ts.map +1 -0
- package/dist/src/tui/file-index.js +75 -0
- package/dist/src/tui/file-index.js.map +1 -0
- package/dist/src/tui/history.d.ts +50 -0
- package/dist/src/tui/history.d.ts.map +1 -0
- package/dist/src/tui/history.js +132 -0
- package/dist/src/tui/history.js.map +1 -0
- package/dist/src/tui/sidebar.d.ts +20 -0
- package/dist/src/tui/sidebar.d.ts.map +1 -0
- package/dist/src/tui/sidebar.js +118 -0
- package/dist/src/tui/sidebar.js.map +1 -0
- package/dist/src/tui/theme.d.ts +15 -0
- package/dist/src/tui/theme.d.ts.map +1 -0
- package/dist/src/tui/theme.js +18 -0
- package/dist/src/tui/theme.js.map +1 -0
- package/dist/src/turn-runner/prompts.d.ts.map +1 -1
- package/dist/src/turn-runner/prompts.js +7 -0
- package/dist/src/turn-runner/prompts.js.map +1 -1
- package/dist/src/turn-runner/tools.d.ts +15 -1
- package/dist/src/turn-runner/tools.d.ts.map +1 -1
- package/dist/src/turn-runner/tools.js +42 -9
- package/dist/src/turn-runner/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/src/tui/app.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
885
|
-
inputField.onCursorChange = () =>
|
|
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
|
-
|
|
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
|