@agi-fans/dsh-tui 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/CHANGELOG.md +115 -1
- package/lib/chrome/box.d.ts +2 -0
- package/lib/chrome/box.js +6 -12
- package/lib/chrome/box.js.map +1 -1
- package/lib/chrome/code-highlight.d.ts +26 -18
- package/lib/chrome/code-highlight.js +114 -39
- package/lib/chrome/code-highlight.js.map +1 -1
- package/lib/chrome/diff-render.d.ts +1 -2
- package/lib/chrome/diff-render.js +41 -90
- package/lib/chrome/diff-render.js.map +1 -1
- package/lib/chrome/main-screen-renderer.d.ts +52 -30
- package/lib/chrome/main-screen-renderer.js +1730 -79
- package/lib/chrome/main-screen-renderer.js.map +1 -1
- package/lib/chrome/markdown.js +144 -101
- package/lib/chrome/markdown.js.map +1 -1
- package/lib/chrome/math.d.ts +4 -0
- package/lib/chrome/math.js +340 -0
- package/lib/chrome/math.js.map +1 -0
- package/lib/chrome/md-style.d.ts +3 -0
- package/lib/chrome/md-style.js.map +1 -1
- package/lib/chrome/mermaid.d.ts +6 -0
- package/lib/chrome/mermaid.js +56 -0
- package/lib/chrome/mermaid.js.map +1 -0
- package/lib/chrome/renderer.d.ts +78 -0
- package/lib/chrome/renderer.js.map +1 -1
- package/lib/chrome/status-config.d.ts +5 -0
- package/lib/chrome/status-config.js +3 -0
- package/lib/chrome/status-config.js.map +1 -1
- package/lib/chrome/status-line.d.ts +0 -21
- package/lib/chrome/status-line.js +8 -65
- package/lib/chrome/status-line.js.map +1 -1
- package/lib/chrome/terminal-images.d.ts +31 -0
- package/lib/chrome/terminal-images.js +34 -0
- package/lib/chrome/terminal-images.js.map +1 -0
- package/lib/chrome/theme.d.ts +23 -6
- package/lib/chrome/theme.js +217 -109
- package/lib/chrome/theme.js.map +1 -1
- package/lib/chrome/tool-args.d.ts +24 -1
- package/lib/chrome/tool-args.js +40 -1
- package/lib/chrome/tool-args.js.map +1 -1
- package/lib/chrome/tool-renderers.d.ts +11 -0
- package/lib/chrome/tool-renderers.js +55 -5
- package/lib/chrome/tool-renderers.js.map +1 -1
- package/lib/chrome/welcome-tips.js +2 -2
- package/lib/chrome/welcome-tips.js.map +1 -1
- package/lib/chrome/width.d.ts +7 -3
- package/lib/chrome/width.js +78 -14
- package/lib/chrome/width.js.map +1 -1
- package/lib/commands/diff.js +49 -3
- package/lib/commands/diff.js.map +1 -1
- package/lib/commands/export.js +28 -5
- package/lib/commands/export.js.map +1 -1
- package/lib/commands/files.d.ts +8 -0
- package/lib/commands/files.js +137 -0
- package/lib/commands/files.js.map +1 -0
- package/lib/commands/plugins.d.ts +8 -0
- package/lib/commands/plugins.js +158 -0
- package/lib/commands/plugins.js.map +1 -0
- package/lib/commands/session-configuration.d.ts +4 -0
- package/lib/commands/session-configuration.js +39 -8
- package/lib/commands/session-configuration.js.map +1 -1
- package/lib/commands/session.js +47 -15
- package/lib/commands/session.js.map +1 -1
- package/lib/commands/steer.js +2 -2
- package/lib/commands/steer.js.map +1 -1
- package/lib/commands/terminal.d.ts +7 -0
- package/lib/commands/terminal.js +99 -0
- package/lib/commands/terminal.js.map +1 -0
- package/lib/definition.d.ts +180 -14
- package/lib/definition.js.map +1 -1
- package/lib/input/clipboard.d.ts +12 -7
- package/lib/input/clipboard.js +79 -20
- package/lib/input/clipboard.js.map +1 -1
- package/lib/input/composer-files.d.ts +26 -0
- package/lib/input/composer-files.js +50 -0
- package/lib/input/composer-files.js.map +1 -0
- package/lib/input/editor-discovery.d.ts +21 -0
- package/lib/input/editor-discovery.js +99 -0
- package/lib/input/editor-discovery.js.map +1 -0
- package/lib/input/editor.d.ts +26 -1
- package/lib/input/editor.js +126 -12
- package/lib/input/editor.js.map +1 -1
- package/lib/input/external-editor.d.ts +5 -2
- package/lib/input/external-editor.js +14 -12
- package/lib/input/external-editor.js.map +1 -1
- package/lib/input/fuzzy-search.d.ts +9 -0
- package/lib/input/fuzzy-search.js +113 -0
- package/lib/input/fuzzy-search.js.map +1 -0
- package/lib/input/keybindings-config.d.ts +1 -1
- package/lib/input/keybindings-config.js +7 -1
- package/lib/input/keybindings-config.js.map +1 -1
- package/lib/input/keys.d.ts +17 -0
- package/lib/input/keys.js +21 -5
- package/lib/input/keys.js.map +1 -1
- package/lib/input/mouse-policy.d.ts +3 -0
- package/lib/input/mouse-policy.js +14 -0
- package/lib/input/mouse-policy.js.map +1 -0
- package/lib/input/paste-burst.d.ts +16 -0
- package/lib/input/paste-burst.js +61 -0
- package/lib/input/paste-burst.js.map +1 -0
- package/lib/input/text-selection.d.ts +20 -0
- package/lib/input/text-selection.js +195 -0
- package/lib/input/text-selection.js.map +1 -0
- package/lib/runtime/composer-images.d.ts +1 -0
- package/lib/runtime/composer-images.js +11 -3
- package/lib/runtime/composer-images.js.map +1 -1
- package/lib/runtime/file-open.d.ts +2 -0
- package/lib/runtime/file-open.js +12 -0
- package/lib/runtime/file-open.js.map +1 -0
- package/lib/runtime/file-review.d.ts +24 -0
- package/lib/runtime/file-review.js +188 -0
- package/lib/runtime/file-review.js.map +1 -0
- package/lib/runtime/herdr-agent.d.ts +7 -15
- package/lib/runtime/herdr-agent.js +151 -79
- package/lib/runtime/herdr-agent.js.map +1 -1
- package/lib/runtime/hook-settings.d.ts +17 -0
- package/lib/runtime/hook-settings.js +57 -0
- package/lib/runtime/hook-settings.js.map +1 -0
- package/lib/runtime/human-interaction.js +1 -1
- package/lib/runtime/human-interaction.js.map +1 -1
- package/lib/runtime/image-preview.d.ts +8 -0
- package/lib/runtime/image-preview.js +119 -0
- package/lib/runtime/image-preview.js.map +1 -0
- package/lib/runtime/plain-tui.d.ts +2 -0
- package/lib/runtime/plain-tui.js +2 -2
- package/lib/runtime/plain-tui.js.map +1 -1
- package/lib/runtime/plugin-settings.d.ts +17 -0
- package/lib/runtime/plugin-settings.js +154 -0
- package/lib/runtime/plugin-settings.js.map +1 -0
- package/lib/runtime/provider-local.d.ts +58 -7
- package/lib/runtime/provider-local.js +1394 -137
- package/lib/runtime/provider-local.js.map +1 -1
- package/lib/runtime/runner.js +3 -3
- package/lib/runtime/runner.js.map +1 -1
- package/lib/runtime/session-archive.d.ts +5 -0
- package/lib/runtime/session-archive.js +114 -0
- package/lib/runtime/session-archive.js.map +1 -0
- package/lib/runtime/subagent-model-selection.d.ts +5 -0
- package/lib/runtime/subagent-model-selection.js +15 -0
- package/lib/runtime/subagent-model-selection.js.map +1 -0
- package/lib/runtime/terminal-notifications.d.ts +8 -0
- package/lib/runtime/terminal-notifications.js +16 -0
- package/lib/runtime/terminal-notifications.js.map +1 -1
- package/lib/runtime/terminal-reader.d.ts +11 -0
- package/lib/runtime/terminal-reader.js +87 -0
- package/lib/runtime/terminal-reader.js.map +1 -0
- package/lib/runtime/tool-presentation.js +43 -30
- package/lib/runtime/tool-presentation.js.map +1 -1
- package/lib/session/composer-drafts.d.ts +20 -0
- package/lib/session/composer-drafts.js +103 -0
- package/lib/session/composer-drafts.js.map +1 -0
- package/lib/session/conversation-turns.d.ts +16 -0
- package/lib/session/conversation-turns.js +33 -0
- package/lib/session/conversation-turns.js.map +1 -0
- package/lib/session/feature-toggles.d.ts +79 -0
- package/lib/session/feature-toggles.js +139 -0
- package/lib/session/feature-toggles.js.map +1 -0
- package/lib/session/fold-policy.d.ts +4 -0
- package/lib/session/fold-policy.js +4 -0
- package/lib/session/fold-policy.js.map +1 -0
- package/lib/session/interaction-adapter.d.ts +2 -2
- package/lib/session/interaction-adapter.js +156 -25
- package/lib/session/interaction-adapter.js.map +1 -1
- package/lib/session/job-notice.d.ts +4 -0
- package/lib/session/job-notice.js +44 -3
- package/lib/session/job-notice.js.map +1 -1
- package/lib/session/message-queue.d.ts +22 -0
- package/lib/session/message-queue.js +157 -0
- package/lib/session/message-queue.js.map +1 -0
- package/lib/session/project-file-search.js +3 -21
- package/lib/session/project-file-search.js.map +1 -1
- package/lib/session/session-controller.d.ts +11 -18
- package/lib/session/session-controller.js +246 -100
- package/lib/session/session-controller.js.map +1 -1
- package/lib/session/session-library.d.ts +11 -3
- package/lib/session/session-library.js +48 -8
- package/lib/session/session-library.js.map +1 -1
- package/lib/session/session-tree.d.ts +26 -0
- package/lib/session/session-tree.js +112 -0
- package/lib/session/session-tree.js.map +1 -0
- package/lib/session/steering.d.ts +3 -0
- package/lib/session/steering.js +9 -0
- package/lib/session/steering.js.map +1 -0
- package/lib/session/tui-settings.d.ts +23 -1
- package/lib/session/tui-settings.js +15 -2
- package/lib/session/tui-settings.js.map +1 -1
- package/lib/session/workflow-projection.d.ts +9 -0
- package/lib/session/workflow-projection.js +44 -0
- package/lib/session/workflow-projection.js.map +1 -0
- package/lib/views/agent-hub.js +9 -4
- package/lib/views/agent-hub.js.map +1 -1
- package/lib/views/autocomplete.js +5 -25
- package/lib/views/autocomplete.js.map +1 -1
- package/lib/views/document-reader.d.ts +34 -0
- package/lib/views/document-reader.js +145 -0
- package/lib/views/document-reader.js.map +1 -0
- package/lib/views/event-views.d.ts +26 -2
- package/lib/views/event-views.js +196 -42
- package/lib/views/event-views.js.map +1 -1
- package/lib/views/hotkeys.d.ts +2 -0
- package/lib/views/hotkeys.js +20 -8
- package/lib/views/hotkeys.js.map +1 -1
- package/lib/views/prompt-selector.d.ts +33 -3
- package/lib/views/prompt-selector.js +287 -43
- package/lib/views/prompt-selector.js.map +1 -1
- package/lib/views/prompt-tree.d.ts +11 -0
- package/lib/views/prompt-tree.js +74 -0
- package/lib/views/prompt-tree.js.map +1 -0
- package/lib/views/session-files.d.ts +7 -0
- package/lib/views/session-files.js +62 -0
- package/lib/views/session-files.js.map +1 -0
- package/lib/views/settings-list.d.ts +32 -4
- package/lib/views/settings-list.js +148 -37
- package/lib/views/settings-list.js.map +1 -1
- package/lib/views/transcript-render.d.ts +117 -18
- package/lib/views/transcript-render.js +1084 -126
- package/lib/views/transcript-render.js.map +1 -1
- package/lib/views/transcript-types.d.ts +80 -2
- package/lib/views/transcript-types.js +11 -0
- package/lib/views/transcript-types.js.map +1 -1
- package/lib/views/workspace-changes.d.ts +27 -0
- package/lib/views/workspace-changes.js +128 -0
- package/lib/views/workspace-changes.js.map +1 -0
- package/package.json +77 -41
- package/lib/chrome/style.d.ts +0 -22
- package/lib/chrome/style.js +0 -23
- package/lib/chrome/style.js.map +0 -1
|
@@ -12,38 +12,24 @@ import { renderCopySelector } from "./copy-selector.js";
|
|
|
12
12
|
import { renderSettings } from "./settings-list.js";
|
|
13
13
|
import { renderTrajectory } from "./trajectory.js";
|
|
14
14
|
import { renderAgentHub } from "./agent-hub.js";
|
|
15
|
-
import { renderPlanReviewPage, renderPromptSelector, renderPromptSelectorPage, } from "./prompt-selector.js";
|
|
15
|
+
import { renderImagePreviewPage, renderPlanReviewPage, renderPromptSelector, renderPromptSelectorPage, renderPromptTreePage, } from "./prompt-selector.js";
|
|
16
16
|
import { resolveStatusBarConfig } from "../chrome/status-config.js";
|
|
17
17
|
import { renderPermissionBadge, renderStatusFooter } from "../chrome/status-line.js";
|
|
18
|
-
import { createTheme, SPINNER, SYMBOL } from "../chrome/theme.js";
|
|
18
|
+
import { createTheme, paintSurface, SPINNER, SYMBOL, BOX } from "../chrome/theme.js";
|
|
19
19
|
import { renderGoalBar } from "../chrome/goal-bar.js";
|
|
20
20
|
import { padToWidth, stripAnsi, truncateToWidth, visibleWidth, wrapText } from "../chrome/width.js";
|
|
21
|
-
import { alignFileDiffs,
|
|
22
|
-
import {
|
|
21
|
+
import { alignFileDiffs, wrapPaintedDiffRows, } from "../chrome/diff-render.js";
|
|
22
|
+
import { workspaceBlockLines } from "./workspace-changes.js";
|
|
23
|
+
import { renderTool, toolArgSubject } from "../chrome/tool-renderers.js";
|
|
24
|
+
import { toolArgsObject } from "../chrome/tool-args.js";
|
|
23
25
|
import { renderToolsPanel } from "../chrome/tools-list.js";
|
|
24
26
|
import { renderCommandOutput, renderCommandSeparator } from "../chrome/command-output.js";
|
|
25
27
|
import { renderPathMentionRows } from "../chrome/path-mentions.js";
|
|
26
28
|
import { blockMatchesQuery, transcriptSearchHint } from "./transcript-search.js";
|
|
27
|
-
import { contentToText, prettyArgs, } from "./transcript-types.js";
|
|
29
|
+
import { contentToText, prettyArgs, reasoningKey, } from "./transcript-types.js";
|
|
28
30
|
function isBlockPending(block) {
|
|
29
31
|
return (block.kind === 'assistant' && block.streaming) || (block.kind === 'tool' && block.status === 'running');
|
|
30
32
|
}
|
|
31
|
-
/** Collapsed tool-output preview height (OMP `DEFAULT_TERMINAL_PREVIEW_LINES`). */
|
|
32
|
-
export const TOOL_COLLAPSED_LINES = 10;
|
|
33
|
-
export const TOOL_INPUT_COLLAPSED_LINES = 3;
|
|
34
|
-
function slicePreview(lines, limit, expanded, direction) {
|
|
35
|
-
if (expanded || lines.length <= limit)
|
|
36
|
-
return { lines: [...lines], hidden: 0 };
|
|
37
|
-
const hidden = lines.length - limit;
|
|
38
|
-
return {
|
|
39
|
-
lines: direction === 'tail' ? lines.slice(hidden) : lines.slice(0, limit),
|
|
40
|
-
hidden,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
function toolPreview(lines, width, limit, expanded, direction) {
|
|
44
|
-
const contentWidth = Math.max(1, width - 4);
|
|
45
|
-
return slicePreview(lines.flatMap(line => wrapText(line, contentWidth)), limit, expanded, direction);
|
|
46
|
-
}
|
|
47
33
|
function exclusiveDiffs(presentation) {
|
|
48
34
|
if (presentation?.result?.card === 'diff')
|
|
49
35
|
return presentation.result.diffs;
|
|
@@ -53,8 +39,42 @@ function exclusiveDiffs(presentation) {
|
|
|
53
39
|
return presentation.call.diffs;
|
|
54
40
|
return undefined;
|
|
55
41
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The transcript's one content column.
|
|
44
|
+
*
|
|
45
|
+
* Every block starts its words in the same column: a reply, a prompt after its
|
|
46
|
+
* `›`, a run's header after its `▸`, a notice. The marks own the two cells to
|
|
47
|
+
* the left of it. A reply used to sit one cell in while the marked rows put
|
|
48
|
+
* their words at three, so a screen had four left edges and read as clutter
|
|
49
|
+
* however tidy each block was on its own.
|
|
50
|
+
*/
|
|
51
|
+
const ASSISTANT_PADDING_X = 2;
|
|
52
|
+
/** Process summaries keep a readable measure even on wide terminals. */
|
|
53
|
+
const PROCESS_WIDTH = 80;
|
|
54
|
+
function processRoom(width, head) {
|
|
55
|
+
return Math.max(1, Math.min(PROCESS_WIDTH, width - 2) - visibleWidth(head));
|
|
56
|
+
}
|
|
57
|
+
/** Shorten directories before sacrificing the filename. Both separators occur in replayed sessions. */
|
|
58
|
+
function shortPath(path, width) {
|
|
59
|
+
if (visibleWidth(path) <= width)
|
|
60
|
+
return path;
|
|
61
|
+
const parts = path.split(/[/\\]/u);
|
|
62
|
+
if (parts.length < 2)
|
|
63
|
+
return truncateToWidth(path, width);
|
|
64
|
+
const separator = path.includes('\\') ? '\\' : '/';
|
|
65
|
+
const file = parts.pop();
|
|
66
|
+
const tail = '…' + separator + file;
|
|
67
|
+
if (visibleWidth(tail) >= width)
|
|
68
|
+
return truncateToWidth(file, width);
|
|
69
|
+
let prefix = '';
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
const next = prefix + part + separator;
|
|
72
|
+
if (visibleWidth(next + tail) > width)
|
|
73
|
+
break;
|
|
74
|
+
prefix = next;
|
|
75
|
+
}
|
|
76
|
+
return prefix + tail;
|
|
77
|
+
}
|
|
58
78
|
function assistantContentLines(lines, width, paddingX) {
|
|
59
79
|
const margin = ' '.repeat(paddingX);
|
|
60
80
|
return lines.map((line) => padToWidth(margin + line + margin, width));
|
|
@@ -76,13 +96,28 @@ function assistantMarkdown(source, theme, width, style) {
|
|
|
76
96
|
return assistantContentLines(rendered, width, paddingX);
|
|
77
97
|
return assistantContentLines(rendered.map(line => lockThinkingLine(line, theme)), width, paddingX);
|
|
78
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* What the reader typed.
|
|
101
|
+
*
|
|
102
|
+
* No mark in front of it. The prompt is the one block whose author is already
|
|
103
|
+
* known — the reader typed it, and it is the row that opens a turn — so it
|
|
104
|
+
* carries its own tint as a slab and reads as a prompt without a glyph
|
|
105
|
+
* claiming what the tint already says. Its words sit on the same column as
|
|
106
|
+
* everything else, so nothing on the screen is held off by a gutter.
|
|
107
|
+
*/
|
|
79
108
|
function userBubble(text, theme, width) {
|
|
80
|
-
const
|
|
81
|
-
const wrapped = renderPathMentionRows(text,
|
|
109
|
+
const gutter = ' ';
|
|
110
|
+
const wrapped = renderPathMentionRows(text, Math.max(1, width - gutter.length), theme);
|
|
82
111
|
const rows = ['', ...wrapped, ''];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
// The padding rows share the prompt's tint, so the prompt is one slab that
|
|
113
|
+
// opens the turn. Unpainted, they were two blank rows of their own on top of
|
|
114
|
+
// the gap between blocks, and the prompt floated in twice the space of
|
|
115
|
+
// anything else on the screen.
|
|
116
|
+
return rows.map((row, index) => {
|
|
117
|
+
if (row === '')
|
|
118
|
+
return paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth('', width));
|
|
119
|
+
const content = padToWidth((index === 1 ? gutter : '') + row, width);
|
|
120
|
+
return paintSurface(theme, 'userMessageBg', 'userMessageText', content);
|
|
86
121
|
});
|
|
87
122
|
}
|
|
88
123
|
function toolIcon(status, theme, spinnerFrame) {
|
|
@@ -95,90 +130,367 @@ function toolIcon(status, theme, spinnerFrame) {
|
|
|
95
130
|
return theme.fg('success', SYMBOL.success);
|
|
96
131
|
return theme.fg('error', SYMBOL.error);
|
|
97
132
|
}
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
/**
|
|
134
|
+
* The gutter mark for a call, or nothing at all.
|
|
135
|
+
*
|
|
136
|
+
* A settled successful call used to carry a checkmark, and a run of five calls
|
|
137
|
+
* put five identical checkmarks in a column — a signal that says only "the row
|
|
138
|
+
* above exists", which it always did. The gutter is for states a reader has to
|
|
139
|
+
* act on: in flight, or wrong. A run of successes is legible by its shape, and
|
|
140
|
+
* the marks that remain mean something.
|
|
141
|
+
*/
|
|
142
|
+
function toolStatusMark(status, theme, spinnerFrame) {
|
|
143
|
+
if (status === 'ok')
|
|
144
|
+
return '';
|
|
145
|
+
return toolIcon(status, theme, spinnerFrame);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One-line fact for a failed call.
|
|
149
|
+
*
|
|
150
|
+
* The row has to answer "what failed" on its own, because that is the whole
|
|
151
|
+
* reason the reader is looking at this line. A terminal echoing the command it
|
|
152
|
+
* just ran is not the answer, so it is skipped along with blank lines; whatever
|
|
153
|
+
* comes back is the first thing the tool itself complained about. Falls back to
|
|
154
|
+
* the call's own summary when the output says nothing.
|
|
155
|
+
*/
|
|
156
|
+
function toolFailureFact(output, command, fact) {
|
|
157
|
+
for (const line of output.split('\n')) {
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
if (trimmed === '')
|
|
160
|
+
continue;
|
|
161
|
+
if (command !== undefined
|
|
162
|
+
&& (trimmed === command || trimmed === `> ${command}` || trimmed === `$ ${command}`))
|
|
163
|
+
continue;
|
|
164
|
+
return trimmed;
|
|
165
|
+
}
|
|
166
|
+
return fact;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* A folded call shows its description once, or a kind and subject as fallback.
|
|
170
|
+
* Errors reserve space for their reason; full commands and output stay in the
|
|
171
|
+
* expanded block. Process rows share an 80-cell measure and right padding.
|
|
172
|
+
*/
|
|
173
|
+
function toolRowLine(block, theme, width, spinnerFrame) {
|
|
101
174
|
const presentation = renderTool({
|
|
102
175
|
name: block.name,
|
|
103
176
|
arguments: prettyArgs(block.args),
|
|
104
177
|
output: block.output,
|
|
105
178
|
status: block.status,
|
|
106
|
-
expanded,
|
|
179
|
+
expanded: false,
|
|
107
180
|
...(block.partial === true ? { partial: true } : {}),
|
|
108
181
|
...(block.presentation === undefined ? {} : { presentation: block.presentation }),
|
|
109
182
|
});
|
|
183
|
+
// A PTC sub-call ran inside a program, not because the model asked for it
|
|
184
|
+
// directly. Indenting says that without a second visual vocabulary, and the
|
|
185
|
+
// width budget shrinks to match so the row still ends at the same column.
|
|
186
|
+
const nest = block.parentCallId === undefined ? '' : ' ';
|
|
187
|
+
// No gutter column. A settled successful call carries no mark at all — a
|
|
188
|
+
// column of identical bullets says only that the row above exists — and the
|
|
189
|
+
// two that remain are the states a reader has to act on: motion while the
|
|
190
|
+
// call is in flight, and the error mark.
|
|
191
|
+
const mark = block.status === 'ok' ? '' : toolStatusMark(block.status, theme, spinnerFrame) + ' ';
|
|
192
|
+
const head = nest + mark;
|
|
193
|
+
const room = processRoom(width, head);
|
|
194
|
+
const label = processLabel(block.name);
|
|
195
|
+
const subject = toolRowSubject(block, presentation, label);
|
|
196
|
+
const args = toolArgsObject(block.args);
|
|
197
|
+
const call = block.presentation?.call;
|
|
198
|
+
const description = args?.['description'] ?? (call?.card === 'terminal' ? call.description : undefined);
|
|
199
|
+
const descriptionLed = (COMMAND_TOOL.test(block.name) || call?.card === 'terminal')
|
|
200
|
+
&& typeof description === 'string' && description.trim() !== '';
|
|
201
|
+
// The echo a failure skips is the command line, which a description-led row
|
|
202
|
+
// no longer carries as its subject.
|
|
203
|
+
const argCommand = args?.['command'];
|
|
204
|
+
const command = typeof argCommand === 'string'
|
|
205
|
+
? argCommand
|
|
206
|
+
: block.presentation?.call?.card === 'terminal' ? presentation.input[0] ?? subject : subject;
|
|
207
|
+
const fact = block.status === 'error'
|
|
208
|
+
? firstLineOf(block.job !== undefined && block.job.status !== 'completed'
|
|
209
|
+
? block.job.detail ?? block.job.status
|
|
210
|
+
: toolFailureFact(block.output, command, presentation.summary ?? '')).replace(/^error:\s*/iu, '')
|
|
211
|
+
: exclusiveDiffs(block.presentation) === undefined ? '' : presentation.summary ?? '';
|
|
212
|
+
// The fact continues the sentence the row already is — `Read file · a.ts ·
|
|
213
|
+
// no such file` — rather than sitting against the right edge, where it read
|
|
214
|
+
// as a separate column that belonged to no row in particular. It is reserved
|
|
215
|
+
// first so a long subject cannot push it off; a failure gets the larger
|
|
216
|
+
// share, because its fact is the reason the reader stopped.
|
|
217
|
+
const factShare = block.status === 'error' ? 0.5 : 1 / 3;
|
|
218
|
+
const factRoom = Math.min(visibleWidth(fact), Math.floor(Math.max(1, room) * factShare));
|
|
219
|
+
const afterFact = Math.max(1, room - (factRoom === 0 ? 0 : factRoom + ROW_JOIN.length));
|
|
220
|
+
const labelRoom = descriptionLed ? 0 : Math.min(visibleWidth(label), afterFact);
|
|
221
|
+
const detailRoom = descriptionLed ? afterFact : afterFact - labelRoom - ROW_JOIN.length;
|
|
222
|
+
const labelText = descriptionLed ? '' : truncateToWidth(label, Math.max(1, labelRoom));
|
|
223
|
+
const pathSubject = ['read', 'write', 'edit'].includes(block.name);
|
|
224
|
+
const detailText = subject === '' || detailRoom < (descriptionLed ? 1 : 8) ? ''
|
|
225
|
+
: pathSubject ? shortPath(subject, detailRoom) : truncateToWidth(subject, detailRoom);
|
|
226
|
+
const tailText = fact === '' || factRoom === 0 ? '' : truncateToWidth(fact, factRoom);
|
|
227
|
+
const text = labelText + (detailText === '' ? '' : (descriptionLed ? '' : ROW_JOIN) + detailText) + (tailText === '' ? '' : ROW_JOIN);
|
|
228
|
+
// A failure is the one line in a run that has to stay findable while scrolling
|
|
229
|
+
// past, so its fact carries the error colour rather than the row's dim.
|
|
230
|
+
const tail = tailText === '' ? '' : block.status === 'error' ? theme.fg('error', tailText) : theme.dim(tailText);
|
|
231
|
+
return [padToWidth(truncateToWidth(head + theme.fg('muted', text) + tail, Math.max(1, width - 2)), width)];
|
|
232
|
+
}
|
|
233
|
+
/** Separator between a process subject and its outcome. */
|
|
234
|
+
const ROW_JOIN = ' · ';
|
|
235
|
+
/** Tools whose `description` argument says what a command was for. */
|
|
236
|
+
const COMMAND_TOOL = /^(bash|shell|pwsh|exec)$/;
|
|
237
|
+
/**
|
|
238
|
+
* What a call acted on, as one line.
|
|
239
|
+
*
|
|
240
|
+
* A shell call carries a `description` written for exactly this purpose, and it
|
|
241
|
+
* reads as intent where the command reads as plumbing: `Show recent commits and
|
|
242
|
+
* status` rather than `cd /long/path && git log --oneline -15 && echo ---`. The
|
|
243
|
+
* command stays reachable by opening the row. Everything else reads its
|
|
244
|
+
* argument fields in the shared order, then any string argument at all — a
|
|
245
|
+
* skill call's only argument is `name` — and finally the card's own title when
|
|
246
|
+
* it says something the label does not.
|
|
247
|
+
*/
|
|
248
|
+
function toolRowSubject(block, presentation, label) {
|
|
249
|
+
const args = toolArgsObject(block.args);
|
|
250
|
+
const call = block.presentation?.call;
|
|
251
|
+
if (COMMAND_TOOL.test(block.name) || call?.card === 'terminal') {
|
|
252
|
+
const description = args?.['description'] ?? (call?.card === 'terminal' ? call.description : undefined);
|
|
253
|
+
if (typeof description === 'string' && description.trim() !== '')
|
|
254
|
+
return firstLineOf(description);
|
|
255
|
+
}
|
|
256
|
+
const fromFields = toolArgSubject(block.args, block.partial === true);
|
|
257
|
+
if (fromFields !== '')
|
|
258
|
+
return firstLineOf(fromFields);
|
|
259
|
+
if (args !== undefined) {
|
|
260
|
+
for (const value of Object.values(args)) {
|
|
261
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
262
|
+
return firstLineOf(value);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// A card's first input line is the tool's own idea of what the call was — a
|
|
266
|
+
// terminal card puts the command there and its title is only the tool name.
|
|
267
|
+
const fromCard = call === undefined ? '' : firstLineOf(presentation.input[0] ?? '');
|
|
268
|
+
if (fromCard !== '')
|
|
269
|
+
return fromCard;
|
|
270
|
+
const title = presentation.title;
|
|
271
|
+
if (title === undefined || title === block.name)
|
|
272
|
+
return '';
|
|
273
|
+
// `Read src/x.ts` under a `Read file` label would say the verb twice.
|
|
274
|
+
const verb = label.split(' ')[0] ?? '';
|
|
275
|
+
return firstLineOf(verb !== '' && title.startsWith(verb + ' ') ? title.slice(verb.length + 1) : title);
|
|
276
|
+
}
|
|
277
|
+
/** Tool content shared by a running turn and a completed turn opened for reading. */
|
|
278
|
+
function toolBlockLines(block, theme, width, mode = 'preview', detailsKey = 'Alt+O') {
|
|
279
|
+
const full = mode !== 'preview';
|
|
280
|
+
const presentation = renderTool({
|
|
281
|
+
name: block.name, arguments: prettyArgs(block.args), output: block.output,
|
|
282
|
+
status: block.status, expanded: full,
|
|
283
|
+
...(block.partial === true ? { partial: true } : {}),
|
|
284
|
+
...(block.presentation === undefined ? {} : { presentation: block.presentation }),
|
|
285
|
+
});
|
|
286
|
+
const args = toolArgsObject(block.args);
|
|
287
|
+
const call = block.presentation?.call;
|
|
288
|
+
const result = block.presentation?.result;
|
|
289
|
+
const shell = COMMAND_TOOL.test(block.name) || call?.card === 'terminal';
|
|
290
|
+
const read = block.name === 'read' || result?.card === 'read';
|
|
110
291
|
const diffs = exclusiveDiffs(block.presentation);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
292
|
+
const padding = width > 4 ? 2 : 0;
|
|
293
|
+
const nest = block.parentCallId === undefined || width < 10 ? 0 : 2;
|
|
294
|
+
const room = Math.max(1, width - padding * 2 - nest);
|
|
295
|
+
const subject = toolArgSubject(block.args, block.partial === true)
|
|
296
|
+
|| (result?.card === 'read' ? result.path : '')
|
|
297
|
+
|| (args === undefined ? '' : Object.values(args).find(value => typeof value === 'string')) || '';
|
|
298
|
+
const command = call?.card === 'terminal' ? call.title
|
|
299
|
+
: typeof args?.['command'] === 'string' ? args['command'] : subject;
|
|
300
|
+
const title = shell ? `$ ${command}`
|
|
301
|
+
: call?.card === 'generic' || call?.card === 'diff' ? call.title
|
|
302
|
+
: !read && presentation.title !== undefined && presentation.title !== block.name ? presentation.title
|
|
303
|
+
: `${block.name}${subject === '' ? '' : ` ${subject}`}`;
|
|
304
|
+
const head = theme.bold(theme.fg(block.status === 'error' ? 'error' : 'toolTitle', title));
|
|
305
|
+
const rows = wrapText(head, room);
|
|
306
|
+
let body = [];
|
|
307
|
+
if (mode === 'details') {
|
|
308
|
+
let input = block.args;
|
|
309
|
+
try {
|
|
310
|
+
input = JSON.stringify(JSON.parse(input), undefined, 2);
|
|
311
|
+
}
|
|
312
|
+
catch { /* Partial arguments retain their received text. */ }
|
|
313
|
+
const output = presentation.output.length > 0 ? presentation.output : block.output.split('\n');
|
|
314
|
+
const outputRows = diffs === undefined
|
|
315
|
+
? output.flatMap(line => wrapText(theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line), room))
|
|
316
|
+
: wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
|
|
317
|
+
body = [
|
|
318
|
+
...wrapText(theme.fg('dim', 'Input'), room),
|
|
319
|
+
...input.split('\n').flatMap(line => wrapText(theme.fg('toolOutput', line), room)),
|
|
320
|
+
'', ...wrapText(theme.fg('dim', 'Output'), room),
|
|
321
|
+
...outputRows,
|
|
322
|
+
];
|
|
323
|
+
// Keep the tool's rich presentation, but expose received text it replaced.
|
|
324
|
+
if (block.output !== '' && (diffs !== undefined || block.output !== output.join('\n'))) {
|
|
325
|
+
body.push('', ...wrapText(theme.fg('dim', 'Raw result'), room));
|
|
326
|
+
for (const line of block.output.split('\n')) {
|
|
327
|
+
body.push(...wrapText(theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line), room));
|
|
328
|
+
}
|
|
121
329
|
}
|
|
122
|
-
const state = block.status === 'running' ? 'running' : block.status === 'ok' ? 'ok' : 'error';
|
|
123
|
-
return renderFramedBlock({ header, state, sections: [{ lines: output.lines }], width }, theme);
|
|
124
330
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
331
|
+
else if (diffs !== undefined) {
|
|
332
|
+
body = wrapPaintedDiffRows(alignFileDiffs(diffs), theme, room);
|
|
333
|
+
}
|
|
334
|
+
else if (full || !read || block.status === 'error') {
|
|
335
|
+
const output = presentation.output.map(line => theme.fg(block.status === 'error' ? 'error' : 'toolOutput', line));
|
|
336
|
+
const input = !shell && !read && (call?.card === 'generic' || (call === undefined && presentation.title !== block.name))
|
|
337
|
+
? presentation.input.flatMap(line => wrapText(theme.fg('toolOutput', line), room)) : [];
|
|
338
|
+
body = [...input, ...output.flatMap(line => wrapText(line, room))];
|
|
339
|
+
// Writes carry their content in arguments before the result arrives.
|
|
340
|
+
if (body.length === 0 && block.name === 'write' && typeof args?.['content'] === 'string') {
|
|
341
|
+
body = args['content'].split('\n').flatMap(line => wrapText(theme.fg('toolOutput', line), room));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
while (body.length > 0 && stripAnsi(body[body.length - 1]).trim() === '')
|
|
345
|
+
body.pop();
|
|
346
|
+
const limit = full || diffs !== undefined ? body.length : shell ? 5
|
|
347
|
+
: /^(grep|search)$/.test(block.name) ? 15 : /^(glob|find|ls)$/.test(block.name) ? 20 : 10;
|
|
348
|
+
const omitted = Math.max(0, body.length - limit);
|
|
349
|
+
if (omitted > 0) {
|
|
350
|
+
body = shell ? body.slice(-limit) : body.slice(0, limit);
|
|
351
|
+
const shortcut = detailsKey === 'Disabled' ? '' : ` · ${detailsKey}: Expand`;
|
|
352
|
+
const hint = theme.fg('dim', `… ${omitted} ${shell ? 'earlier' : 'more'} lines${shortcut}`);
|
|
353
|
+
if (shell)
|
|
354
|
+
body.unshift(...wrapText(hint, room));
|
|
143
355
|
else
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
...(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
356
|
+
body.push(...wrapText(hint, room));
|
|
357
|
+
}
|
|
358
|
+
if (!full && read && block.status !== 'error' && block.output !== '' && detailsKey !== 'Disabled') {
|
|
359
|
+
body.push(...wrapText(theme.fg('dim', `${detailsKey}: Expand result`), room));
|
|
360
|
+
}
|
|
361
|
+
if (full && block.status === 'error' && call?.card === 'diff')
|
|
362
|
+
rows.push('', ...wrapPaintedDiffRows(alignFileDiffs(call.diffs), theme, room));
|
|
363
|
+
if (body.length > 0) {
|
|
364
|
+
rows.push('');
|
|
365
|
+
for (const row of body)
|
|
366
|
+
rows.push(row);
|
|
367
|
+
}
|
|
368
|
+
if (block.status === 'error')
|
|
369
|
+
rows.push(...wrapText(theme.fg('error', 'Tool failed'), room));
|
|
370
|
+
if (block.job !== undefined)
|
|
371
|
+
rows.push(...wrapText(theme.fg(block.status === 'error' ? 'error' : 'muted', `Job ${block.job.id} · ${block.job.detail ?? block.job.status}`), room));
|
|
372
|
+
const bg = block.status === 'running' ? 'toolPendingBg' : block.status === 'error' ? 'toolErrorBg' : 'toolSuccessBg';
|
|
373
|
+
return ['', ...rows, ''].map(row => paintSurface(theme, bg, 'toolOutput', padToWidth(' '.repeat(padding + nest) + row, width)));
|
|
374
|
+
}
|
|
375
|
+
function firstLineOf(text) {
|
|
376
|
+
for (const line of text.split('\n')) {
|
|
377
|
+
const trimmed = line.trim();
|
|
378
|
+
if (trimmed !== '')
|
|
379
|
+
return trimmed;
|
|
380
|
+
}
|
|
381
|
+
return '';
|
|
382
|
+
}
|
|
383
|
+
/** Thinking uses the same quiet Markdown style throughout the turn. */
|
|
384
|
+
function openReasoningLines(block, theme, width, mathMode = 'auto') {
|
|
385
|
+
return assistantMarkdown(block.reasoning, theme, width, { color: 'thinkingText', italic: true, mathMode });
|
|
386
|
+
}
|
|
387
|
+
/** The block indexes a run shows as rows, in order: its members, then the thought it took from its answer. */
|
|
388
|
+
function groupMembers(group) {
|
|
389
|
+
const members = [];
|
|
390
|
+
for (let at = group.start; at < (group.answer ?? group.until); at += 1)
|
|
391
|
+
members.push(at);
|
|
392
|
+
if (group.tookThought && group.answer !== undefined)
|
|
393
|
+
members.push(group.answer);
|
|
394
|
+
return members;
|
|
155
395
|
}
|
|
156
396
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* @param spinnerFrame - activity spinner phase for running tools.
|
|
163
|
-
* @param toolsExpanded - paint full tool output instead of the collapsed preview.
|
|
164
|
-
* @returns display lines (already width-fitted).
|
|
397
|
+
* One member of a run, as a row or opened in place.
|
|
398
|
+
*
|
|
399
|
+
* `thoughtOnly` is the step that answered: the run took its thought as the
|
|
400
|
+
* last row and its words are the answer painted below the run, so the run
|
|
401
|
+
* paints the thought and nothing else.
|
|
165
402
|
*/
|
|
166
|
-
|
|
403
|
+
function groupMemberLines(index, context, thoughtOnly = false) {
|
|
404
|
+
const { blocks, options, theme, width } = context;
|
|
405
|
+
const block = blocks[index];
|
|
406
|
+
// Search must reveal a match even when it falls outside a tool's normal preview.
|
|
407
|
+
const full = block.kind === 'tool' && (context.matches.has(index) || options.expandedTools?.has(block.callId) === true);
|
|
408
|
+
const signature = [width, options.colors, context.trueColor, context.themeName, options.mathMode, options.mermaidMode, thoughtOnly, full, options.toolDetailsKey].join('\0');
|
|
409
|
+
const cached = groupMemberCache.get(block);
|
|
410
|
+
let lines;
|
|
411
|
+
if (cached?.signature === signature)
|
|
412
|
+
lines = cached.lines;
|
|
413
|
+
else {
|
|
414
|
+
lines = block.kind === 'tool' ? toolBlockLines(block, theme, width, full ? 'details' : 'preview', options.toolDetailsKey)
|
|
415
|
+
: block.kind === 'assistant' ? thoughtOnly ? openReasoningLines(block, theme, width, options.mathMode)
|
|
416
|
+
: blockLines(block, theme, width, { mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' })
|
|
417
|
+
: block.kind === 'notice' ? blockLines(block, theme, width) : [];
|
|
418
|
+
groupMemberCache.set(block, { signature, lines });
|
|
419
|
+
}
|
|
420
|
+
const search = options.transcriptSearch;
|
|
421
|
+
return search !== undefined && context.matches.has(index)
|
|
422
|
+
? lines.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
|
|
423
|
+
: [...lines];
|
|
424
|
+
}
|
|
425
|
+
/** A notice that reports on the run's work, as one of its rows. */
|
|
426
|
+
function processNoticeLine(block, theme, width, indent) {
|
|
427
|
+
const color = block.level === 'error' ? 'error' : 'muted';
|
|
428
|
+
const head = indent + (block.level === 'error' ? theme.fg(color, SYMBOL.error) + ' ' : '');
|
|
429
|
+
const room = processRoom(width, head);
|
|
430
|
+
return padToWidth(truncateToWidth(head + theme.fg(color, truncateToWidth(block.text.split('\n')[0] ?? '', room)), width), width);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Rows of a run live across frames, so they are kept per block the same way a
|
|
434
|
+
* top-level block's rows are: a streamed token re-lays the frame without
|
|
435
|
+
* re-laying every call the run already made.
|
|
436
|
+
*/
|
|
437
|
+
const groupMemberCache = new WeakMap();
|
|
438
|
+
/** Flat chronological content, shared by live and opened turns. */
|
|
439
|
+
function processGroupBody(group, context) {
|
|
440
|
+
const lines = [];
|
|
441
|
+
const offsets = new Map();
|
|
442
|
+
for (const index of groupMembers(group)) {
|
|
443
|
+
const rows = groupMemberLines(index, context, index === group.answer && group.tookThought);
|
|
444
|
+
if (rows.length > 0 && lines.length > 0)
|
|
445
|
+
lines.push('');
|
|
446
|
+
offsets.set(index, lines.length);
|
|
447
|
+
for (const row of rows)
|
|
448
|
+
lines.push(row);
|
|
449
|
+
}
|
|
450
|
+
return { lines, offsets };
|
|
451
|
+
}
|
|
452
|
+
/** A settled turn's length, the way a person would say it. */
|
|
453
|
+
export function formatElapsed(ms) {
|
|
454
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
455
|
+
if (seconds < 60)
|
|
456
|
+
return `${seconds}s`;
|
|
457
|
+
const minutes = Math.floor(seconds / 60);
|
|
458
|
+
if (minutes < 60)
|
|
459
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
460
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
461
|
+
}
|
|
462
|
+
/** A completed turn's duration and any failures remain visible when folded. */
|
|
463
|
+
function processGroupHeader(group, theme, width, elapsed, expandKey = 'Ctrl+O') {
|
|
464
|
+
const label = elapsed === undefined ? 'Worked' : `Worked for ${elapsed}`;
|
|
465
|
+
const failure = group.failures === 0 ? '' : theme.fg('error', ` · ${group.failures} failed`);
|
|
466
|
+
const labelRoom = Math.max(1, width - 4 - visibleWidth(failure));
|
|
467
|
+
const summary = `${SYMBOL.folded} ${theme.fg('dim', truncateToWidth(label, labelRoom))}${failure}`;
|
|
468
|
+
const hint = ` · ${expandKey} to expand`;
|
|
469
|
+
const suffix = expandKey !== 'Disabled' && visibleWidth(summary) + visibleWidth(hint) <= width - 2
|
|
470
|
+
? theme.fg('dim', hint) : '';
|
|
471
|
+
return [padToWidth(truncateToWidth(summary + suffix, Math.max(1, width - 2)), width)];
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Render a block for the transcript or plain printer. Full output is available
|
|
475
|
+
* to direct callers; turn inspection uses previews. omitReasoning excludes
|
|
476
|
+
* thinking already drawn by a run.
|
|
477
|
+
*/
|
|
478
|
+
export function blockLines(block, theme, width, { toolsExpanded = false, omitReasoning = false, toolDetailsKey = 'Alt+O', mathMode = 'auto', mermaidMode = 'auto' } = {}) {
|
|
167
479
|
if (block.kind === 'user')
|
|
168
480
|
return userBubble(block.text, theme, width);
|
|
169
481
|
if (block.kind === 'assistant') {
|
|
170
482
|
const lines = [];
|
|
171
|
-
if (block.reasoning !== '') {
|
|
172
|
-
lines.push(...
|
|
483
|
+
if (block.reasoning !== '' && !omitReasoning) {
|
|
484
|
+
lines.push(...openReasoningLines(block, theme, width, mathMode));
|
|
173
485
|
if (block.text !== '')
|
|
174
486
|
lines.push('');
|
|
175
487
|
}
|
|
176
|
-
if (block.text === '' && block.streaming) {
|
|
488
|
+
if (block.text === '' && block.reasoning === '' && block.streaming) {
|
|
177
489
|
const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
|
|
178
490
|
lines.push(...assistantContentLines([theme.fg('dim', '…')], width, paddingX));
|
|
179
491
|
}
|
|
180
492
|
else if (block.text !== '') {
|
|
181
|
-
lines.push(...assistantMarkdown(block.text, theme, width, hasExplicitTextColor(theme) ? { color: 'text' } :
|
|
493
|
+
lines.push(...assistantMarkdown(block.text, theme, width, { ...(hasExplicitTextColor(theme) ? { color: 'text' } : {}), mathMode, mermaidMode }));
|
|
182
494
|
}
|
|
183
495
|
if (block.interrupted === true) {
|
|
184
496
|
const paddingX = width > ASSISTANT_PADDING_X * 2 ? ASSISTANT_PADDING_X : 0;
|
|
@@ -186,12 +498,19 @@ export function blockLines(block, theme, width, spinnerFrame = 0, toolsExpanded
|
|
|
186
498
|
}
|
|
187
499
|
return lines;
|
|
188
500
|
}
|
|
189
|
-
if (block.kind === 'tool')
|
|
190
|
-
return toolBlockLines(block, theme, width,
|
|
501
|
+
if (block.kind === 'tool') {
|
|
502
|
+
return toolBlockLines(block, theme, width, toolsExpanded ? 'full' : 'preview', toolDetailsKey);
|
|
503
|
+
}
|
|
191
504
|
if (block.kind === 'toolCatalog')
|
|
192
505
|
return renderToolsPanel(block.tools, theme, width, toolsExpanded);
|
|
506
|
+
if (block.kind === 'workspace')
|
|
507
|
+
return workspaceBlockLines(block, theme, width, toolsExpanded);
|
|
193
508
|
if (block.kind === 'commandOutput')
|
|
194
509
|
return renderCommandOutput(block.command, block.text, theme, width);
|
|
510
|
+
if (block.boundary === true)
|
|
511
|
+
return [renderTranscriptBoundary(block.text, theme, width)];
|
|
512
|
+
if (block.process !== undefined && block.framed !== true)
|
|
513
|
+
return [processNoticeLine(block, theme, width, '')];
|
|
195
514
|
if (block.framed !== true) {
|
|
196
515
|
const prefix = ' ';
|
|
197
516
|
const continuation = ' '.repeat(visibleWidth(prefix));
|
|
@@ -210,16 +529,55 @@ export function blockLines(block, theme, width, spinnerFrame = 0, toolsExpanded
|
|
|
210
529
|
applyBg: false,
|
|
211
530
|
}, theme);
|
|
212
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Seam labels live in one table with the row that draws them, so a later
|
|
534
|
+
* language layer moves the strings in one edit rather than chasing them through
|
|
535
|
+
* the runtime. English until that layer exists.
|
|
536
|
+
*/
|
|
537
|
+
const SEAM_LABELS = {
|
|
538
|
+
'session-opened': 'session opened · earlier output retained',
|
|
539
|
+
cleared: 'transcript cleared · earlier output retained',
|
|
540
|
+
};
|
|
541
|
+
/** The row that separates two documents in the terminal's history. */
|
|
542
|
+
export function transcriptSeam(reason) {
|
|
543
|
+
return { kind: 'notice', level: 'info', text: SEAM_LABELS[reason], boundary: true };
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The seam where the transcript was replaced.
|
|
547
|
+
*
|
|
548
|
+
* This row exists because the earlier output is still in the terminal's own
|
|
549
|
+
* scrollback and stays there. Without it, scrolling up walks into rows that
|
|
550
|
+
* describe a document the live view no longer holds and nothing says why. The
|
|
551
|
+
* label names the cause; the rules are chrome, not a message about anything.
|
|
552
|
+
*/
|
|
553
|
+
function renderTranscriptBoundary(label, theme, width) {
|
|
554
|
+
if (width <= 0)
|
|
555
|
+
return '';
|
|
556
|
+
const labelText = theme.fg('dim', label);
|
|
557
|
+
const rule = theme.fg('border', BOX.horizontal);
|
|
558
|
+
// A terminal too narrow for a rule on either side still gets the label, since
|
|
559
|
+
// the label is the part that carries the fact.
|
|
560
|
+
const room = width - visibleWidth(label) - 2;
|
|
561
|
+
if (room < 2)
|
|
562
|
+
return truncateToWidth(labelText, Math.max(1, width));
|
|
563
|
+
const left = Math.floor(room / 2);
|
|
564
|
+
return rule.repeat(left) + ' ' + labelText + ' ' + rule.repeat(room - left);
|
|
565
|
+
}
|
|
213
566
|
/** Plain text of one block, used by transcript search and match painting. */
|
|
214
567
|
export function blockSearchText(block) {
|
|
215
568
|
if (block.kind === 'assistant') {
|
|
216
569
|
return block.reasoning === '' ? block.text : `${block.reasoning}\n${block.text}`;
|
|
217
570
|
}
|
|
218
571
|
if (block.kind === 'tool')
|
|
219
|
-
return `${block.args}\n${block.output}`;
|
|
572
|
+
return `${block.args}\n${block.output}${block.job === undefined ? '' : `\n${block.job.id} ${block.job.status} ${block.job.detail ?? ''}`}`;
|
|
220
573
|
if (block.kind === 'toolCatalog') {
|
|
221
574
|
return block.tools.map(tool => `${tool.name} ${tool.description}`).join('\n');
|
|
222
575
|
}
|
|
576
|
+
if (block.kind === 'workspace') {
|
|
577
|
+
// Only the served file paths are searchable; a turn whose summary the
|
|
578
|
+
// host no longer holds contributes no path text.
|
|
579
|
+
return block.summary?.files.map(file => file.display).join('\n') ?? `turn ${block.turn}`;
|
|
580
|
+
}
|
|
223
581
|
return block.text;
|
|
224
582
|
}
|
|
225
583
|
function fitFrame(lines, width, stablePrefix = 0) {
|
|
@@ -243,32 +601,267 @@ function fitFrame(lines, width, stablePrefix = 0) {
|
|
|
243
601
|
const transcriptBodyCache = new WeakMap();
|
|
244
602
|
/** Settled immutable blocks keep their expensive Markdown/tool layout. */
|
|
245
603
|
const blockLinesCache = new WeakMap();
|
|
246
|
-
function cachedBlockLines(block, options, theme, themeName, trueColor,
|
|
247
|
-
const animatedSpinnerFrame = block.kind === 'tool' && block.status === 'running' ? spinnerFrame : -1;
|
|
604
|
+
function cachedBlockLines(block, options, theme, themeName, trueColor, expanded, omitReasoning = false) {
|
|
248
605
|
const cached = blockLinesCache.get(block);
|
|
249
606
|
if (cached !== undefined
|
|
250
607
|
&& cached.width === options.width
|
|
251
608
|
&& cached.colors === options.colors
|
|
252
609
|
&& cached.trueColor === trueColor
|
|
253
610
|
&& cached.themeName === themeName
|
|
254
|
-
&& cached.
|
|
255
|
-
&& cached.
|
|
611
|
+
&& cached.expanded === expanded
|
|
612
|
+
&& cached.omitReasoning === omitReasoning
|
|
613
|
+
&& cached.mathMode === options.mathMode
|
|
614
|
+
&& cached.mermaidMode === options.mermaidMode
|
|
615
|
+
&& cached.toolDetailsKey === options.toolDetailsKey
|
|
616
|
+
&& cached.toolDetails === (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true))
|
|
256
617
|
return cached.lines;
|
|
257
|
-
const
|
|
618
|
+
const toolDetails = block.kind === 'tool' && options.expandedTools?.has(block.callId) === true;
|
|
619
|
+
const lines = block.kind === 'tool' && toolDetails
|
|
620
|
+
? toolBlockLines(block, theme, options.width, 'details', options.toolDetailsKey)
|
|
621
|
+
: blockLines(block, theme, options.width, { toolsExpanded: expanded, omitReasoning, toolDetailsKey: options.toolDetailsKey ?? 'Alt+O', mathMode: options.mathMode ?? 'auto', mermaidMode: options.mermaidMode ?? 'auto' });
|
|
258
622
|
blockLinesCache.set(block, {
|
|
623
|
+
mathMode: options.mathMode,
|
|
624
|
+
mermaidMode: options.mermaidMode,
|
|
625
|
+
toolDetailsKey: options.toolDetailsKey,
|
|
626
|
+
toolDetails,
|
|
259
627
|
width: options.width,
|
|
260
628
|
colors: options.colors,
|
|
261
629
|
trueColor,
|
|
262
630
|
themeName,
|
|
263
|
-
spinnerFrame: animatedSpinnerFrame,
|
|
264
631
|
expanded,
|
|
632
|
+
omitReasoning,
|
|
265
633
|
lines,
|
|
266
634
|
});
|
|
267
635
|
return lines;
|
|
268
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* What a stretch of the transcript was doing, as a short English label.
|
|
639
|
+
*
|
|
640
|
+
* Status strings are English until a language layer exists, so every label a
|
|
641
|
+
* reader can see is resolved from this one table rather than spelled into the
|
|
642
|
+
* rendering code. A tool with no family is reported by its own name, which is
|
|
643
|
+
* still more informative than dropping it.
|
|
644
|
+
*/
|
|
645
|
+
const PROCESS_LABELS = [
|
|
646
|
+
{ match: /^read$/, label: 'Read file' },
|
|
647
|
+
{ match: /^(read_image|readImage)$/, label: 'View image' },
|
|
648
|
+
{ match: /^(write|notebook_edit|apply_patch)$/, label: 'Write file' },
|
|
649
|
+
{ match: /^(edit|multi_edit|str_replace_editor)$/, label: 'Edit file' },
|
|
650
|
+
{ match: /^(grep|glob|list_dir|search)$/, label: 'Search code' },
|
|
651
|
+
{ match: /^(bash|shell|pwsh|exec|write_stdin)$/, label: 'Run command' },
|
|
652
|
+
{ match: /^(run_code)$/, label: 'Run code' },
|
|
653
|
+
{ match: /^(web_search)$/, label: 'Search web' },
|
|
654
|
+
{ match: /^(web_fetch)$/, label: 'Fetch page' },
|
|
655
|
+
{
|
|
656
|
+
match: /^(subagent|subagent_fork|subagent_isolated|agent|ralph|workflow_run)$/, label: 'Run subagent',
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
match: /^(session_search|session_trace|session_event_read|session_event_search|session_event_trace)$/,
|
|
660
|
+
label: 'Search sessions',
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
match: /^(todo_write|update_goal|create_goal|get_goal)$/, label: 'Update plan',
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
match: /^(ask_user_question|ask_user|user_question)$/, label: 'Ask question',
|
|
667
|
+
},
|
|
668
|
+
{ match: /^skill$/, label: 'Load skill' },
|
|
669
|
+
];
|
|
670
|
+
/**
|
|
671
|
+
* The phrase for one tool, or the tool's own name when it belongs to no
|
|
672
|
+
* category. An unknown tool still says what it is, which is honest; inventing a
|
|
673
|
+
* category for it would not be.
|
|
674
|
+
*/
|
|
675
|
+
function processLabel(name) {
|
|
676
|
+
for (const category of PROCESS_LABELS) {
|
|
677
|
+
if (category.match.test(name))
|
|
678
|
+
return category.label;
|
|
679
|
+
}
|
|
680
|
+
return name;
|
|
681
|
+
}
|
|
682
|
+
/** A group has to stand for repetition; one row is not repetition. */
|
|
683
|
+
const MIN_GROUP_BLOCKS = 2;
|
|
684
|
+
function blockIdentity(block, index) {
|
|
685
|
+
if (block.kind === 'tool')
|
|
686
|
+
return `tool:${block.callId}`;
|
|
687
|
+
if (block.kind === 'assistant')
|
|
688
|
+
return `assistant:${reasoningKey(block)}`;
|
|
689
|
+
// A run is keyed by its first block, and a run may begin with a process
|
|
690
|
+
// notice. Keying that by kind alone gave every such run in the document the
|
|
691
|
+
// same key, so opening one opened all of them and the reader's opened-run
|
|
692
|
+
// state bled across turns. The notice's durable source tells them apart; the
|
|
693
|
+
// index is the last resort for one that carries none, which still cannot
|
|
694
|
+
// collide with another notice's keyed run.
|
|
695
|
+
if (block.kind === 'notice' && block.process?.source !== undefined) {
|
|
696
|
+
return `notice:${block.process.source}`;
|
|
697
|
+
}
|
|
698
|
+
return `${block.kind}@${index}`;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Does this block belong to a turn's run, or stand on its own?
|
|
702
|
+
*
|
|
703
|
+
* Everything a turn does between the prompt and its answer is the run: every
|
|
704
|
+
* thought, every call, every reply the model wrote on the way, and the notices
|
|
705
|
+
* that report on that work — a retry, a background job settling. A thought used
|
|
706
|
+
* to close the run before it, which split a fifty-step turn into fifty runs;
|
|
707
|
+
* then a reply written mid-turn did the same, and a job notice split it again.
|
|
708
|
+
* The reference client folds the whole turn behind one line and leaves only the
|
|
709
|
+
* answer outside, and that is the shape a reader scrolling back wants: one row
|
|
710
|
+
* per turn, then what it concluded.
|
|
711
|
+
*
|
|
712
|
+
* An interrupted step is not work: it carries the notice that it stopped.
|
|
713
|
+
*/
|
|
714
|
+
function isProcessBlock(block) {
|
|
715
|
+
if (block.kind === 'tool')
|
|
716
|
+
return true;
|
|
717
|
+
if (block.kind === 'notice')
|
|
718
|
+
return block.process !== undefined;
|
|
719
|
+
return block.kind === 'assistant' && block.interrupted !== true;
|
|
720
|
+
}
|
|
721
|
+
function blockTurn(block) {
|
|
722
|
+
if (block.kind === 'tool' || block.kind === 'assistant')
|
|
723
|
+
return block.turn;
|
|
724
|
+
if (block.kind === 'notice')
|
|
725
|
+
return block.process?.turn;
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
function isLiveBlock(block) {
|
|
729
|
+
return (block.kind === 'tool' && block.status === 'running')
|
|
730
|
+
|| (block.kind === 'assistant' && block.streaming && block.text === '');
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Project the transcript's turns into runs without touching the block list.
|
|
734
|
+
*
|
|
735
|
+
* A stretch of process blocks from one turn is one run. If the stretch ends on
|
|
736
|
+
* a reply, that reply is the turn's answer and stays outside; any reply before
|
|
737
|
+
* it was written on the way and is part of the run. A prompt, the per-turn
|
|
738
|
+
* changed-file record, or any notice that speaks to the reader ends the stretch,
|
|
739
|
+
* and so does a block from another turn — two turns with no prompt between
|
|
740
|
+
* them, such as a goal continuing itself, still read as two runs.
|
|
741
|
+
*
|
|
742
|
+
* The block list is deliberately left alone: a group is a rendering decision, so
|
|
743
|
+
* search, focus, and the row offsets keep addressing real blocks, and a group
|
|
744
|
+
* that is still growing cannot invalidate anything a reader already saw.
|
|
745
|
+
*/
|
|
746
|
+
export function processGroups(blocks) {
|
|
747
|
+
const groups = [];
|
|
748
|
+
let start = -1;
|
|
749
|
+
let segmentTurn;
|
|
750
|
+
const flush = (end) => {
|
|
751
|
+
if (start < 0)
|
|
752
|
+
return;
|
|
753
|
+
// The turn's answer is the stretch's last reply, and process work can
|
|
754
|
+
// settle after it: a background job finishes, a retry is reported, both
|
|
755
|
+
// reported as process blocks precisely so they join the run instead of
|
|
756
|
+
// splitting it. Those trailing blocks are the run's work but not its
|
|
757
|
+
// conclusion, so the run ends at the answer and they follow it as rows of
|
|
758
|
+
// their own. Reading only the stretch's last block mistook them for the
|
|
759
|
+
// answer's absence and folded the answer itself into the run, where a
|
|
760
|
+
// folded standard transcript hid the whole reply.
|
|
761
|
+
//
|
|
762
|
+
// The last reply wins, not the first one met going back: a turn's earlier
|
|
763
|
+
// replies were written on the way and belong to the run, so scanning back
|
|
764
|
+
// from the end and taking the first reply would fold the one reply the
|
|
765
|
+
// model wrote mid-turn and leave the real answer outside it.
|
|
766
|
+
let answer;
|
|
767
|
+
for (let at = start; at < end; at += 1) {
|
|
768
|
+
const block = blocks[at];
|
|
769
|
+
if (block?.kind === 'assistant' && block.text !== '')
|
|
770
|
+
answer = at;
|
|
771
|
+
else if (block !== undefined && !isProcessBlock(block))
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
const memberEnd = answer ?? end;
|
|
775
|
+
const last = blocks[answer ?? end - 1];
|
|
776
|
+
const absorbs = answer !== undefined && last?.kind === 'assistant' && last.reasoning !== '' ? answer : undefined;
|
|
777
|
+
const size = memberEnd - start + (absorbs === undefined ? 0 : 1);
|
|
778
|
+
const answerIndex = answer;
|
|
779
|
+
if (size >= MIN_GROUP_BLOCKS || (size > 0 && segmentTurn !== undefined)) {
|
|
780
|
+
let calls = 0;
|
|
781
|
+
let failures = 0;
|
|
782
|
+
let live = false;
|
|
783
|
+
// The whole stretch counts, not just the part before the answer: a
|
|
784
|
+
// background job that settles behind the reply is still work this run
|
|
785
|
+
// did, and a failed call among them is still a failure. Stopping at the
|
|
786
|
+
// answer left the header saying fewer calls than the reader could see,
|
|
787
|
+
// and said nothing about a call that failed after it.
|
|
788
|
+
for (let at = start; at < end; at += 1) {
|
|
789
|
+
const block = blocks[at];
|
|
790
|
+
if (isLiveBlock(block))
|
|
791
|
+
live = true;
|
|
792
|
+
if (block.kind === 'notice' && block.level === 'error')
|
|
793
|
+
failures += 1;
|
|
794
|
+
if (block.kind !== 'tool')
|
|
795
|
+
continue;
|
|
796
|
+
calls += 1;
|
|
797
|
+
if (block.status === 'error')
|
|
798
|
+
failures += 1;
|
|
799
|
+
}
|
|
800
|
+
const turn = segmentTurn ?? (blocks[end]?.kind === 'workspace' ? blocks[end].turn : undefined);
|
|
801
|
+
groups.push({
|
|
802
|
+
start,
|
|
803
|
+
until: end,
|
|
804
|
+
...(answerIndex === undefined ? {} : { answer: answerIndex }),
|
|
805
|
+
// The run took this step's thought as its last row, so its words are
|
|
806
|
+
// painted without it. A separate flag rather than a second index: the
|
|
807
|
+
// block is always the answer.
|
|
808
|
+
tookThought: absorbs !== undefined,
|
|
809
|
+
calls,
|
|
810
|
+
failures,
|
|
811
|
+
live,
|
|
812
|
+
...(turn === undefined ? {} : { turn }),
|
|
813
|
+
key: blockIdentity(blocks[start], start),
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
start = -1;
|
|
817
|
+
segmentTurn = undefined;
|
|
818
|
+
};
|
|
819
|
+
blocks.forEach((block, index) => {
|
|
820
|
+
if (!isProcessBlock(block)) {
|
|
821
|
+
flush(index);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const turn = blockTurn(block);
|
|
825
|
+
if (start >= 0 && turn !== undefined && segmentTurn !== undefined && turn !== segmentTurn)
|
|
826
|
+
flush(index);
|
|
827
|
+
if (start < 0)
|
|
828
|
+
start = index;
|
|
829
|
+
segmentTurn ??= turn;
|
|
830
|
+
});
|
|
831
|
+
flush(blocks.length);
|
|
832
|
+
return groups;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* The runs as the reader sees them now: {@link processGroups}, with the run a
|
|
836
|
+
* running turn is still adding to marked live.
|
|
837
|
+
*
|
|
838
|
+
* That run is live even between two events, when nothing is in flight — the
|
|
839
|
+
* model is choosing what to do next — and while the answer streams below it,
|
|
840
|
+
* so it becomes a group once, when the turn ends, rather than folding and
|
|
841
|
+
* unfolding at every pause. The host aims Ctrl+O with the same projection the
|
|
842
|
+
* renderer paints, so the two cannot disagree about what is folded.
|
|
843
|
+
*/
|
|
844
|
+
export function turnGroups(state) {
|
|
845
|
+
const groups = processGroups(state.blocks);
|
|
846
|
+
if (state.status === 'idle')
|
|
847
|
+
return groups;
|
|
848
|
+
const last = state.blocks.length;
|
|
849
|
+
return groups.map(group => {
|
|
850
|
+
// `until` is the stretch's true end, including work that settled after the
|
|
851
|
+
// answer; comparing `end` would call a run with trailing work settled
|
|
852
|
+
// before the transcript's tail, and it would fold while the turn ran.
|
|
853
|
+
const trailing = group.until === last
|
|
854
|
+
|| (group.until === last - 1 && state.blocks[group.until]?.kind === 'assistant');
|
|
855
|
+
const current = (group.turn === state.turn || (state.turn === 0 && trailing))
|
|
856
|
+
&& state.turnSpans?.[state.turn]?.end === undefined;
|
|
857
|
+
return (current || (group.turn === undefined && trailing)) && !group.live ? { ...group, live: true } : group;
|
|
858
|
+
});
|
|
859
|
+
}
|
|
269
860
|
function renderTranscriptBody(state, options, theme, spinnerFrame) {
|
|
270
861
|
const toolsExpanded = options.toolsExpanded === true;
|
|
271
862
|
const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
|
|
863
|
+
const expandedReasoning = [...(options.expandedReasoning ?? [])].sort().join('\0');
|
|
864
|
+
const openedGroups = [...(options.openedGroups ?? [])].sort().join('\0');
|
|
272
865
|
const themeName = options.themeName ?? 'dark';
|
|
273
866
|
const trueColor = options.trueColor === true;
|
|
274
867
|
const animatedSpinnerFrame = state.blocks.some(block => block.kind === 'tool' && block.status === 'running')
|
|
@@ -285,36 +878,279 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
|
|
|
285
878
|
&& cached.trueColor === trueColor
|
|
286
879
|
&& cached.themeName === themeName
|
|
287
880
|
&& cached.spinnerFrame === animatedSpinnerFrame
|
|
881
|
+
&& cached.mathMode === options.mathMode
|
|
882
|
+
&& cached.mermaidMode === options.mermaidMode
|
|
288
883
|
&& cached.toolsExpanded === toolsExpanded
|
|
289
884
|
&& cached.expandedTools === expandedTools
|
|
290
|
-
&& cached.
|
|
291
|
-
|
|
885
|
+
&& cached.expandedReasoning === expandedReasoning
|
|
886
|
+
&& cached.openedGroups === openedGroups
|
|
887
|
+
&& cached.toolDetailsKey === options.toolDetailsKey
|
|
888
|
+
&& cached.turnSpans === state.turnSpans
|
|
889
|
+
&& cached.turnDetailsKey === options.turnDetailsKey
|
|
890
|
+
&& cached.searchKey === searchKey
|
|
891
|
+
&& cached.status === state.status
|
|
892
|
+
&& cached.focusBlock === options.focusBlock) {
|
|
893
|
+
return {
|
|
894
|
+
lines: cached.lines,
|
|
895
|
+
blockStarts: cached.blockStarts,
|
|
896
|
+
blockDrawStarts: cached.blockDrawStarts,
|
|
897
|
+
foldShape: cached.foldShape,
|
|
898
|
+
foldMarks: cached.foldMarks,
|
|
899
|
+
liveFrom: cached.liveFrom,
|
|
900
|
+
};
|
|
292
901
|
}
|
|
293
902
|
const matches = new Set(search?.matches ?? []);
|
|
294
903
|
const lines = [];
|
|
295
904
|
const blockStarts = [];
|
|
905
|
+
// Where each block is drawn, apart from the offsets above: a search can aim a
|
|
906
|
+
// block's offset at the thought it also carries, but its drawn span does not
|
|
907
|
+
// move, and the boundary mapper needs the drawn one.
|
|
908
|
+
const blockDrawStarts = [];
|
|
296
909
|
let previous;
|
|
910
|
+
// A group is a rendering decision, so a block keeps its own identity and row
|
|
911
|
+
// offset whether or not the group around it is open. A collapsed group
|
|
912
|
+
// therefore owns the rows of its first block and nothing after it, which is
|
|
913
|
+
// why anything a reader can address — a search match, the focused block — has
|
|
914
|
+
// to force its own group open.
|
|
915
|
+
const groups = turnGroups(state);
|
|
916
|
+
const groupOf = new Int32Array(state.blocks.length).fill(-1);
|
|
917
|
+
const openGroups = new Set();
|
|
918
|
+
const absorbed = new Set();
|
|
919
|
+
// Thoughts a search hit has to show in full, beyond what the reader opened.
|
|
920
|
+
const openedThoughts = new Set(options.expandedReasoning ?? []);
|
|
921
|
+
// Where each answer's thought is actually drawn, for its fold mark. The
|
|
922
|
+
// navigation offset below may point elsewhere; the mark must not follow it.
|
|
923
|
+
const drawnThoughts = new Map();
|
|
924
|
+
const lastOfTurn = new Map();
|
|
925
|
+
groups.forEach((group, index) => {
|
|
926
|
+
for (let at = group.start; at < group.until; at += 1)
|
|
927
|
+
groupOf[at] = index;
|
|
928
|
+
// The turn's answer is painted below the run, not inside it. Leaving it
|
|
929
|
+
// claimed here would route the reply through the run's body and drop it
|
|
930
|
+
// from a folded transcript, which is what a reader most needs to see.
|
|
931
|
+
if (group.answer !== undefined && group.answer !== group.start)
|
|
932
|
+
groupOf[group.answer] = -1;
|
|
933
|
+
if (group.tookThought && group.answer !== undefined)
|
|
934
|
+
absorbed.add(group.answer);
|
|
935
|
+
if (group.turn !== undefined)
|
|
936
|
+
lastOfTurn.set(group.turn, index);
|
|
937
|
+
const reasoningHit = group.tookThought && group.answer !== undefined
|
|
938
|
+
&& search !== undefined && matches.has(group.answer)
|
|
939
|
+
&& blockMatchesQuery(state.blocks[group.answer].reasoning, search.query);
|
|
940
|
+
// A hit inside the thought the run took needs that thought laid out in
|
|
941
|
+
// full. Opening only the run shows a one-line preview, so the matched text
|
|
942
|
+
// stays out of sight while the transcript reports the hit. This is a render
|
|
943
|
+
// decision, not the reader's own state, so it goes in a local set rather
|
|
944
|
+
// than into the options the host owns.
|
|
945
|
+
if (reasoningHit && group.answer !== undefined) {
|
|
946
|
+
openedThoughts.add(reasoningKey(state.blocks[group.answer]));
|
|
947
|
+
}
|
|
948
|
+
const asked = toolsExpanded
|
|
949
|
+
|| options.openedGroups?.has(group.key) === true
|
|
950
|
+
|| state.blocks.slice(group.start, group.until).some(block => block.kind === 'tool' && options.expandedTools?.has(block.callId))
|
|
951
|
+
|| reasoningHit
|
|
952
|
+
|| [...matches].some(index => index >= group.start && index < group.until)
|
|
953
|
+
|| options.focusBlock !== undefined
|
|
954
|
+
&& options.focusBlock >= group.start && options.focusBlock < group.until;
|
|
955
|
+
if (asked)
|
|
956
|
+
openGroups.add(index);
|
|
957
|
+
});
|
|
958
|
+
// Every surface that is open, keyed and placed, so the host can tell where a
|
|
959
|
+
// change of shape starts. Global switches reshape from the top.
|
|
960
|
+
const foldMarks = [];
|
|
961
|
+
if (toolsExpanded)
|
|
962
|
+
foldMarks.push({ key: 'all', row: 0 });
|
|
963
|
+
let liveFrom;
|
|
964
|
+
const context = {
|
|
965
|
+
blocks: state.blocks, theme, themeName, trueColor, width: options.width,
|
|
966
|
+
matches, options: { ...options, expandedReasoning: openedThoughts },
|
|
967
|
+
};
|
|
968
|
+
const compactProcessRow = (block) => {
|
|
969
|
+
if (block?.kind === 'notice')
|
|
970
|
+
return block.process !== undefined && block.framed !== true;
|
|
971
|
+
return block?.kind === 'tool' && !toolsExpanded && !options.expandedTools?.has(block.callId);
|
|
972
|
+
};
|
|
973
|
+
// Only a turn's last run is summarized by how long the turn took; an earlier
|
|
974
|
+
// run in the same turn did not take all of it.
|
|
975
|
+
const elapsedOf = (index) => {
|
|
976
|
+
const group = groups[index];
|
|
977
|
+
if (group.live || group.turn === undefined || lastOfTurn.get(group.turn) !== index)
|
|
978
|
+
return undefined;
|
|
979
|
+
const span = state.turnSpans?.[group.turn];
|
|
980
|
+
return span?.end === undefined ? undefined : formatElapsed(span.end - span.start);
|
|
981
|
+
};
|
|
297
982
|
for (let index = 0; index < state.blocks.length; index += 1) {
|
|
298
983
|
const block = state.blocks[index];
|
|
299
|
-
|
|
984
|
+
const groupIndex = groupOf[index] ?? -1;
|
|
985
|
+
if (groupIndex >= 0) {
|
|
986
|
+
// A run is painted whole by its first block: the header, then its rows
|
|
987
|
+
// when open. A collapsed run still shows the rows that failed, because a
|
|
988
|
+
// header saying only "something failed" would hide the row that says
|
|
989
|
+
// what — and forcing the whole run open for one failed grep would undo
|
|
990
|
+
// the fold for the most common failure there is.
|
|
991
|
+
const group = groups[groupIndex];
|
|
992
|
+
if (lines.length > 0)
|
|
993
|
+
lines.push('');
|
|
994
|
+
const headerRow = lines.length;
|
|
995
|
+
const opened = openGroups.has(groupIndex);
|
|
996
|
+
const shape = group.live ? 'live' : opened ? 'open' : 'folded';
|
|
997
|
+
foldMarks.push({ key: `run:${group.key}:${shape}`, row: headerRow });
|
|
998
|
+
if (group.live)
|
|
999
|
+
liveFrom ??= headerRow;
|
|
1000
|
+
// A live run has no header: it becomes a group when its turn ends.
|
|
1001
|
+
if (!group.live && !opened) {
|
|
1002
|
+
lines.push(...processGroupHeader(group, theme, options.width, elapsedOf(groupIndex), options.turnDetailsKey));
|
|
1003
|
+
}
|
|
1004
|
+
const bodyRow = lines.length;
|
|
1005
|
+
let offsets = new Map();
|
|
1006
|
+
if (group.live || opened) {
|
|
1007
|
+
const body = processGroupBody(group, context);
|
|
1008
|
+
for (const line of body.lines)
|
|
1009
|
+
lines.push(line);
|
|
1010
|
+
offsets = body.offsets;
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
for (let at = group.start; at < (group.answer ?? group.until); at += 1) {
|
|
1014
|
+
const member = state.blocks[at];
|
|
1015
|
+
if (!(member.kind === 'tool' && member.status === 'error')
|
|
1016
|
+
&& !(member.kind === 'notice' && member.level === 'error'))
|
|
1017
|
+
continue;
|
|
1018
|
+
offsets.set(at, lines.length - bodyRow);
|
|
1019
|
+
lines.push(...(member.kind === 'tool' ? toolRowLine(member, theme, options.width, spinnerFrame) : groupMemberLines(at, context)));
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// The turn's answer, painted as a row of its own between the run's work
|
|
1023
|
+
// and the work that settled behind it, in the order the events arrived.
|
|
1024
|
+
// Painting it last would read as though the run concluded after the
|
|
1025
|
+
// notice, and — worse — would give the answer and the notice each other's
|
|
1026
|
+
// row, so a reader aiming at one landed on the other.
|
|
1027
|
+
if (group.answer !== undefined) {
|
|
1028
|
+
if (lines.length > 0)
|
|
1029
|
+
lines.push('');
|
|
1030
|
+
// The run painted this step's thought above; keep its row. The thought's
|
|
1031
|
+
// row is where it is *drawn*, which is not the same as where a search
|
|
1032
|
+
// should put the reader: a query in the reply aims at the reply, a query
|
|
1033
|
+
// in the thought at the thought. The draw position is what a fold mark
|
|
1034
|
+
// has to report, so it is kept apart from the navigation offset below
|
|
1035
|
+
// rather than being overwritten by it.
|
|
1036
|
+
const thoughtHit = group.tookThought
|
|
1037
|
+
&& search !== undefined && matches.has(group.answer)
|
|
1038
|
+
&& !blockMatchesQuery(state.blocks[group.answer].text, search.query);
|
|
1039
|
+
const thoughtRow = offsets.get(group.answer);
|
|
1040
|
+
drawnThoughts.set(group.answer, thoughtRow);
|
|
1041
|
+
// The answer's own drawn start is its words, never the thought above
|
|
1042
|
+
// them — a search aims `offsets` at the thought so the reader lands on
|
|
1043
|
+
// the match, and the boundary mapper must not mistake that for the
|
|
1044
|
+
// answer starting earlier.
|
|
1045
|
+
blockDrawStarts[group.answer] = lines.length;
|
|
1046
|
+
offsets.set(group.answer, lines.length - bodyRow);
|
|
1047
|
+
const answer = state.blocks[group.answer];
|
|
1048
|
+
const rendered = cachedBlockLines(answer, options, theme, themeName, trueColor, toolsExpanded, absorbed.has(group.answer));
|
|
1049
|
+
for (const line of (search !== undefined && matches.has(group.answer)
|
|
1050
|
+
? rendered.map(row => blockMatchesQuery(stripAnsi(row), search.query) ? theme.inverse(row) : row)
|
|
1051
|
+
: rendered))
|
|
1052
|
+
lines.push(line);
|
|
1053
|
+
if (thoughtHit && thoughtRow !== undefined)
|
|
1054
|
+
blockStarts[group.answer] = bodyRow + thoughtRow;
|
|
1055
|
+
if (thoughtHit && thoughtRow !== undefined)
|
|
1056
|
+
offsets.set(group.answer, thoughtRow);
|
|
1057
|
+
// One block, two rows: the run painted its thought above, this painted
|
|
1058
|
+
// its words below. A search hit can be in either, and the offset has to
|
|
1059
|
+
// lead to the one that matched — aiming at the reply left a long
|
|
1060
|
+
// thought's match above the viewport, found and not readable.
|
|
1061
|
+
}
|
|
1062
|
+
// Work that settled after the answer still belongs to the run, so it is
|
|
1063
|
+
// painted as the run's rows directly beneath the reply rather than as
|
|
1064
|
+
// loose paragraphs at the page's left margin.
|
|
1065
|
+
for (let at = group.answer === undefined ? group.until : group.answer + 1; at < group.until; at += 1) {
|
|
1066
|
+
// Appended one at a time: a long run's member can be a very large
|
|
1067
|
+
// array, and spreading it into a call overflows the argument limit.
|
|
1068
|
+
const trailing = state.blocks[at];
|
|
1069
|
+
const failed = (trailing?.kind === 'notice' && trailing.level === 'error')
|
|
1070
|
+
|| (trailing?.kind === 'tool' && trailing.status === 'error');
|
|
1071
|
+
if (group.live || opened || failed) {
|
|
1072
|
+
if (lines.length > 0 && !(trailing?.kind === 'notice' && state.blocks[at - 1]?.kind === 'notice'))
|
|
1073
|
+
lines.push('');
|
|
1074
|
+
offsets.set(at, lines.length - bodyRow);
|
|
1075
|
+
const rows = !group.live && !opened && trailing?.kind === 'tool'
|
|
1076
|
+
? toolRowLine(trailing, theme, options.width, spinnerFrame)
|
|
1077
|
+
: groupMemberLines(at, context);
|
|
1078
|
+
for (const line of rows)
|
|
1079
|
+
lines.push(line);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// A member the body did not paint answers to the header, which is the row
|
|
1083
|
+
// a reader aiming at it would land on anyway. Offsets are assigned by
|
|
1084
|
+
// block index, never by the order the rows were painted: the two differ
|
|
1085
|
+
// once a run carries an answer with work behind it, and a shifted offset
|
|
1086
|
+
// aims a search hit or a Ctrl+O at the wrong row.
|
|
1087
|
+
for (let at = group.start; at < group.until; at += 1) {
|
|
1088
|
+
const offset = offsets.get(at);
|
|
1089
|
+
const row = offset === undefined ? headerRow : bodyRow + offset;
|
|
1090
|
+
blockStarts[at] = row;
|
|
1091
|
+
const member = state.blocks[at];
|
|
1092
|
+
// Not `offsets`: a search may have aimed that at the thought, and this
|
|
1093
|
+
// array is the blocks' own drawn positions.
|
|
1094
|
+
if (blockDrawStarts[at] === undefined) {
|
|
1095
|
+
const drawn = offsets.get(at);
|
|
1096
|
+
blockDrawStarts[at] = drawn === undefined ? headerRow : bodyRow + drawn;
|
|
1097
|
+
}
|
|
1098
|
+
if (member.kind === 'tool' && options.expandedTools?.has(member.callId) === true) {
|
|
1099
|
+
foldMarks.push({ key: `call:${member.callId}`, row });
|
|
1100
|
+
}
|
|
1101
|
+
// The effective set, not the host's: a search can open a thought the
|
|
1102
|
+
// reader never asked for, and the rows that adds still reshaped. The
|
|
1103
|
+
// key is the same one the host already uses for a thought it opened, so
|
|
1104
|
+
// the two causes share one identity and neither is encoded in it.
|
|
1105
|
+
if (member.kind === 'assistant' && openedThoughts.has(reasoningKey(member))) {
|
|
1106
|
+
const drawn = drawnThoughts.get(at);
|
|
1107
|
+
foldMarks.push({ key: `thought:${reasoningKey(member)}`, row: drawn === undefined ? row : bodyRow + drawn });
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// Everything this run owned is painted, so the walk resumes past it.
|
|
1111
|
+
index = group.until - 1;
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
else if (lines.length > 0) {
|
|
300
1115
|
const previousCommand = commandSurfaceName(previous);
|
|
301
1116
|
const currentCommand = commandSurfaceName(block);
|
|
302
1117
|
if (previousCommand !== undefined && currentCommand !== undefined && previousCommand !== currentCommand) {
|
|
303
1118
|
lines.push('', renderCommandSeparator(theme, options.width), '');
|
|
304
1119
|
}
|
|
305
|
-
else {
|
|
1120
|
+
else if (!compactProcessRow(block) || !compactProcessRow(state.blocks[index - 1])) {
|
|
306
1121
|
lines.push('');
|
|
307
1122
|
}
|
|
308
1123
|
}
|
|
309
|
-
blockStarts
|
|
310
|
-
|
|
311
|
-
|
|
1124
|
+
blockStarts[index] = lines.length;
|
|
1125
|
+
if (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true) {
|
|
1126
|
+
foldMarks.push({ key: `call:${block.callId}`, row: lines.length });
|
|
1127
|
+
}
|
|
1128
|
+
if (block.kind === 'assistant' && options.expandedReasoning?.has(reasoningKey(block)) === true) {
|
|
1129
|
+
foldMarks.push({ key: `thought:${reasoningKey(block)}`, row: lines.length });
|
|
1130
|
+
}
|
|
1131
|
+
const liveBlock = state.status !== 'idle' && isProcessBlock(block)
|
|
1132
|
+
&& (block.kind !== 'assistant' || block.reasoning !== '')
|
|
1133
|
+
&& (blockTurn(block) === undefined || blockTurn(block) === state.turn);
|
|
1134
|
+
if (liveBlock)
|
|
1135
|
+
liveFrom ??= lines.length;
|
|
1136
|
+
const expanded = (block.kind === 'workspace' && groups.some((group, at) => group.turn === block.turn && openGroups.has(at))) || matches.has(index) || toolsExpanded
|
|
1137
|
+
|| (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
|
|
1138
|
+
const rendered = cachedBlockLines(block, options, theme, themeName, trueColor, expanded, absorbed.has(index));
|
|
312
1139
|
lines.push(...(search !== undefined && matches.has(index)
|
|
313
1140
|
? rendered.map(line => blockMatchesQuery(stripAnsi(line), search.query) ? theme.inverse(line) : line)
|
|
314
1141
|
: rendered));
|
|
1142
|
+
blockDrawStarts[index] = blockStarts[index];
|
|
315
1143
|
previous = block;
|
|
316
1144
|
}
|
|
1145
|
+
// Streaming text and a call settling change rows in place without changing
|
|
1146
|
+
// this; opening, folding, or a run settling at turn end do.
|
|
1147
|
+
const foldShape = foldMarks.map(mark => mark.key).join('\u0001');
|
|
317
1148
|
transcriptBodyCache.set(state.blocks, {
|
|
1149
|
+
mathMode: options.mathMode,
|
|
1150
|
+
mermaidMode: options.mermaidMode,
|
|
1151
|
+
toolDetailsKey: options.toolDetailsKey,
|
|
1152
|
+
turnDetailsKey: options.turnDetailsKey,
|
|
1153
|
+
blockDrawStarts,
|
|
318
1154
|
width: options.width,
|
|
319
1155
|
colors: options.colors,
|
|
320
1156
|
trueColor,
|
|
@@ -322,11 +1158,19 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
|
|
|
322
1158
|
spinnerFrame: animatedSpinnerFrame,
|
|
323
1159
|
toolsExpanded,
|
|
324
1160
|
expandedTools,
|
|
1161
|
+
expandedReasoning,
|
|
1162
|
+
openedGroups,
|
|
325
1163
|
searchKey,
|
|
1164
|
+
turnSpans: state.turnSpans,
|
|
1165
|
+
status: state.status,
|
|
1166
|
+
focusBlock: options.focusBlock,
|
|
1167
|
+
foldShape,
|
|
1168
|
+
foldMarks,
|
|
1169
|
+
liveFrom,
|
|
326
1170
|
lines,
|
|
327
1171
|
blockStarts,
|
|
328
1172
|
});
|
|
329
|
-
return { lines, blockStarts };
|
|
1173
|
+
return { lines, blockStarts, blockDrawStarts, foldShape, foldMarks, liveFrom };
|
|
330
1174
|
}
|
|
331
1175
|
function commandSurfaceName(block) {
|
|
332
1176
|
if (block?.kind === 'toolCatalog')
|
|
@@ -337,6 +1181,15 @@ function commandSurfaceName(block) {
|
|
|
337
1181
|
}
|
|
338
1182
|
/** Rows moved per Shift+Arrow (OMP ScrollView `fastScrollLines`). */
|
|
339
1183
|
export const TRANSCRIPT_FAST_SCROLL = 5;
|
|
1184
|
+
const promptSummaryCache = new WeakMap();
|
|
1185
|
+
function promptSummary(block) {
|
|
1186
|
+
const cached = promptSummaryCache.get(block);
|
|
1187
|
+
if (cached !== undefined)
|
|
1188
|
+
return cached;
|
|
1189
|
+
const summary = block.text.replace(/\s+/gu, ' ').trim();
|
|
1190
|
+
promptSummaryCache.set(block, summary);
|
|
1191
|
+
return summary;
|
|
1192
|
+
}
|
|
340
1193
|
function earlierLabel(count) {
|
|
341
1194
|
return '… ↑ ' + count + ' earlier line' + (count === 1 ? '' : 's') + ' ⟨Pg↑⟩';
|
|
342
1195
|
}
|
|
@@ -346,14 +1199,22 @@ function laterLabel(count) {
|
|
|
346
1199
|
/**
|
|
347
1200
|
* Window `body` into `budget` rows with ↑/↓ overflow markers.
|
|
348
1201
|
* `start` is the first body row to keep; non-finite values pin to the tail.
|
|
1202
|
+
*
|
|
1203
|
+
* `documentRows` says which of `body`'s rows the returned lines are, and where
|
|
1204
|
+
* in the returned lines they start. A window that is nothing but an overflow
|
|
1205
|
+
* marker carries none: `null`, because the frame drew no body rather than
|
|
1206
|
+
* because the caller forgot to say.
|
|
349
1207
|
*/
|
|
350
1208
|
export function windowTranscript(body, budget, start, theme) {
|
|
351
1209
|
const len = body.length;
|
|
352
1210
|
if (budget <= 0) {
|
|
353
|
-
return { lines: [], start: 0, maxStart: 0, budget, hiddenAbove: len, hiddenBelow: 0 };
|
|
1211
|
+
return { lines: [], start: 0, maxStart: 0, budget, hiddenAbove: len, hiddenBelow: 0, documentRows: null };
|
|
354
1212
|
}
|
|
355
1213
|
if (len <= budget) {
|
|
356
|
-
return {
|
|
1214
|
+
return {
|
|
1215
|
+
lines: [...body], start: 0, maxStart: 0, budget, hiddenAbove: 0, hiddenBelow: 0,
|
|
1216
|
+
documentRows: { documentStart: 0, documentEnd: len, frameStart: 0 },
|
|
1217
|
+
};
|
|
357
1218
|
}
|
|
358
1219
|
const maxStart = Math.max(0, len - (budget - 1));
|
|
359
1220
|
const s = Number.isFinite(start) ? Math.max(0, Math.min(Math.trunc(start), maxStart)) : maxStart;
|
|
@@ -372,6 +1233,7 @@ export function windowTranscript(body, budget, start, theme) {
|
|
|
372
1233
|
budget,
|
|
373
1234
|
hiddenAbove: atTail ? len : s,
|
|
374
1235
|
hiddenBelow: atTail ? 0 : len - s,
|
|
1236
|
+
documentRows: null,
|
|
375
1237
|
};
|
|
376
1238
|
}
|
|
377
1239
|
if (atTail) {
|
|
@@ -379,6 +1241,7 @@ export function windowTranscript(body, budget, start, theme) {
|
|
|
379
1241
|
const hiddenAbove = len - take;
|
|
380
1242
|
return {
|
|
381
1243
|
lines: [theme.fg('dim', earlierLabel(hiddenAbove)), ...body.slice(hiddenAbove)],
|
|
1244
|
+
documentRows: { documentStart: hiddenAbove, documentEnd: len, frameStart: 1 },
|
|
382
1245
|
start: hiddenAbove,
|
|
383
1246
|
maxStart,
|
|
384
1247
|
budget,
|
|
@@ -391,6 +1254,7 @@ export function windowTranscript(body, budget, start, theme) {
|
|
|
391
1254
|
const hiddenBelow = len - take;
|
|
392
1255
|
return {
|
|
393
1256
|
lines: [...body.slice(0, take), theme.fg('dim', laterLabel(hiddenBelow))],
|
|
1257
|
+
documentRows: { documentStart: 0, documentEnd: take, frameStart: 0 },
|
|
394
1258
|
start: 0,
|
|
395
1259
|
maxStart,
|
|
396
1260
|
budget,
|
|
@@ -401,6 +1265,7 @@ export function windowTranscript(body, budget, start, theme) {
|
|
|
401
1265
|
if (budget === 2) {
|
|
402
1266
|
return {
|
|
403
1267
|
lines: [theme.fg('dim', earlierLabel(s)), body[s] ?? ''],
|
|
1268
|
+
documentRows: { documentStart: s, documentEnd: s + 1, frameStart: 1 },
|
|
404
1269
|
start: s,
|
|
405
1270
|
maxStart,
|
|
406
1271
|
budget,
|
|
@@ -416,6 +1281,7 @@ export function windowTranscript(body, budget, start, theme) {
|
|
|
416
1281
|
...body.slice(s, s + take),
|
|
417
1282
|
theme.fg('dim', laterLabel(hiddenBelow)),
|
|
418
1283
|
],
|
|
1284
|
+
documentRows: { documentStart: s, documentEnd: s + take, frameStart: 1 },
|
|
419
1285
|
start: s,
|
|
420
1286
|
maxStart,
|
|
421
1287
|
budget,
|
|
@@ -640,6 +1506,17 @@ export function renderTurnError(text, theme, width) {
|
|
|
640
1506
|
+ (suffix === '' ? '' : theme.fg('dim', suffix));
|
|
641
1507
|
return [padToWidth(line, width)];
|
|
642
1508
|
}
|
|
1509
|
+
/** Human guidance remains visible until the Harness claims it for a model step. */
|
|
1510
|
+
export function renderSteering(inbox, theme, width) {
|
|
1511
|
+
const messages = inbox.filter(message => message.source.kind === 'user');
|
|
1512
|
+
if (messages.length === 0 || width <= 0)
|
|
1513
|
+
return [];
|
|
1514
|
+
const latest = queuedMessageLabel(messages.at(-1));
|
|
1515
|
+
const count = messages.length > 1 ? ` · ${messages.length}` : '';
|
|
1516
|
+
return [truncateToWidth(' ' + theme.fg('border', '│') + ' '
|
|
1517
|
+
+ theme.bold(theme.fg('accent', 'Guidance · next step' + count))
|
|
1518
|
+
+ theme.fg('dim', ' · ') + theme.fg('text', latest), width)];
|
|
1519
|
+
}
|
|
643
1520
|
/** Compact, unframed pending-message view placed immediately above the composer. */
|
|
644
1521
|
export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
|
|
645
1522
|
const labels = [
|
|
@@ -664,9 +1541,9 @@ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
|
|
|
664
1541
|
return paintedLeft + ' ' + action;
|
|
665
1542
|
};
|
|
666
1543
|
if (labels.length === 1) {
|
|
667
|
-
return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
|
|
1544
|
+
return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('dim', 'next turn · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
|
|
668
1545
|
}
|
|
669
|
-
const summary = ` · ${labels.length}${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
|
|
1546
|
+
const summary = ` · ${labels.length} · next turn${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
|
|
670
1547
|
const visibleLabels = labels.slice(start);
|
|
671
1548
|
const lines = [
|
|
672
1549
|
alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', '↑ edit latest'), false),
|
|
@@ -697,6 +1574,8 @@ export function renderView(state, options) {
|
|
|
697
1574
|
lines: fitFrame(hub.lines, width),
|
|
698
1575
|
cursor: hub.cursor,
|
|
699
1576
|
cursorVisible: hub.cursorVisible,
|
|
1577
|
+
// A full-screen surface drew no transcript body.
|
|
1578
|
+
documentRows: null,
|
|
700
1579
|
};
|
|
701
1580
|
}
|
|
702
1581
|
if (options.trajectory !== undefined) {
|
|
@@ -705,6 +1584,8 @@ export function renderView(state, options) {
|
|
|
705
1584
|
lines: fitFrame(trajectory.lines, width),
|
|
706
1585
|
cursor: trajectory.cursor,
|
|
707
1586
|
cursorVisible: trajectory.cursorVisible,
|
|
1587
|
+
// A full-screen surface drew no transcript body.
|
|
1588
|
+
documentRows: null,
|
|
708
1589
|
};
|
|
709
1590
|
}
|
|
710
1591
|
if (options.settings !== undefined && options.promptSelector === undefined) {
|
|
@@ -713,22 +1594,30 @@ export function renderView(state, options) {
|
|
|
713
1594
|
lines: fitFrame(settings.lines, width),
|
|
714
1595
|
cursor: settings.cursor,
|
|
715
1596
|
cursorVisible: false,
|
|
1597
|
+
// A full-screen surface drew no transcript body.
|
|
1598
|
+
documentRows: null,
|
|
716
1599
|
};
|
|
717
1600
|
}
|
|
718
|
-
if (options.promptSelector?.request.presentation === 'fullscreen-list') {
|
|
719
|
-
const selector = renderPromptSelectorPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
|
|
1601
|
+
if (options.promptSelector?.request.presentation === 'fullscreen-list' || options.promptSelector?.request.presentation === 'fullscreen-tree') {
|
|
1602
|
+
const selector = (options.promptSelector.request.presentation === 'fullscreen-tree' ? renderPromptTreePage : renderPromptSelectorPage)(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
|
|
720
1603
|
return {
|
|
721
1604
|
lines: fitFrame(selector.lines, width),
|
|
1605
|
+
...(selector.document === undefined ? {} : { promptDocument: selector.document }),
|
|
722
1606
|
cursor: selector.cursor,
|
|
723
1607
|
cursorVisible: true,
|
|
1608
|
+
// A full-screen surface drew no transcript body.
|
|
1609
|
+
documentRows: null,
|
|
724
1610
|
};
|
|
725
1611
|
}
|
|
726
|
-
if (options.promptSelector?.request.presentation === 'plan-review') {
|
|
727
|
-
const review = renderPlanReviewPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName);
|
|
1612
|
+
if (options.promptSelector?.request.presentation === 'plan-review' || options.promptSelector?.request.presentation === 'document') {
|
|
1613
|
+
const review = options.promptSelector.request.documentImage === undefined ? renderPlanReviewPage(options.promptSelector, theme, width, height, options.input, options.inputCursor, appName) : renderImagePreviewPage(options.promptSelector, theme, width, height, appName, options.imageProtocol, options.imageCellSize);
|
|
728
1614
|
return {
|
|
1615
|
+
...(review.image === undefined ? {} : { image: review.image }),
|
|
729
1616
|
lines: fitFrame(review.lines, width),
|
|
730
1617
|
cursor: review.cursor,
|
|
731
1618
|
cursorVisible: review.cursorVisible === true,
|
|
1619
|
+
// A full-screen surface drew no transcript body.
|
|
1620
|
+
documentRows: null,
|
|
732
1621
|
...(review.document === undefined ? {} : { promptDocument: review.document }),
|
|
733
1622
|
};
|
|
734
1623
|
}
|
|
@@ -764,6 +1653,8 @@ export function renderView(state, options) {
|
|
|
764
1653
|
}, options.transcriptSearch.matches.length)
|
|
765
1654
|
: options.inspected === undefined
|
|
766
1655
|
? slashInlineHint(options.input, options.inputCursor, options.commands)
|
|
1656
|
+
?? (state.status === 'running' && options.steerKey !== undefined
|
|
1657
|
+
? `Enter: next turn · ${options.steerKey}: next step` : null)
|
|
767
1658
|
: options.inspected.writable
|
|
768
1659
|
? slashInlineHint(options.input, options.inputCursor, options.commands) ?? 'Enter to steer · Esc to return'
|
|
769
1660
|
: 'Read-only · Esc to return';
|
|
@@ -813,7 +1704,10 @@ export function renderView(state, options) {
|
|
|
813
1704
|
: undefined;
|
|
814
1705
|
const queuedSubmissions = editor === undefined || options.inspected !== undefined
|
|
815
1706
|
? []
|
|
816
|
-
:
|
|
1707
|
+
: [
|
|
1708
|
+
...renderSteering(state.nextStepInbox, theme, width),
|
|
1709
|
+
...renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox),
|
|
1710
|
+
];
|
|
817
1711
|
// Above the composer and below the queue: the failure explains why the queue
|
|
818
1712
|
// is not moving, and the row survives until the next submission.
|
|
819
1713
|
const turnError = editor === undefined || options.inspected !== undefined || state.turnError === undefined
|
|
@@ -823,6 +1717,10 @@ export function renderView(state, options) {
|
|
|
823
1717
|
const goal = editor === undefined || options.inspected !== undefined || options.sessionControls?.goal === undefined
|
|
824
1718
|
? []
|
|
825
1719
|
: renderGoalBar(options.sessionControls.goal, theme, width);
|
|
1720
|
+
const pendingCount = options.sessionControls?.pendingQuestions ?? 0;
|
|
1721
|
+
const questions = editor === undefined || options.inspected !== undefined || pendingCount === 0
|
|
1722
|
+
? []
|
|
1723
|
+
: [truncateToWidth(theme.fg('warning', ` ${pendingCount} pending ${pendingCount === 1 ? 'question' : 'questions'} · /questions to answer`), width)];
|
|
826
1724
|
const inspect = editor === undefined ? [] : renderInspectBanner(options.inspected, theme, width, spinnerFrame);
|
|
827
1725
|
const subagents = editor === undefined
|
|
828
1726
|
? []
|
|
@@ -834,29 +1732,46 @@ export function renderView(state, options) {
|
|
|
834
1732
|
const inputLines = promptSelector?.lines ?? settings?.lines ?? copySelector?.lines ?? search?.lines
|
|
835
1733
|
?? (editor === undefined ? [] : editor.lines);
|
|
836
1734
|
const spacer = 1;
|
|
837
|
-
const reserved = inputLines.length + working.length + inspect.length + subagents.length + todos.length + goal.length + queuedSubmissions.length + turnError.length + spacer + autocomplete.length + statusFooter.length;
|
|
1735
|
+
const reserved = inputLines.length + working.length + inspect.length + subagents.length + todos.length + goal.length + questions.length + queuedSubmissions.length + turnError.length + spacer + autocomplete.length + statusFooter.length;
|
|
838
1736
|
const budget = Math.max(0, height - reserved);
|
|
839
|
-
const
|
|
1737
|
+
const focusIndex = options.focusBlock === undefined
|
|
840
1738
|
? undefined
|
|
841
|
-
|
|
1739
|
+
// Clamp against the blocks, not the offsets: a collapsed run's members
|
|
1740
|
+
// share its header's row, so the offset list can be shorter than the block
|
|
1741
|
+
// list, and clamping to it would aim the reader at a different block.
|
|
1742
|
+
: Math.max(0, Math.min(options.focusBlock, state.blocks.length - 1));
|
|
1743
|
+
const focusStart = focusIndex === undefined ? undefined : options.focusBlockEdge === 'end'
|
|
1744
|
+
? Math.max(0, (transcript.blockStarts[focusIndex + 1] ?? transcript.lines.length) - Math.max(1, budget - 2))
|
|
1745
|
+
: transcript.blockStarts[focusIndex];
|
|
842
1746
|
const requestedStart = focusStart === undefined
|
|
843
1747
|
? (options.scrollStart ?? Number.POSITIVE_INFINITY)
|
|
844
1748
|
: transcriptStart + focusStart;
|
|
845
1749
|
const hasOverlay = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || search !== undefined;
|
|
846
1750
|
const isFollowing = requestedStart === Number.POSITIVE_INFINITY && !hasOverlay;
|
|
847
|
-
|
|
1751
|
+
// A run still at work reshapes as it goes — its newest rows scroll, it folds
|
|
1752
|
+
// when the turn ends — so none of it may freeze into history until it
|
|
1753
|
+
// settles, the same way a running call's rows may not.
|
|
1754
|
+
const livePinned = transcript.liveFrom !== undefined
|
|
1755
|
+
|| state.blocks.some(block => block.kind === 'tool' && block.status === 'running');
|
|
848
1756
|
const windowed = isFollowing
|
|
849
1757
|
? undefined
|
|
850
1758
|
: windowTranscript(body, budget, requestedStart, theme);
|
|
851
1759
|
const visible = windowed?.lines ?? body;
|
|
852
1760
|
const lines = [...visible];
|
|
1761
|
+
const showJump = !hasOverlay && windowed !== undefined && budget >= 3
|
|
1762
|
+
&& (windowed.hiddenBelow > 0 || (options.openedGroups?.size ?? 0) > 0 || options.toolsExpanded === true);
|
|
1763
|
+
const jumpText = truncateToWidth(width >= 38 ? ' ↓ Jump to latest message · End ' : ' ↓ Latest · End ', width);
|
|
1764
|
+
const jumpColumn = Math.max(0, Math.floor((width - visibleWidth(jumpText)) / 2));
|
|
1765
|
+
const jumpToLatest = showJump ? { row: lines.length, column: jumpColumn, width: visibleWidth(jumpText) } : undefined;
|
|
853
1766
|
if (visible.length > 0)
|
|
854
|
-
lines.push(
|
|
855
|
-
|
|
1767
|
+
lines.push(showJump
|
|
1768
|
+
? ' '.repeat(jumpColumn) + theme.inverse(jumpText) : '');
|
|
1769
|
+
const bottomRows = working.length + goal.length + questions.length + inspect.length + subagents.length + todos.length + queuedSubmissions.length + turnError.length + inputLines.length + autocomplete.length + statusFooter.length;
|
|
856
1770
|
const fill = Math.max(0, height - lines.length - bottomRows);
|
|
857
1771
|
lines.push(...Array.from({ length: fill }, () => ''));
|
|
858
1772
|
lines.push(...working);
|
|
859
1773
|
lines.push(...goal);
|
|
1774
|
+
lines.push(...questions);
|
|
860
1775
|
lines.push(...inspect);
|
|
861
1776
|
lines.push(...subagents);
|
|
862
1777
|
lines.push(...todos);
|
|
@@ -869,8 +1784,12 @@ export function renderView(state, options) {
|
|
|
869
1784
|
let liveStart;
|
|
870
1785
|
if (isFollowing) {
|
|
871
1786
|
const firstPending = state.blocks.findIndex(isBlockPending);
|
|
872
|
-
|
|
873
|
-
|
|
1787
|
+
const pendingRow = firstPending >= 0 ? transcript.blockStarts[firstPending] : undefined;
|
|
1788
|
+
const liveRow = transcript.liveFrom === undefined
|
|
1789
|
+
? pendingRow
|
|
1790
|
+
: Math.min(pendingRow ?? transcript.liveFrom, transcript.liveFrom);
|
|
1791
|
+
if (liveRow !== undefined) {
|
|
1792
|
+
liveStart = transcriptStart + liveRow;
|
|
874
1793
|
}
|
|
875
1794
|
else {
|
|
876
1795
|
liveStart = lines.length - bottomRows;
|
|
@@ -888,8 +1807,40 @@ export function renderView(state, options) {
|
|
|
888
1807
|
// of measuring the complete native-scrollback history on every frame.
|
|
889
1808
|
const trimmed = fitFrame(lines, width, isFollowing ? liveStart : 0);
|
|
890
1809
|
const caret = promptSelector?.cursor ?? settings?.cursor ?? copySelector?.cursor ?? search?.cursor ?? editor?.cursor ?? { row: 0, column: 0 };
|
|
1810
|
+
// Which document rows this frame's body came from. `body` already includes the
|
|
1811
|
+
// header, so a window's own range is already in document coordinates and must
|
|
1812
|
+
// not have the header added again. No window means the whole body was drawn —
|
|
1813
|
+
// that is a known range, not a frame that failed to report one, and treating
|
|
1814
|
+
// it as "no body" would clear the record on every ordinary follow frame.
|
|
1815
|
+
const windowRange = windowed === undefined
|
|
1816
|
+
? (body.length === 0 ? null : { documentStart: 0, documentEnd: body.length, frameStart: 0 })
|
|
1817
|
+
: windowed.documentRows;
|
|
1818
|
+
const bodySource = windowRange === undefined
|
|
1819
|
+
? null
|
|
1820
|
+
: windowRange;
|
|
1821
|
+
const stickyHeaders = [];
|
|
1822
|
+
if (!hasOverlay && budget >= 3) {
|
|
1823
|
+
for (let at = 0; at < state.blocks.length; at += 1) {
|
|
1824
|
+
const block = state.blocks[at];
|
|
1825
|
+
if (block.kind !== 'user')
|
|
1826
|
+
continue;
|
|
1827
|
+
const previous = stickyHeaders.at(-1);
|
|
1828
|
+
if (previous !== undefined)
|
|
1829
|
+
previous.end = transcriptStart + transcript.blockStarts[at];
|
|
1830
|
+
const padding = width > 4 ? 2 : 0;
|
|
1831
|
+
const label = truncateToWidth(promptSummary(block), Math.max(1, width - padding * 2));
|
|
1832
|
+
stickyHeaders.push({
|
|
1833
|
+
start: transcriptStart + (transcript.blockStarts[at + 1] ?? transcript.lines.length),
|
|
1834
|
+
end: body.length,
|
|
1835
|
+
text: paintSurface(theme, 'userMessageBg', 'userMessageText', padToWidth(' '.repeat(padding) + label, width)),
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
891
1839
|
return {
|
|
892
1840
|
lines: trimmed,
|
|
1841
|
+
stickyHeaders,
|
|
1842
|
+
...(jumpToLatest === undefined ? {} : { jumpToLatest }),
|
|
1843
|
+
documentRows: bodySource,
|
|
893
1844
|
cursor: {
|
|
894
1845
|
row: editorStart + caret.row,
|
|
895
1846
|
column: Math.min(caret.column, width),
|
|
@@ -904,12 +1855,19 @@ export function renderView(state, options) {
|
|
|
904
1855
|
// history; the renderer needs to tell them apart because only the first may
|
|
905
1856
|
// borrow the alternate buffer.
|
|
906
1857
|
...(liveStart === 0 ? { transientSurface: hasOverlay ? 'overlay' : 'scroll' } : {}),
|
|
907
|
-
transcript:
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1858
|
+
transcript: {
|
|
1859
|
+
...(windowed ?? {
|
|
1860
|
+
start: (body.length > budget ? body.length - Math.max(1, budget - 1) : 0),
|
|
1861
|
+
maxStart: (body.length > budget ? body.length - Math.max(1, budget - 1) : 0),
|
|
1862
|
+
budget,
|
|
1863
|
+
hiddenAbove: (body.length > budget ? body.length - Math.max(1, budget - 1) : 0),
|
|
1864
|
+
hiddenBelow: 0,
|
|
1865
|
+
}),
|
|
1866
|
+
blockStarts: transcript.blockStarts,
|
|
1867
|
+
blockDrawStarts: transcript.blockDrawStarts,
|
|
1868
|
+
foldShape: transcript.foldShape,
|
|
1869
|
+
foldMarks: transcript.foldMarks,
|
|
1870
|
+
bodyRow: transcriptStart,
|
|
913
1871
|
},
|
|
914
1872
|
};
|
|
915
1873
|
}
|