@sagmans/dsh-tui 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +270 -68
- package/lib/agent/present.js +1 -1
- package/lib/agent/present.js.map +1 -1
- package/lib/agent/prompt-history.d.ts.map +1 -1
- package/lib/agent/prompt-history.js +5 -3
- package/lib/agent/prompt-history.js.map +1 -1
- package/lib/agent/status.d.ts +1 -1
- package/lib/cards.d.ts +83 -13
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +96 -28
- package/lib/cards.js.map +1 -1
- package/lib/export.d.ts +3 -3
- package/lib/export.d.ts.map +1 -1
- package/lib/export.js +23 -13
- package/lib/export.js.map +1 -1
- package/lib/fold-cursor.d.ts +12 -5
- package/lib/fold-cursor.d.ts.map +1 -1
- package/lib/fold-cursor.js +16 -5
- package/lib/fold-cursor.js.map +1 -1
- package/lib/gates.d.ts.map +1 -1
- package/lib/gates.js +42 -9
- package/lib/gates.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +459 -72
- package/lib/index.js.map +1 -1
- package/lib/injection.d.ts +9 -0
- package/lib/injection.d.ts.map +1 -0
- package/lib/injection.js +128 -0
- package/lib/injection.js.map +1 -0
- package/lib/input/actions.d.ts +19 -2
- package/lib/input/actions.d.ts.map +1 -1
- package/lib/input/actions.js +57 -11
- package/lib/input/actions.js.map +1 -1
- package/lib/input/cancel.d.ts +55 -0
- package/lib/input/cancel.d.ts.map +1 -0
- package/lib/input/cancel.js +47 -0
- package/lib/input/cancel.js.map +1 -0
- package/lib/input/completion.d.ts +5 -2
- package/lib/input/completion.d.ts.map +1 -1
- package/lib/input/completion.js +79 -4
- package/lib/input/completion.js.map +1 -1
- package/lib/input/file-search.d.ts +105 -0
- package/lib/input/file-search.d.ts.map +1 -0
- package/lib/input/file-search.js +665 -0
- package/lib/input/file-search.js.map +1 -0
- package/lib/input/fuzzy.d.ts +42 -0
- package/lib/input/fuzzy.d.ts.map +1 -0
- package/lib/input/fuzzy.js +289 -0
- package/lib/input/fuzzy.js.map +1 -0
- package/lib/input/keymap.d.ts +3 -1
- package/lib/input/keymap.d.ts.map +1 -1
- package/lib/input/keymap.js +14 -3
- package/lib/input/keymap.js.map +1 -1
- package/lib/input/match.d.ts +12 -4
- package/lib/input/match.d.ts.map +1 -1
- package/lib/input/match.js +50 -51
- package/lib/input/match.js.map +1 -1
- package/lib/input/submission.d.ts +10 -0
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +7 -6
- package/lib/input/submission.js.map +1 -1
- package/lib/keys-command.d.ts +19 -3
- package/lib/keys-command.d.ts.map +1 -1
- package/lib/keys-command.js +34 -31
- package/lib/keys-command.js.map +1 -1
- package/lib/settings-notice.d.ts +5 -0
- package/lib/settings-notice.d.ts.map +1 -1
- package/lib/settings-notice.js +9 -7
- package/lib/settings-notice.js.map +1 -1
- package/lib/stash/paths.d.ts +8 -8
- package/lib/stash/paths.d.ts.map +1 -1
- package/lib/stash/paths.js +19 -18
- package/lib/stash/paths.js.map +1 -1
- package/lib/stash/schema.d.ts +11 -12
- package/lib/stash/schema.d.ts.map +1 -1
- package/lib/stash/schema.js +10 -29
- package/lib/stash/schema.js.map +1 -1
- package/lib/stash/store.js +10 -10
- package/lib/stash/store.js.map +1 -1
- package/lib/stash.d.ts +37 -6
- package/lib/stash.d.ts.map +1 -1
- package/lib/stash.js +93 -36
- package/lib/stash.js.map +1 -1
- package/lib/terminal/external-editor.d.ts +93 -0
- package/lib/terminal/external-editor.d.ts.map +1 -0
- package/lib/terminal/external-editor.js +263 -0
- package/lib/terminal/external-editor.js.map +1 -0
- package/lib/terminal/host-writes.d.ts +39 -0
- package/lib/terminal/host-writes.d.ts.map +1 -0
- package/lib/terminal/host-writes.js +136 -0
- package/lib/terminal/host-writes.js.map +1 -0
- package/lib/terminal/signals.d.ts +25 -0
- package/lib/terminal/signals.d.ts.map +1 -0
- package/lib/terminal/signals.js +39 -0
- package/lib/terminal/signals.js.map +1 -0
- package/lib/terminal/warning-screen.d.ts +19 -1
- package/lib/terminal/warning-screen.d.ts.map +1 -1
- package/lib/terminal/warning-screen.js +37 -1
- package/lib/terminal/warning-screen.js.map +1 -1
- package/lib/terminal-text.d.ts +72 -0
- package/lib/terminal-text.d.ts.map +1 -0
- package/lib/terminal-text.js +603 -0
- package/lib/terminal-text.js.map +1 -0
- package/lib/text.d.ts +45 -6
- package/lib/text.d.ts.map +1 -1
- package/lib/text.js +106 -46
- package/lib/text.js.map +1 -1
- package/lib/theme-command.d.ts +7 -4
- package/lib/theme-command.d.ts.map +1 -1
- package/lib/theme-command.js +32 -12
- package/lib/theme-command.js.map +1 -1
- package/lib/theme-files.d.ts +110 -0
- package/lib/theme-files.d.ts.map +1 -0
- package/lib/theme-files.js +352 -0
- package/lib/theme-files.js.map +1 -0
- package/lib/theme-schema.d.ts +121 -0
- package/lib/theme-schema.d.ts.map +1 -0
- package/lib/theme-schema.js +87 -0
- package/lib/theme-schema.js.map +1 -0
- package/lib/theme-settings.d.ts +43 -10
- package/lib/theme-settings.d.ts.map +1 -1
- package/lib/theme-settings.js +103 -43
- package/lib/theme-settings.js.map +1 -1
- package/lib/theme-tokens.d.ts +39 -7
- package/lib/theme-tokens.d.ts.map +1 -1
- package/lib/theme-tokens.js +96 -13
- package/lib/theme-tokens.js.map +1 -1
- package/lib/theme.d.ts +24 -2
- package/lib/theme.d.ts.map +1 -1
- package/lib/theme.js +49 -6
- package/lib/theme.js.map +1 -1
- package/lib/tool-display.d.ts +57 -0
- package/lib/tool-display.d.ts.map +1 -0
- package/lib/tool-display.js +51 -0
- package/lib/tool-display.js.map +1 -0
- package/lib/transcript.d.ts +28 -0
- package/lib/transcript.d.ts.map +1 -1
- package/lib/transcript.js +126 -37
- package/lib/transcript.js.map +1 -1
- package/lib/ui/diff.d.ts +49 -0
- package/lib/ui/diff.d.ts.map +1 -0
- package/lib/ui/diff.js +208 -0
- package/lib/ui/diff.js.map +1 -0
- package/lib/ui/dock.d.ts.map +1 -1
- package/lib/ui/dock.js +9 -6
- package/lib/ui/dock.js.map +1 -1
- package/lib/ui/editor.d.ts.map +1 -1
- package/lib/ui/editor.js +4 -2
- package/lib/ui/editor.js.map +1 -1
- package/lib/ui/frame.d.ts +16 -5
- package/lib/ui/frame.d.ts.map +1 -1
- package/lib/ui/frame.js +32 -13
- package/lib/ui/frame.js.map +1 -1
- package/lib/ui/keymap-picker.d.ts +16 -0
- package/lib/ui/keymap-picker.d.ts.map +1 -0
- package/lib/ui/keymap-picker.js +41 -0
- package/lib/ui/keymap-picker.js.map +1 -0
- package/lib/ui/layout.d.ts +33 -0
- package/lib/ui/layout.d.ts.map +1 -0
- package/lib/ui/layout.js +33 -0
- package/lib/ui/layout.js.map +1 -0
- package/lib/ui/markdown.d.ts +25 -3
- package/lib/ui/markdown.d.ts.map +1 -1
- package/lib/ui/markdown.js +12 -8
- package/lib/ui/markdown.js.map +1 -1
- package/lib/ui/picker-card.d.ts +42 -0
- package/lib/ui/picker-card.d.ts.map +1 -0
- package/lib/ui/picker-card.js +148 -0
- package/lib/ui/picker-card.js.map +1 -0
- package/lib/ui/picker.d.ts +40 -2
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +50 -6
- package/lib/ui/picker.js.map +1 -1
- package/lib/ui/prompt.d.ts +10 -2
- package/lib/ui/prompt.d.ts.map +1 -1
- package/lib/ui/prompt.js +21 -2
- package/lib/ui/prompt.js.map +1 -1
- package/lib/ui/rows.d.ts +8 -6
- package/lib/ui/rows.d.ts.map +1 -1
- package/lib/ui/rows.js +10 -18
- package/lib/ui/rows.js.map +1 -1
- package/lib/ui/stash-picker.d.ts +2 -2
- package/lib/ui/stash-picker.d.ts.map +1 -1
- package/lib/ui/stash-picker.js +4 -4
- package/lib/ui/stash-picker.js.map +1 -1
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +18 -16
- package/lib/ui/status.js.map +1 -1
- package/lib/ui/theme-picker.d.ts +18 -0
- package/lib/ui/theme-picker.d.ts.map +1 -0
- package/lib/ui/theme-picker.js +68 -0
- package/lib/ui/theme-picker.js.map +1 -0
- package/lib/ui/view.d.ts +146 -22
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +461 -154
- package/lib/ui/view.js.map +1 -1
- package/package.json +45 -11
- package/themes/deepseek-blue.yaml +183 -0
- package/themes/violet-orbit.yaml +178 -0
package/lib/ui/view.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
2
|
-
import { cardDetailRows, shellFoldHint, shellRetentionHint } from "../cards.js";
|
|
1
|
+
import { visibleWidth, wrapTextWithAnsi, } from '@earendil-works/pi-tui';
|
|
2
|
+
import { cardDetailRows, clip, oneLine, shellFoldHint, shellRetentionHint } from "../cards.js";
|
|
3
3
|
import { defaultKeymap, hintKeys } from "../input/actions.js";
|
|
4
4
|
import { CUSTOM_ROW_NUMBER } from "../gates.js";
|
|
5
|
-
import {
|
|
5
|
+
import { DEFAULT_TOOL_DISPLAY } from "../tool-display.js";
|
|
6
6
|
import { CARD_ROW_TOKEN } from "../theme-tokens.js";
|
|
7
|
-
import {
|
|
7
|
+
import { codeBlockLines } from "./diff.js";
|
|
8
|
+
import { canFrame, frameLines, FRAME_COLUMNS, textWidth } from "./frame.js";
|
|
9
|
+
import { ANSWER_FACE } from "./markdown.js";
|
|
10
|
+
import { pickerCardLines } from "./picker-card.js";
|
|
8
11
|
import { RowCache } from "./rows.js";
|
|
9
12
|
const DETAIL_INDENT = ' ';
|
|
10
13
|
const OPTION_INDENT = ' ';
|
|
11
14
|
/** A nested call is a signpost under its card, so it sits one step shallower than that card's detail. */
|
|
12
15
|
const SUBCALL_INDENT = ' ';
|
|
16
|
+
/**
|
|
17
|
+
* Blank columns a one-line tool row keeps at the right edge.
|
|
18
|
+
*
|
|
19
|
+
* The cut mark would otherwise sit under the terminal's own last column, where
|
|
20
|
+
* a border or the cursor lives; the padding is part of the look, not a
|
|
21
|
+
* preference, so it is not a settings key.
|
|
22
|
+
*/
|
|
23
|
+
const COLLAPSED_EDGE_PADDING = 5;
|
|
13
24
|
/** The mark a cursor falls back to when the reader has not set one, so no literal lives in a template. */
|
|
14
25
|
const CURSOR_MARK = '❯';
|
|
15
26
|
const APPROVAL_MARK = '⚠';
|
|
@@ -42,21 +53,74 @@ const STAT_TOKEN = {
|
|
|
42
53
|
removed: 'tool.stat.removed',
|
|
43
54
|
size: 'tool.stat.size',
|
|
44
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* A markdown theme that keeps every element in one token.
|
|
58
|
+
*
|
|
59
|
+
* A thought is deliberately recessive, and the answer's theme would let a
|
|
60
|
+
* heading or a link inside it outshine the answer it produced. The structure
|
|
61
|
+
* still shows because markdown draws it around the text — bullets, fences,
|
|
62
|
+
* indents, table rules — rather than in the text's own colour. A fenced diff is
|
|
63
|
+
* the one block that does not recede: red and green are what the fence means
|
|
64
|
+
* rather than how loudly it is drawn, and a thought showing a change is the
|
|
65
|
+
* place the reader most needs to see which side of it moved.
|
|
66
|
+
*/
|
|
67
|
+
function recessiveMarkdownTheme(style, theme) {
|
|
68
|
+
return {
|
|
69
|
+
heading: style,
|
|
70
|
+
link: style,
|
|
71
|
+
linkUrl: style,
|
|
72
|
+
code: style,
|
|
73
|
+
codeBlock: style,
|
|
74
|
+
codeBlockBorder: style,
|
|
75
|
+
quote: style,
|
|
76
|
+
quoteBorder: style,
|
|
77
|
+
hr: style,
|
|
78
|
+
listBullet: style,
|
|
79
|
+
bold: style,
|
|
80
|
+
italic: style,
|
|
81
|
+
strikethrough: style,
|
|
82
|
+
underline: style,
|
|
83
|
+
highlightCode: (code, lang) => codeBlockLines(code, lang, {
|
|
84
|
+
style: (token, text) => theme.style(token, text),
|
|
85
|
+
visible: token => theme.visible(token),
|
|
86
|
+
plain: style,
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
45
90
|
/**
|
|
46
91
|
* The state a reader gets before opening anything, and the view's own fallback.
|
|
47
92
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
93
|
+
* Thoughts start folded because they can be long enough to push the answer off
|
|
94
|
+
* the screen. Cards and a program's calls start from the reader's own `tools:`
|
|
95
|
+
* settings, which ship folded for cards — one clipped line each — and inline for
|
|
96
|
+
* a program's calls, whose one line per dispatch is what the card stands for.
|
|
52
97
|
*/
|
|
53
98
|
export const DEFAULT_VIEW_STATE = { expandCards: false, expandReasoning: false, expandSubCalls: true };
|
|
99
|
+
/** Keys are prefixed because cards, thoughts, and dispatches all share one map. */
|
|
100
|
+
const toolClickKey = (id) => (id === '' ? undefined : `tool:${id}`);
|
|
101
|
+
const reasoningClickKey = (id) => (id === '' ? undefined : `reason:${id}`);
|
|
102
|
+
const subCallClickKey = (parentId, id) => `sub:${parentId}/${id}`;
|
|
103
|
+
/** The column a one-line tool row must not pass, so a cut never reaches the edge. */
|
|
104
|
+
const collapsedEdge = (width) => Math.max(1, width - COLLAPSED_EDGE_PADDING);
|
|
54
105
|
export class TranscriptView {
|
|
55
106
|
model;
|
|
56
107
|
theme;
|
|
57
108
|
markdown;
|
|
58
109
|
options;
|
|
59
110
|
rows;
|
|
111
|
+
/**
|
|
112
|
+
* Fold choices the reader made by clicking, per message.
|
|
113
|
+
*
|
|
114
|
+
* Absent means the key or the tool's own policy decides, so a settings edit
|
|
115
|
+
* stays in charge of messages nobody clicked. Keyed by the call or thought id
|
|
116
|
+
* rather than by the entry, because a result replaces the entry a click was
|
|
117
|
+
* made on and a thought outlives the live row that streamed it.
|
|
118
|
+
*/
|
|
119
|
+
clicked = new Map();
|
|
120
|
+
/** The rows each clickable message drew last render, for mapping a click back to it. */
|
|
121
|
+
spans = [];
|
|
122
|
+
/** The same spans relative to their entry, so a cached entry still answers clicks. */
|
|
123
|
+
entrySpans = new WeakMap();
|
|
60
124
|
constructor(model, theme, markdown, options = {}) {
|
|
61
125
|
this.model = model;
|
|
62
126
|
this.theme = theme;
|
|
@@ -67,6 +131,56 @@ export class TranscriptView {
|
|
|
67
131
|
get viewState() {
|
|
68
132
|
return this.options.state?.() ?? DEFAULT_VIEW_STATE;
|
|
69
133
|
}
|
|
134
|
+
/** The display the reader configured for a tool, or the shipped one. */
|
|
135
|
+
toolDisplay(tool) {
|
|
136
|
+
return this.options.toolDisplay?.(tool) ?? DEFAULT_TOOL_DISPLAY;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether one tool message draws open.
|
|
140
|
+
*
|
|
141
|
+
* A click outranks the key, which outranks the tool's start state: Ctrl+O
|
|
142
|
+
* stays "show me everything", while one clicked message keeps the state the
|
|
143
|
+
* reader gave it.
|
|
144
|
+
*/
|
|
145
|
+
expansionOf(entry) {
|
|
146
|
+
const key = toolClickKey(entry.id);
|
|
147
|
+
const clicked = key === undefined ? undefined : this.clicked.get(key);
|
|
148
|
+
return clicked ?? (this.viewState.expandCards || !this.toolDisplay(entry.card.tool).collapsed);
|
|
149
|
+
}
|
|
150
|
+
/** Whether one thought draws its body; a click decides for that thought alone. */
|
|
151
|
+
reasoningOpen(entry) {
|
|
152
|
+
const key = reasoningClickKey(entry.id);
|
|
153
|
+
const clicked = key === undefined ? undefined : this.clicked.get(key);
|
|
154
|
+
return clicked ?? this.viewState.expandReasoning;
|
|
155
|
+
}
|
|
156
|
+
/** Whether one dispatched call draws its argument in full; a click decides for that call. */
|
|
157
|
+
subCallOpen(parentId, id) {
|
|
158
|
+
return this.clicked.get(subCallClickKey(parentId, id)) ?? false;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Answer a click on a message by folding or unfolding that one row.
|
|
162
|
+
*
|
|
163
|
+
* The tightest span under the point wins, so a click on a dispatched call
|
|
164
|
+
* opens that call rather than the card around it. Only a left click is
|
|
165
|
+
* consumed: a press, a drag, and a wheel belong to the surface's own
|
|
166
|
+
* selection and scrolling, and a row that swallowed them would cost the
|
|
167
|
+
* reader the ability to copy the command it just drew.
|
|
168
|
+
*/
|
|
169
|
+
handleMouse(event) {
|
|
170
|
+
if (event.type !== 'click' || event.button !== 'left')
|
|
171
|
+
return undefined;
|
|
172
|
+
let span;
|
|
173
|
+
for (const candidate of this.spans) {
|
|
174
|
+
if (event.y < candidate.start || event.y >= candidate.end)
|
|
175
|
+
continue;
|
|
176
|
+
if (span === undefined || candidate.end - candidate.start < span.end - span.start)
|
|
177
|
+
span = candidate;
|
|
178
|
+
}
|
|
179
|
+
if (span === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
this.clicked.set(span.key, !span.expanded);
|
|
182
|
+
return { handled: true, render: true };
|
|
183
|
+
}
|
|
70
184
|
/** The cache's own account of the work it avoided; a test reads this. */
|
|
71
185
|
rowStats() {
|
|
72
186
|
return this.rows.stats();
|
|
@@ -76,13 +190,20 @@ export class TranscriptView {
|
|
|
76
190
|
// anyway; an explicit invalidate means the caller wants them rebuilt.
|
|
77
191
|
this.rows.clear();
|
|
78
192
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Wrap one text block under a prefix, keeping the prefix's column budget.
|
|
195
|
+
*
|
|
196
|
+
* The block is drawn before it is wrapped: a sequence a terminal would have
|
|
197
|
+
* acted on has to be gone before pi-tui measures the row, and a tab has to
|
|
198
|
+
* land on the stop the writer saw rather than one a wrapper would guess at.
|
|
199
|
+
* The prefix seeds that stop, because it is already on the row.
|
|
200
|
+
*/
|
|
201
|
+
pushWrapped(lines, text, width, prefix, token) {
|
|
81
202
|
const lead = visibleWidth(prefix);
|
|
82
203
|
const indent = ' '.repeat(lead);
|
|
83
|
-
const wrapped = wrapTextWithAnsi(
|
|
204
|
+
const wrapped = wrapTextWithAnsi(this.theme.rich(text, { token, column: lead }), Math.max(1, width - lead));
|
|
84
205
|
wrapped.forEach((line, index) => {
|
|
85
|
-
lines.push(
|
|
206
|
+
lines.push(this.theme.cut(`${index === 0 ? prefix : indent}${line}`, width, ''));
|
|
86
207
|
});
|
|
87
208
|
}
|
|
88
209
|
/**
|
|
@@ -102,19 +223,67 @@ export class TranscriptView {
|
|
|
102
223
|
lines.push(this.theme.cut(`${index === 0 ? prefix : indent}${line}`, width, ''));
|
|
103
224
|
});
|
|
104
225
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The rows one markdown message draws, with the padding pi-tui adds for background styling removed.
|
|
228
|
+
*
|
|
229
|
+
* The source is drawn first so the markdown renderer never measures a sequence
|
|
230
|
+
* it cannot see; its own spans are applied around the result afterwards.
|
|
231
|
+
*/
|
|
232
|
+
markdownLines(text, width, live, face, column = 0) {
|
|
233
|
+
return this.markdown
|
|
234
|
+
.render(this.theme.rich(text, { column }), Math.max(1, width), live, face)
|
|
235
|
+
.map(line => line.replace(/[ \t]+$/u, ''));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Render one message's text as markdown.
|
|
239
|
+
*
|
|
240
|
+
* A thought passes its own face and an indent, so the row stays visibly a
|
|
241
|
+
* detail of the step that produced it rather than a second answer.
|
|
242
|
+
*/
|
|
243
|
+
pushMarkdown(lines, text, width, live, face = ANSWER_FACE, indent = '') {
|
|
244
|
+
const lead = visibleWidth(indent);
|
|
245
|
+
for (const line of this.markdownLines(text, Math.max(1, width - lead), live, face, lead)) {
|
|
111
246
|
// A blank markdown line stays blank: it shows nothing, so it holds nothing.
|
|
112
|
-
if (
|
|
247
|
+
if (line === '') {
|
|
113
248
|
lines.push('');
|
|
114
|
-
|
|
249
|
+
continue;
|
|
115
250
|
}
|
|
116
|
-
lines.push(this.theme.cut(
|
|
117
|
-
}
|
|
251
|
+
lines.push(this.theme.cut(`${indent}${line}`, width, '…'));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* One message closed into a frame, with its markdown laid out to what is left inside.
|
|
256
|
+
*
|
|
257
|
+
* A prompt and a reply are the two objects of an exchange, so both are drawn as
|
|
258
|
+
* bars rather than as one more stretch of rows; the face, the border's element,
|
|
259
|
+
* and whether the text is still arriving are the only differences. Markdown lays
|
|
260
|
+
* itself out to the frame's text width, so the frame may only place the rows:
|
|
261
|
+
* wrapping them again would break what it drew.
|
|
262
|
+
*/
|
|
263
|
+
pushFramed(lines, text, width, live, face, borderToken) {
|
|
264
|
+
const framed = canFrame(width, this.theme.visible(borderToken));
|
|
265
|
+
const inside = framed ? width - FRAME_COLUMNS : width;
|
|
266
|
+
const body = this.markdownLines(text, textWidth(inside), live, face);
|
|
267
|
+
lines.push(...frameLines(body, width, {
|
|
268
|
+
text: line => line,
|
|
269
|
+
border: rule => this.theme.style(borderToken, rule),
|
|
270
|
+
framed,
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The face a thought is drawn in.
|
|
275
|
+
*
|
|
276
|
+
* Its shade stays the thought body's and it asks for no drawing: a diagram in
|
|
277
|
+
* the middle of a thought would carry the answer's weight, and the answer's
|
|
278
|
+
* colours would make the thinking compete with it.
|
|
279
|
+
*/
|
|
280
|
+
reasoningFace() {
|
|
281
|
+
const body = (text) => this.theme.style('transcript.reasoning.body', text);
|
|
282
|
+
return { name: 'reasoning', base: { color: body }, theme: recessiveMarkdownTheme(body, this.theme), transform: false };
|
|
283
|
+
}
|
|
284
|
+
/** The face a submitted prompt is drawn in: its structure is markdown's, its shade stays the prompt's. */
|
|
285
|
+
userFace() {
|
|
286
|
+
return { name: 'user', base: { color: text => this.theme.style('transcript.user', text) } };
|
|
118
287
|
}
|
|
119
288
|
/** The live map, or the shipped one for a caller that did not lend one. */
|
|
120
289
|
keymap() {
|
|
@@ -123,62 +292,73 @@ export class TranscriptView {
|
|
|
123
292
|
reasoningFoldHint() {
|
|
124
293
|
return hintKeys(this.keymap(), 'surface.reasoning') || REASONING_FOLD_FALLBACK;
|
|
125
294
|
}
|
|
126
|
-
pushReasoning(lines, entry, width) {
|
|
295
|
+
pushReasoning(lines, entry, width, spans) {
|
|
127
296
|
if (!this.theme.visible('transcript.reasoning.summary'))
|
|
128
297
|
return;
|
|
298
|
+
const start = lines.length;
|
|
299
|
+
const open = this.reasoningOpen(entry);
|
|
129
300
|
const glyph = this.theme.glyph('transcript.reasoning.summary');
|
|
130
301
|
const lead = glyph === '' ? '' : `${glyph} `;
|
|
131
302
|
// A folded row carries the key that opens it, because a count with no way to
|
|
132
303
|
// reach the text reads the same as the text never having arrived. The key
|
|
133
304
|
// rides the row rather than a line of its own, so naming the hidden body
|
|
134
305
|
// costs no vertical space.
|
|
135
|
-
const suffix = !
|
|
306
|
+
const suffix = !open && entry.body !== '' && this.theme.visible('transcript.reasoning.hint')
|
|
136
307
|
? ` (${this.reasoningFoldHint()})`
|
|
137
308
|
: '';
|
|
138
309
|
// The key is kept whole: the summary is the part that gives up room.
|
|
139
310
|
const room = Math.max(1, width - visibleWidth(suffix));
|
|
140
|
-
const summary = this.theme.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
this.
|
|
311
|
+
const summary = this.theme.cut(this.theme.rich(`${lead}${entry.summary}`, { token: 'transcript.reasoning.summary', column: visibleWidth(lead) }), room, '');
|
|
312
|
+
const lined = suffix === '' ? summary : `${summary}${this.theme.style('transcript.reasoning.hint', suffix)}`;
|
|
313
|
+
// The key is kept whole only while the row has room for it: a row wider than
|
|
314
|
+
// the surface loses its tail to the terminal, and the terminal's clamp is not
|
|
315
|
+
// one this component can count on.
|
|
316
|
+
lines.push(this.theme.cut(lined, width, '…'));
|
|
317
|
+
if (open && this.theme.visible('transcript.reasoning.body')) {
|
|
318
|
+
this.pushMarkdown(lines, entry.body, width, entry.live, this.reasoningFace(), DETAIL_INDENT);
|
|
148
319
|
}
|
|
320
|
+
const key = reasoningClickKey(entry.id);
|
|
321
|
+
if (key !== undefined)
|
|
322
|
+
spans.push({ key, start, end: lines.length, expanded: open });
|
|
149
323
|
}
|
|
150
|
-
pushCard(lines,
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
324
|
+
pushCard(lines, entry, width, spans) {
|
|
325
|
+
const card = entry.card;
|
|
326
|
+
const spec = this.toolDisplay(card.tool);
|
|
327
|
+
const expanded = this.expansionOf(entry);
|
|
328
|
+
const start = lines.length;
|
|
329
|
+
// What a folded card shows of its rows is the reader's choice, so a shell
|
|
330
|
+
// run no longer spends twenty rows on screen just because its output is the
|
|
331
|
+
// answer: the answer is one click away, and the row it costs is one.
|
|
156
332
|
const preview = expanded
|
|
157
333
|
? { expanded: true }
|
|
158
|
-
:
|
|
334
|
+
: spec.output === 'tail'
|
|
335
|
+
? { expanded: false, preview: 'tail', rows: spec.tail }
|
|
336
|
+
: { expanded: false, preview: 'title' };
|
|
159
337
|
const { lines: detail, hidden } = cardDetailRows(card, preview);
|
|
160
338
|
const titleToken = card.failed ? 'tool.failed.title' : 'tool.title';
|
|
161
339
|
const glyphToken = card.failed ? 'tool.failed.glyph' : 'tool.glyph';
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
340
|
+
if (expanded) {
|
|
341
|
+
const { lead, body } = this.renderHead(card, titleToken, glyphToken);
|
|
342
|
+
// A header that folds keeps the argument and its stats; a terminal command
|
|
343
|
+
// can be longer than the screen, so it wraps under its own indent.
|
|
344
|
+
if (body !== '')
|
|
345
|
+
this.pushStyledWrapped(lines, body, width, lead);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
const head = this.renderCollapsedHead(card, hidden, titleToken, glyphToken, width);
|
|
349
|
+
if (head !== '')
|
|
350
|
+
lines.push(this.theme.cut(head, collapsedEdge(width), '…'));
|
|
351
|
+
}
|
|
352
|
+
// The calls a program dispatched sit under the header whether the card is
|
|
353
|
+
// open or folded: one line per call is what the card stands for, and hiding
|
|
354
|
+
// them behind the card's own fold made a program's work invisible.
|
|
167
355
|
if (this.viewState.expandSubCalls)
|
|
168
|
-
this.pushSubCalls(lines,
|
|
169
|
-
if (card.kind === 'terminal' && card.argument !== undefined && card.argument !== '' && this.theme.visible('tool.args')) {
|
|
170
|
-
this.pushStyledWrapped(lines, this.theme.
|
|
356
|
+
this.pushSubCalls(lines, entry, width, spans);
|
|
357
|
+
if (expanded && card.kind === 'terminal' && card.argument !== undefined && card.argument !== '' && this.theme.visible('tool.args')) {
|
|
358
|
+
this.pushStyledWrapped(lines, this.theme.rich(card.argument, { token: 'tool.args', column: visibleWidth(DETAIL_INDENT) }), width, DETAIL_INDENT);
|
|
171
359
|
}
|
|
172
360
|
for (const row of detail) {
|
|
173
|
-
|
|
174
|
-
// a diff line beginning with "+" is an addition because the presenter
|
|
175
|
-
// said so, not because of its first character.
|
|
176
|
-
const drawn = row.parts
|
|
177
|
-
.map(part => {
|
|
178
|
-
const token = CARD_ROW_TOKEN[card.kind]?.[part.class] ?? FALLBACK_ROW_TOKEN;
|
|
179
|
-
return this.theme.visible(token) ? this.theme.style(token, displayText(part.text)) : '';
|
|
180
|
-
})
|
|
181
|
-
.join('');
|
|
361
|
+
const drawn = this.detailRow(row, card.kind);
|
|
182
362
|
// A row whose every part is hidden draws nothing, and nothing must not
|
|
183
363
|
// cost a line: the indent would read as an empty row the card does not have.
|
|
184
364
|
if (drawn === '')
|
|
@@ -186,114 +366,210 @@ export class TranscriptView {
|
|
|
186
366
|
lines.push(this.theme.cut(`${DETAIL_INDENT}${drawn}`, width, ''));
|
|
187
367
|
}
|
|
188
368
|
// The pill is not output, so it draws after the preview window rather than
|
|
189
|
-
// inside it: a run bounded to its tail still reports how it ended.
|
|
190
|
-
|
|
191
|
-
|
|
369
|
+
// inside it: a run bounded to its tail still reports how it ended. Folded,
|
|
370
|
+
// it rides the header instead, where it costs no row of its own.
|
|
371
|
+
if (expanded && card.kind === 'terminal' && card.status !== undefined && this.theme.visible('tool.terminal.status')) {
|
|
372
|
+
lines.push(this.theme.cut(`${DETAIL_INDENT}${this.theme.rich(card.status, { token: 'tool.terminal.status', column: visibleWidth(DETAIL_INDENT) })}`, width, ''));
|
|
192
373
|
}
|
|
193
|
-
if (hidden <= 0 || !this.theme.visible('tool.hint'))
|
|
194
|
-
return;
|
|
195
374
|
// A shell card's rows are kept from the end, so a hidden count always names
|
|
196
375
|
// the rows *before* what is on screen and the hint has to say so; every
|
|
197
|
-
// other card keeps its head, where a neutral count is enough. A folded
|
|
198
|
-
//
|
|
199
|
-
// opened hint promises no more than memory kept.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
? shellRetentionHint(hidden)
|
|
203
|
-
:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
376
|
+
// other card keeps its head, where a neutral count is enough. A folded card
|
|
377
|
+
// that shows a tail is bounded by that window, an opened one by retention,
|
|
378
|
+
// so the opened hint promises no more than memory kept.
|
|
379
|
+
if (hidden > 0 && this.theme.visible('tool.hint')) {
|
|
380
|
+
const hint = preview.expanded
|
|
381
|
+
? card.kind === 'terminal' ? shellRetentionHint(hidden) : `${hidden} ${CARD_HINT_RETAINED}`
|
|
382
|
+
: preview.preview === 'tail'
|
|
383
|
+
? shellFoldHint(hidden, hintKeys(this.keymap(), 'surface.toolDetail') || CARD_OPEN_FALLBACK)
|
|
384
|
+
: undefined;
|
|
385
|
+
if (hint !== undefined) {
|
|
386
|
+
lines.push(this.theme.style('tool.hint', this.theme.cut(`${DETAIL_INDENT}${hint}`, width, '')));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const key = toolClickKey(entry.id);
|
|
390
|
+
if (key !== undefined)
|
|
391
|
+
spans.push({ key, start, end: lines.length, expanded });
|
|
208
392
|
}
|
|
209
393
|
/**
|
|
210
394
|
* The calls one card dispatched, one entry each.
|
|
211
395
|
*
|
|
212
396
|
* A nested call is a signpost rather than a card of its own, so it keeps one
|
|
213
|
-
* entry per call
|
|
214
|
-
*
|
|
215
|
-
*
|
|
397
|
+
* entry per call, clipped to that entry until the reader clicks it: the
|
|
398
|
+
* argument is the part a reader scans for, and one that always wrapped pushed
|
|
399
|
+
* the rest of the program off the screen.
|
|
216
400
|
*/
|
|
217
|
-
pushSubCalls(lines,
|
|
401
|
+
pushSubCalls(lines, entry, width, spans) {
|
|
402
|
+
const card = entry.card;
|
|
218
403
|
const subCalls = card.subCalls ?? [];
|
|
219
404
|
for (const call of subCalls) {
|
|
405
|
+
const start = lines.length;
|
|
406
|
+
const open = this.subCallOpen(entry.id, call.id);
|
|
220
407
|
const titleToken = call.failed ? 'tool.failed.title' : 'tool.subcall.title';
|
|
221
|
-
const
|
|
408
|
+
const column = visibleWidth(SUBCALL_INDENT);
|
|
409
|
+
const title = this.theme.visible(titleToken) ? this.theme.rich(call.title, { token: titleToken, column }) : '';
|
|
222
410
|
const argument = call.argument === undefined || !this.theme.visible('tool.subcall.args')
|
|
223
411
|
? ''
|
|
224
|
-
: ` ${this.theme.
|
|
412
|
+
: ` ${this.theme.rich(call.argument, { token: 'tool.subcall.args', column })}`;
|
|
225
413
|
const drawn = `${title}${argument}`;
|
|
226
414
|
// A row whose every part is hidden draws nothing, and nothing must not
|
|
227
415
|
// cost a line the card does not have.
|
|
228
416
|
if (drawn === '')
|
|
229
417
|
continue;
|
|
230
|
-
|
|
418
|
+
if (open) {
|
|
419
|
+
this.pushStyledWrapped(lines, drawn, width, SUBCALL_INDENT);
|
|
420
|
+
this.pushSubCallOutput(lines, call, width);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
lines.push(this.theme.cut(`${SUBCALL_INDENT}${drawn}`, collapsedEdge(width), '…'));
|
|
424
|
+
}
|
|
425
|
+
spans.push({ key: subCallClickKey(entry.id, call.id), start, end: lines.length, expanded: open });
|
|
231
426
|
}
|
|
232
427
|
const hidden = (card.subCallsTotal ?? subCalls.length) - subCalls.length;
|
|
233
428
|
if (hidden > 0 && this.theme.visible('tool.hint')) {
|
|
234
429
|
lines.push(this.theme.style('tool.hint', this.theme.cut(`${SUBCALL_INDENT}… ${hidden} more calls`, width, '')));
|
|
235
430
|
}
|
|
236
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* What a dispatched call declared and what it produced, under its argument.
|
|
434
|
+
*
|
|
435
|
+
* The parent card keeps only the program's return value, so both are otherwise
|
|
436
|
+
* gone; a reader who opens the row is asking for them, which is why the call's
|
|
437
|
+
* own rows come first and its outcome under them. Each section keeps the
|
|
438
|
+
* retention its card used, so a long run still says what it dropped.
|
|
439
|
+
*/
|
|
440
|
+
pushSubCallOutput(lines, call, width) {
|
|
441
|
+
for (const section of [call.presented, call.output]) {
|
|
442
|
+
if (section === undefined)
|
|
443
|
+
continue;
|
|
444
|
+
for (const row of section.rows) {
|
|
445
|
+
const drawn = this.detailRow(row, section.kind);
|
|
446
|
+
if (drawn === '')
|
|
447
|
+
continue;
|
|
448
|
+
lines.push(this.theme.cut(`${DETAIL_INDENT}${drawn}`, width, ''));
|
|
449
|
+
}
|
|
450
|
+
const hidden = section.totalLines - section.rows.length;
|
|
451
|
+
if (hidden > 0 && this.theme.visible('tool.hint')) {
|
|
452
|
+
// A shell's rows are kept from the end, so its hidden count names the
|
|
453
|
+
// earlier lines; every other kind keeps its head, where the count is of
|
|
454
|
+
// what follows and a neutral wording is what the card uses too.
|
|
455
|
+
const hint = section.kind === 'terminal' ? shellRetentionHint(hidden) : `${hidden} ${CARD_HINT_RETAINED}`;
|
|
456
|
+
lines.push(this.theme.style('tool.hint', this.theme.cut(`${DETAIL_INDENT}${hint}`, width, '')));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* A folded card's one row: label, clipped argument, measured facts, outcome.
|
|
462
|
+
*
|
|
463
|
+
* The outcome rides this row rather than a row of its own, because one line
|
|
464
|
+
* per call is what folding promises. A shell card's hidden rows are counted
|
|
465
|
+
* here too: its output is the answer the reader asked for, and a fold that
|
|
466
|
+
* left no trace of it would read as a call that produced nothing.
|
|
467
|
+
*
|
|
468
|
+
* The argument is the part that gives up room: it is clipped to whatever the
|
|
469
|
+
* label, the facts, and the outcome leave before the edge, so a wide terminal
|
|
470
|
+
* shows more of the call while a narrow one still shows how it ended.
|
|
471
|
+
*/
|
|
472
|
+
renderCollapsedHead(card, hidden, titleToken, glyphToken, width) {
|
|
473
|
+
const edge = collapsedEdge(width);
|
|
474
|
+
const lead = this.cardLead(titleToken, glyphToken);
|
|
475
|
+
const title = this.cardTitle(card, titleToken);
|
|
476
|
+
const separator = this.statSeparator();
|
|
477
|
+
// The facts carry their own lead; the outcome and the count join whatever
|
|
478
|
+
// precedes them, so the folded line reads as one sentence about the run.
|
|
479
|
+
let tail = this.renderStats(card.stats);
|
|
480
|
+
const status = card.kind === 'terminal' && card.status !== undefined && this.theme.visible('tool.terminal.status')
|
|
481
|
+
? this.theme.rich(card.status, { token: 'tool.terminal.status' })
|
|
482
|
+
: '';
|
|
483
|
+
if (status !== '')
|
|
484
|
+
tail += `${separator}${status}`;
|
|
485
|
+
if (card.kind === 'terminal' && hidden > 0) {
|
|
486
|
+
const count = this.renderStats([{ kind: 'size', text: `${hidden} line${hidden === 1 ? '' : 's'}` }], '');
|
|
487
|
+
if (count !== '')
|
|
488
|
+
tail += `${separator}${count}`;
|
|
489
|
+
}
|
|
490
|
+
const fixed = visibleWidth(lead) + visibleWidth(title) + visibleWidth(tail);
|
|
491
|
+
const room = Math.max(0, edge - fixed - (title === '' ? 0 : 1));
|
|
492
|
+
const argument = room > 0 ? this.cardArgument(card, room) : '';
|
|
493
|
+
const head = [title, argument].filter(part => part !== '').join(' ');
|
|
494
|
+
// A row with no visible label or argument opens with the tail, and a line
|
|
495
|
+
// that begins with a separator reads as a row that lost its first word.
|
|
496
|
+
if (head === '' && tail.startsWith(separator))
|
|
497
|
+
tail = tail.slice(separator.length);
|
|
498
|
+
return `${lead}${head}${tail}`;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* One detail row, drawn with its own kind's colours or nothing at all.
|
|
502
|
+
*
|
|
503
|
+
* The row says what it is, so the renderer never guesses from the text: a diff
|
|
504
|
+
* line beginning with "+" is an addition because the presenter said so, not
|
|
505
|
+
* because of its first character.
|
|
506
|
+
*/
|
|
507
|
+
detailRow(row, kind) {
|
|
508
|
+
return row.parts
|
|
509
|
+
.map(part => {
|
|
510
|
+
const token = CARD_ROW_TOKEN[kind]?.[part.class] ?? FALLBACK_ROW_TOKEN;
|
|
511
|
+
return this.theme.visible(token) ? this.theme.rich(part.text, { token, column: visibleWidth(DETAIL_INDENT) }) : '';
|
|
512
|
+
})
|
|
513
|
+
.join('');
|
|
514
|
+
}
|
|
515
|
+
/** The mark that introduces a card, empty when the theme hides its label. */
|
|
516
|
+
cardLead(titleToken, glyphToken) {
|
|
517
|
+
const glyph = this.theme.visible(titleToken) ? this.theme.glyph(glyphToken) : '';
|
|
518
|
+
return glyph === '' ? '' : `${glyph} `;
|
|
519
|
+
}
|
|
520
|
+
/** A card's label, styled, or nothing when the theme hides it. */
|
|
521
|
+
cardTitle(card, titleToken) {
|
|
522
|
+
return this.theme.visible(titleToken) ? this.theme.rich(card.title, { token: titleToken }) : '';
|
|
523
|
+
}
|
|
524
|
+
/** A card's argument, styled and flattened, clipped to `limit` when one is given. */
|
|
525
|
+
cardArgument(card, limit) {
|
|
526
|
+
if (card.argument === undefined || card.argument === '' || !this.theme.visible('tool.args'))
|
|
527
|
+
return '';
|
|
528
|
+
const shown = limit === undefined ? card.argument : clip(oneLine(card.argument), limit);
|
|
529
|
+
return this.theme.rich(shown, { token: 'tool.args' });
|
|
530
|
+
}
|
|
237
531
|
/**
|
|
238
532
|
* A card's header: its label, its argument, and its measured facts.
|
|
239
533
|
*
|
|
240
534
|
* The glyph is returned apart from the body so a wrapped continuation can
|
|
241
|
-
* align under the label rather than under the mark.
|
|
242
|
-
* left out because it needs a row of its own — it is the one
|
|
243
|
-
* be a whole command rather than a word.
|
|
535
|
+
* align under the label rather than under the mark. An opened terminal's
|
|
536
|
+
* argument is left out because it needs a row of its own — it is the one
|
|
537
|
+
* argument that can be a whole command rather than a word.
|
|
244
538
|
*/
|
|
245
539
|
renderHead(card, titleToken, glyphToken) {
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
540
|
+
const lead = this.cardLead(titleToken, glyphToken);
|
|
541
|
+
const title = this.cardTitle(card, titleToken);
|
|
542
|
+
const argument = card.kind === 'terminal' ? '' : this.cardArgument(card);
|
|
543
|
+
const head = [title, argument].filter(part => part !== '').join(' ');
|
|
544
|
+
return { lead, body: head + this.renderStats(card.stats) };
|
|
545
|
+
}
|
|
546
|
+
/** The separator a header uses between its label, its facts, and its outcome. */
|
|
547
|
+
statSeparator() {
|
|
548
|
+
return this.theme.style('tool.stat.separator', STAT_SEPARATOR);
|
|
253
549
|
}
|
|
254
|
-
/**
|
|
255
|
-
|
|
550
|
+
/**
|
|
551
|
+
* The measured facts, each in its own colour, or nothing when none is visible.
|
|
552
|
+
*
|
|
553
|
+
* The lead is a parameter because a header that already carries a title and an
|
|
554
|
+
* argument separates its facts with the same mark as its outcome, while facts
|
|
555
|
+
* on a row of their own stand apart with blank columns.
|
|
556
|
+
*/
|
|
557
|
+
renderStats(stats, lead = STAT_LEAD) {
|
|
256
558
|
if (stats === undefined || stats.length === 0)
|
|
257
559
|
return '';
|
|
258
560
|
const drawn = stats
|
|
259
561
|
.filter(stat => this.theme.visible(STAT_TOKEN[stat.kind]))
|
|
260
|
-
.map(stat => this.theme.
|
|
562
|
+
.map(stat => this.theme.rich(`${STAT_SYMBOL[stat.kind]}${stat.text}`, { token: STAT_TOKEN[stat.kind] }));
|
|
261
563
|
if (drawn.length === 0)
|
|
262
564
|
return '';
|
|
263
|
-
return `${
|
|
565
|
+
return `${lead}${drawn.join(this.statSeparator())}`;
|
|
264
566
|
}
|
|
265
567
|
pushPicker(lines, picker, width) {
|
|
266
568
|
lines.push('');
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
if (picker.note !== undefined && this.theme.visible('picker.note')) {
|
|
273
|
-
this.pushWrapped(lines, picker.note, width, DETAIL_INDENT, text => this.theme.style('picker.note', text));
|
|
274
|
-
}
|
|
275
|
-
if (picker.filter !== '' && this.theme.visible('picker.filter')) {
|
|
276
|
-
lines.push(this.theme.style('picker.filter', this.theme.cut(`${DETAIL_INDENT}filter: ${displayText(picker.filter)}`, width, '')));
|
|
277
|
-
}
|
|
278
|
-
if (picker.above > 0 && this.theme.visible('picker.scrollNewer')) {
|
|
279
|
-
lines.push(this.theme.style('picker.scrollNewer', this.theme.cut(`${OPTION_INDENT}… ${picker.above} newer`, width, '')));
|
|
280
|
-
}
|
|
281
|
-
for (const row of picker.rows) {
|
|
282
|
-
const token = row.current ? 'picker.rowCurrent' : 'picker.row';
|
|
283
|
-
if (!this.theme.visible(token))
|
|
284
|
-
continue;
|
|
285
|
-
const cursor = row.current ? this.theme.glyph('picker.cursor') || CURSOR_MARK : NO_CURSOR;
|
|
286
|
-
const text = row.description === undefined
|
|
287
|
-
? `${cursor} ${row.label}`
|
|
288
|
-
: `${cursor} ${row.label} — ${row.description}`;
|
|
289
|
-
lines.push(this.theme.style(token, this.theme.cut(`${OPTION_INDENT}${displayText(text)}`, width, '')));
|
|
290
|
-
}
|
|
291
|
-
if (picker.below > 0 && this.theme.visible('picker.scrollOlder')) {
|
|
292
|
-
lines.push(this.theme.style('picker.scrollOlder', this.theme.cut(`${OPTION_INDENT}… ${picker.below} older`, width, '')));
|
|
293
|
-
}
|
|
294
|
-
if (this.theme.visible('picker.hint')) {
|
|
295
|
-
lines.push(this.theme.style('picker.hint', this.theme.cut(`${OPTION_INDENT}${displayText(picker.hint)}`, width, '')));
|
|
296
|
-
}
|
|
569
|
+
// The rows come from the renderer a popup also draws through, so a card
|
|
570
|
+
// reads the same whether it sits at the end of the transcript or in a box
|
|
571
|
+
// over it, and a field added to a card reaches both at once.
|
|
572
|
+
lines.push(...pickerCardLines(picker, width, this.theme));
|
|
297
573
|
}
|
|
298
574
|
pushGate(lines, gate, width) {
|
|
299
575
|
lines.push('');
|
|
@@ -302,11 +578,11 @@ export class TranscriptView {
|
|
|
302
578
|
const glyph = this.theme.glyph(glyphToken) || (gate.kind === 'approval' ? APPROVAL_MARK : QUESTION_MARK);
|
|
303
579
|
// The question is the thing being decided, so it wraps rather than being
|
|
304
580
|
// cut: a reader cannot answer a sentence they were not shown.
|
|
305
|
-
this.pushWrapped(lines, gate.title, width, `${glyph} `,
|
|
581
|
+
this.pushWrapped(lines, gate.title, width, `${glyph} `, 'gate.title');
|
|
306
582
|
}
|
|
307
583
|
if (this.theme.visible('gate.detail')) {
|
|
308
584
|
for (const detail of gate.detail) {
|
|
309
|
-
this.pushWrapped(lines, detail, width, DETAIL_INDENT,
|
|
585
|
+
this.pushWrapped(lines, detail, width, DETAIL_INDENT, 'gate.detail');
|
|
310
586
|
}
|
|
311
587
|
}
|
|
312
588
|
gate.options.forEach((option, position) => {
|
|
@@ -336,13 +612,19 @@ export class TranscriptView {
|
|
|
336
612
|
// its frame, its padding, and its cursor for the width it is given.
|
|
337
613
|
if (gate.answerInput !== undefined) {
|
|
338
614
|
const room = Math.max(1, width - visibleWidth(OPTION_INDENT));
|
|
339
|
-
|
|
340
|
-
|
|
615
|
+
// A surface narrower than the indent cannot place the answer: that row is cut
|
|
616
|
+
// here so what is handed out already fits, rather than being trimmed at the
|
|
617
|
+
// terminal past the point where the view could count it. A row that fits is
|
|
618
|
+
// left exactly as the editor drew it, cursor styling included.
|
|
619
|
+
for (const row of gate.answerInput.render(room)) {
|
|
620
|
+
const placed = `${OPTION_INDENT}${row}`;
|
|
621
|
+
lines.push(visibleWidth(placed) <= width ? placed : this.theme.cut(placed, width, ''));
|
|
622
|
+
}
|
|
341
623
|
}
|
|
342
624
|
// The keys are how the gate is answered at all, so they wrap rather than
|
|
343
625
|
// lose their tail at a narrow edge.
|
|
344
626
|
if (this.theme.visible('gate.hint')) {
|
|
345
|
-
this.pushWrapped(lines, gate.hint, width, OPTION_INDENT,
|
|
627
|
+
this.pushWrapped(lines, gate.hint, width, OPTION_INDENT, 'gate.hint');
|
|
346
628
|
}
|
|
347
629
|
}
|
|
348
630
|
/**
|
|
@@ -360,42 +642,41 @@ export class TranscriptView {
|
|
|
360
642
|
const box = row.selected ? CHECKBOX_ON : CHECKBOX_OFF;
|
|
361
643
|
const lead = `${OPTION_INDENT}${cursor} ${box} ${row.number}. `;
|
|
362
644
|
const text = row.description === undefined ? row.label : `${row.label} — ${row.description}`;
|
|
363
|
-
// The text reaches pushWrapped
|
|
364
|
-
//
|
|
365
|
-
this.pushWrapped(lines, text, width, lead,
|
|
645
|
+
// The text reaches pushWrapped undrawn: it is read once there, and reading
|
|
646
|
+
// it twice would show the reader the escape instead of the character.
|
|
647
|
+
this.pushWrapped(lines, text, width, lead, token);
|
|
366
648
|
}
|
|
367
649
|
/** The rows one transcript entry becomes; `live` marks the entry the turn is still writing. */
|
|
368
|
-
renderEntry(entry, lines, width, live) {
|
|
650
|
+
renderEntry(entry, lines, width, live, spans) {
|
|
369
651
|
switch (entry.kind) {
|
|
370
652
|
case 'tool':
|
|
371
|
-
this.pushCard(lines, entry
|
|
653
|
+
this.pushCard(lines, entry, width, spans);
|
|
372
654
|
return;
|
|
373
655
|
case 'reasoning':
|
|
374
|
-
this.pushReasoning(lines, entry, width);
|
|
656
|
+
this.pushReasoning(lines, entry, width, spans);
|
|
375
657
|
return;
|
|
376
658
|
case 'assistant':
|
|
377
|
-
|
|
659
|
+
// The reply is boxed the way the prompt that asked for it is, so one
|
|
660
|
+
// exchange reads as two objects rather than as a box and then a stream.
|
|
661
|
+
this.pushFramed(lines, entry.text, width, live, ANSWER_FACE, 'transcript.assistant.border');
|
|
378
662
|
return;
|
|
379
|
-
case 'user':
|
|
663
|
+
case 'user': {
|
|
380
664
|
if (!this.theme.visible('transcript.user'))
|
|
381
665
|
return;
|
|
382
666
|
// A prompt is boxed wherever it is read, so the row it left in the queue
|
|
383
667
|
// and the row it becomes here are recognisably the same object.
|
|
384
|
-
|
|
385
|
-
text: text => this.theme.style('transcript.user', text),
|
|
386
|
-
border: rule => this.theme.editor.borderColor(rule),
|
|
387
|
-
framed: canFrame(width, this.theme.visible('editor.border')),
|
|
388
|
-
}));
|
|
668
|
+
this.pushFramed(lines, entry.text, width, false, this.userFace(), 'editor.border');
|
|
389
669
|
return;
|
|
670
|
+
}
|
|
390
671
|
case 'notice':
|
|
391
672
|
if (!this.theme.visible('transcript.notice'))
|
|
392
673
|
return;
|
|
393
|
-
this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.notice'),
|
|
674
|
+
this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.notice'), 'transcript.notice');
|
|
394
675
|
return;
|
|
395
676
|
case 'marker':
|
|
396
677
|
if (!this.theme.visible('transcript.marker'))
|
|
397
678
|
return;
|
|
398
|
-
this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.marker'),
|
|
679
|
+
this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.marker'), 'transcript.marker');
|
|
399
680
|
return;
|
|
400
681
|
}
|
|
401
682
|
}
|
|
@@ -411,27 +692,53 @@ export class TranscriptView {
|
|
|
411
692
|
// The revision is part of the key: rows drawn under an older theme table
|
|
412
693
|
// must miss, or a settings change would restyle only the rows that happened
|
|
413
694
|
// to be redrawn for another reason.
|
|
414
|
-
const
|
|
695
|
+
const baseTag = `${width}|${state.expandCards ? 'c' : '-'}${state.expandReasoning ? 'r' : '-'}${state.expandSubCalls ? 'p' : '-'}|${this.theme.revision}`;
|
|
415
696
|
const lines = [];
|
|
697
|
+
const spans = [];
|
|
416
698
|
const settled = this.model.settledCount();
|
|
417
699
|
const entries = this.model.entries();
|
|
418
700
|
for (const [index, entry] of entries.entries()) {
|
|
701
|
+
const start = lines.length;
|
|
702
|
+
// Every fold this entry can answer to is part of the tag, so clicking one
|
|
703
|
+
// message — or one call inside it — rebuilds that message alone while the
|
|
704
|
+
// rows around it stay cached.
|
|
705
|
+
const open = entry.kind === 'tool' ? this.expansionOf(entry) : entry.kind === 'reasoning' ? this.reasoningOpen(entry) : undefined;
|
|
706
|
+
const marks = entry.kind === 'tool'
|
|
707
|
+
? `${open === true ? '+' : '-'}${(entry.card.subCalls ?? []).map(call => (this.subCallOpen(entry.id, call.id) ? '1' : '0')).join('')}`
|
|
708
|
+
: open === true ? '+' : '-';
|
|
709
|
+
const tag = `${baseTag}|${marks}`;
|
|
710
|
+
const local = [];
|
|
419
711
|
// The in-flight rows change on every frame, so caching them would only
|
|
420
712
|
// fill the cache with objects nobody will ask for again.
|
|
421
713
|
if (index >= settled) {
|
|
422
|
-
this.renderEntry(entry, lines, width, true);
|
|
423
|
-
|
|
714
|
+
this.renderEntry(entry, lines, width, true, local);
|
|
715
|
+
// An in-flight entry draws straight into the transcript, so the spans it
|
|
716
|
+
// recorded already name transcript rows; offsetting them again would move
|
|
717
|
+
// every hit target as many rows down as the entry's own start.
|
|
718
|
+
spans.push(...local);
|
|
424
719
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
720
|
+
else {
|
|
721
|
+
const cached = this.rows.lookup(entry, tag);
|
|
722
|
+
const saved = this.entrySpans.get(entry);
|
|
723
|
+
if (cached !== undefined && saved !== undefined) {
|
|
724
|
+
lines.push(...cached);
|
|
725
|
+
local.push(...saved);
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
const rendered = [];
|
|
729
|
+
this.renderEntry(entry, rendered, width, false, local);
|
|
730
|
+
this.rows.store(entry, tag, rendered);
|
|
731
|
+
this.entrySpans.set(entry, local);
|
|
732
|
+
lines.push(...rendered);
|
|
733
|
+
}
|
|
734
|
+
// A cached span is kept relative to its entry so the entry can hand it
|
|
735
|
+
// back; a click needs it in transcript rows.
|
|
736
|
+
for (const span of local) {
|
|
737
|
+
spans.push({ key: span.key, start: span.start + start, end: span.end + start, expanded: span.expanded });
|
|
738
|
+
}
|
|
429
739
|
}
|
|
430
|
-
const rendered = [];
|
|
431
|
-
this.renderEntry(entry, rendered, width, false);
|
|
432
|
-
this.rows.store(entry, tag, rendered);
|
|
433
|
-
lines.push(...rendered);
|
|
434
740
|
}
|
|
741
|
+
this.spans = spans;
|
|
435
742
|
const picker = this.options.picker?.();
|
|
436
743
|
if (picker !== undefined)
|
|
437
744
|
this.pushPicker(lines, picker, width);
|