@aiwayds/dsh-tui-pi 0.1.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/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/dsh-tui-pi +5 -0
- package/cordis.patch.yml +9 -0
- package/lib/append-system.d.ts +66 -0
- package/lib/append-system.js +161 -0
- package/lib/append-system.js.map +1 -0
- package/lib/commands.d.ts +53 -0
- package/lib/commands.js +167 -0
- package/lib/commands.js.map +1 -0
- package/lib/dsh-events.d.ts +106 -0
- package/lib/dsh-events.js +30 -0
- package/lib/dsh-events.js.map +1 -0
- package/lib/editor.d.ts +28 -0
- package/lib/editor.js +70 -0
- package/lib/editor.js.map +1 -0
- package/lib/footer.d.ts +36 -0
- package/lib/footer.js +112 -0
- package/lib/footer.js.map +1 -0
- package/lib/frame.d.ts +35 -0
- package/lib/frame.js +75 -0
- package/lib/frame.js.map +1 -0
- package/lib/git.d.ts +17 -0
- package/lib/git.js +51 -0
- package/lib/git.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +781 -0
- package/lib/index.js.map +1 -0
- package/lib/instructions.d.ts +29 -0
- package/lib/instructions.js +67 -0
- package/lib/instructions.js.map +1 -0
- package/lib/live-widgets.d.ts +85 -0
- package/lib/live-widgets.js +218 -0
- package/lib/live-widgets.js.map +1 -0
- package/lib/messages.d.ts +277 -0
- package/lib/messages.js +734 -0
- package/lib/messages.js.map +1 -0
- package/lib/permission.d.ts +27 -0
- package/lib/permission.js +48 -0
- package/lib/permission.js.map +1 -0
- package/lib/provider-catalog.d.ts +114 -0
- package/lib/provider-catalog.js +124 -0
- package/lib/provider-catalog.js.map +1 -0
- package/lib/quotes.d.ts +28 -0
- package/lib/quotes.js +144 -0
- package/lib/quotes.js.map +1 -0
- package/lib/reload.d.ts +23 -0
- package/lib/reload.js +171 -0
- package/lib/reload.js.map +1 -0
- package/lib/selectors.d.ts +48 -0
- package/lib/selectors.js +261 -0
- package/lib/selectors.js.map +1 -0
- package/lib/session.d.ts +157 -0
- package/lib/session.js +555 -0
- package/lib/session.js.map +1 -0
- package/lib/sessions.d.ts +73 -0
- package/lib/sessions.js +253 -0
- package/lib/sessions.js.map +1 -0
- package/lib/settings.d.ts +180 -0
- package/lib/settings.js +1328 -0
- package/lib/settings.js.map +1 -0
- package/lib/text.d.ts +22 -0
- package/lib/text.js +45 -0
- package/lib/text.js.map +1 -0
- package/lib/theme/index.d.ts +79 -0
- package/lib/theme/index.js +121 -0
- package/lib/theme/index.js.map +1 -0
- package/lib/theme/palette.d.ts +56 -0
- package/lib/theme/palette.js +154 -0
- package/lib/theme/palette.js.map +1 -0
- package/lib/theme-settings.d.ts +68 -0
- package/lib/theme-settings.js +223 -0
- package/lib/theme-settings.js.map +1 -0
- package/lib/tui.d.ts +70 -0
- package/lib/tui.js +206 -0
- package/lib/tui.js.map +1 -0
- package/lib/welcome.d.ts +91 -0
- package/lib/welcome.js +281 -0
- package/lib/welcome.js.map +1 -0
- package/package.json +50 -0
- package/patches/@earendil-works__pi-tui.patch +72 -0
- package/pnpm-workspace.yaml +2 -0
- package/templates/APPEND_SYSTEM.md +34 -0
package/lib/messages.js
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript rendering: turns dsh session events into pi-tui components.
|
|
3
|
+
*
|
|
4
|
+
* Incremental by design (pi-turbo lesson): every event does O(event) work —
|
|
5
|
+
* streaming deltas update one Text in place, tool cards are keyed by callId,
|
|
6
|
+
* and nothing ever re-scans the session log.
|
|
7
|
+
*
|
|
8
|
+
* Streaming strategy: during `assistant/chunk` the raw text grows in a plain
|
|
9
|
+
* Text component; on the assembled `assistant/message` the streaming component
|
|
10
|
+
* is replaced by proper Markdown rendering. This keeps per-token cost at
|
|
11
|
+
* O(accumulated text) instead of re-parsing markdown on every delta.
|
|
12
|
+
*
|
|
13
|
+
* Panels: think blocks and tool cards render as boxed rows — a full box
|
|
14
|
+
* border (top border + header row + body rows + bottom border). The
|
|
15
|
+
* configured height counts the DISPLAYED rows — the header line plus the
|
|
16
|
+
* content rows ('5' shows five rows; the two box borders add two more
|
|
17
|
+
* physical rows, so a '5' box is seven terminal rows tall). The height is
|
|
18
|
+
* configurable through the `dsh-tui` settings namespace ('5'/'7'/'10'
|
|
19
|
+
* displayed rows, or 'all' to print the full body without a row cap). The
|
|
20
|
+
* transcript doc is a plain Container
|
|
21
|
+
* inside the outer ScrollView, so pi-tui 0.84.2 never lays out nested
|
|
22
|
+
* components (a Container without a layout node renders by simple
|
|
23
|
+
* concatenation — verified in dist/layout.js) and an inner ScrollView can
|
|
24
|
+
* never obtain a viewport. The body is therefore a padded tail of the last
|
|
25
|
+
* body-row budget (or every row, in 'all' mode) rather than an internal
|
|
26
|
+
* scroll; every row (borders included) carries the panel background. In
|
|
27
|
+
* 'all' mode the unbounded content stays bounded on screen: a streaming
|
|
28
|
+
* reasoning panel boxes only a STREAMING_TAIL_LINES live tail while chunks
|
|
29
|
+
* are in flight (the assembled message renders the full body), and a settled
|
|
30
|
+
* tool card keeps at most ALL_TOOL_RESULT_LINES rows with a drop marker.
|
|
31
|
+
*
|
|
32
|
+
* Theme hot-switch: every applied operation is appended to `replay` (O(1)
|
|
33
|
+
* per event — never a render-path scan). `setTheme` is an explicit user
|
|
34
|
+
* action, so it may do a one-off full rebuild: clear the doc and re-apply
|
|
35
|
+
* the buffered operations against the new theme. Streaming and tool cards
|
|
36
|
+
* rebuild exactly as they were applied, so an in-flight stream simply
|
|
37
|
+
* continues `setText` on its rebuilt component. (The live Todos/Agents
|
|
38
|
+
* widgets live outside the transcript — see live-widgets.ts.)
|
|
39
|
+
*
|
|
40
|
+
* Welcome banner: the first replay op, pushed at construction (the doc is
|
|
41
|
+
* cleared first, replacing tui.ts's startup placeholder). It stays at the
|
|
42
|
+
* top of the doc — the event flow appends below it, and every rebuild
|
|
43
|
+
* (relayout/setTheme) reproduces it first with the current theme.
|
|
44
|
+
*/
|
|
45
|
+
import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui';
|
|
46
|
+
import { ansiFg, RESET } from "./theme/index.js";
|
|
47
|
+
import { clipToWidth, visibleWidth } from "./text.js";
|
|
48
|
+
import { buildWelcomeBanner } from "./welcome.js";
|
|
49
|
+
import { formatDailyQuote, pickDailyQuote } from "./quotes.js";
|
|
50
|
+
/**
|
|
51
|
+
* Default displayed height of a think/tool panel: the header line plus the
|
|
52
|
+
* content rows. The single default for every '5' fallback (the renderer
|
|
53
|
+
* constructor, the settings schema default/entry/narrowing) — other heights
|
|
54
|
+
* are set through the `panelHeight` setting.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_PANEL_HEIGHT = '5';
|
|
57
|
+
/** Content rows inside the default panel (DEFAULT_PANEL_HEIGHT displayed rows − the header row). */
|
|
58
|
+
const PANEL_BODY_LINES = Number(DEFAULT_PANEL_HEIGHT) - 1;
|
|
59
|
+
/**
|
|
60
|
+
* 'all' streaming cap: while a reasoning stream is in flight, the panel boxes
|
|
61
|
+
* only this many trailing rows. Without the cap every chunk would re-box the
|
|
62
|
+
* whole accumulated body — O(accumulated) per chunk, O(n²) over the stream
|
|
63
|
+
* (3000 lines ≈ 22s vs 155ms at a fixed height). The live tail is transient:
|
|
64
|
+
* the assembled `assistant/message` reasoning block (and the replay rebuilds)
|
|
65
|
+
* render the full body.
|
|
66
|
+
*/
|
|
67
|
+
export const STREAMING_TAIL_LINES = 200;
|
|
68
|
+
/**
|
|
69
|
+
* 'all' settle cap: a settled tool card keeps at most this many body rows,
|
|
70
|
+
* with a `… (+N lines)` marker for the drop. The unlimited body would
|
|
71
|
+
* otherwise hitch the frame and balloon memory on a 50k-line tool result.
|
|
72
|
+
*/
|
|
73
|
+
export const ALL_TOOL_RESULT_LINES = 2000;
|
|
74
|
+
/** Thinking panel header row content (icon + label), 11 visible columns. */
|
|
75
|
+
const THINKING_HEADER = '💭 thinking';
|
|
76
|
+
/**
|
|
77
|
+
* Fallback terminal columns when the real width is unknown (non-TTY
|
|
78
|
+
* contexts, e.g. tests): conservative so no sane terminal wraps.
|
|
79
|
+
*/
|
|
80
|
+
const PANEL_LINE_CAP_FALLBACK = 200;
|
|
81
|
+
/**
|
|
82
|
+
* Terminal columns a panel body row's CONTENT may occupy so the whole
|
|
83
|
+
* bordered row renders on exactly one physical line: the body Text wraps at
|
|
84
|
+
* `width - paddingX*2` (paddingX = 1), every row carries 4 columns of box
|
|
85
|
+
* chrome (`│ ` … ` │`), and tool rows add a 2-column indent — hence the -6
|
|
86
|
+
* (think) and -8 (tool, indent = 2) headroom.
|
|
87
|
+
*/
|
|
88
|
+
export function panelLineCap(columns, indent = 0) {
|
|
89
|
+
return Math.max(1, (columns === undefined ? PANEL_LINE_CAP_FALLBACK : columns) - 6 - indent);
|
|
90
|
+
}
|
|
91
|
+
/** Full visible width of one bordered panel row, box chrome included. */
|
|
92
|
+
export function panelBoxWidth(columns) {
|
|
93
|
+
return panelLineCap(columns) + 4;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One bordered panel row of exactly `boxWidth` visible columns: side borders
|
|
97
|
+
* in `borderFg`, `inner` (already styled, already clipped) left-aligned and
|
|
98
|
+
* padded with spaces to the full box width. No trailing RESET — the panel bg
|
|
99
|
+
* function terminates the row and paints the whole width.
|
|
100
|
+
*/
|
|
101
|
+
function borderedRow(boxWidth, borderFg, inner) {
|
|
102
|
+
const pad = Math.max(0, boxWidth - 4 - visibleWidth(inner));
|
|
103
|
+
return `${borderFg}│ ${inner}${' '.repeat(pad)}${borderFg} │`;
|
|
104
|
+
}
|
|
105
|
+
/** Top border line (`┌─…─┐`), `boxWidth` columns wide, in `borderFg`. */
|
|
106
|
+
function panelTopBorder(boxWidth, borderFg) {
|
|
107
|
+
return `${borderFg}┌${'─'.repeat(Math.max(0, boxWidth - 2))}┐`;
|
|
108
|
+
}
|
|
109
|
+
/** Bottom border line (`└─…─┘`), `boxWidth` columns wide, in `borderFg`. */
|
|
110
|
+
function panelBottomBorder(boxWidth, borderFg) {
|
|
111
|
+
return `${borderFg}└${'─'.repeat(Math.max(0, boxWidth - 2))}┘`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Clip an unstyled line to one physical panel row. Must run BEFORE styling:
|
|
115
|
+
* clipToWidth counts per grapheme, so the ASCII fragments of an SGR code
|
|
116
|
+
* would count as visible columns (verified against pi-tui 0.84.2) — clipping
|
|
117
|
+
* plain text first, then applying ANSI, keeps the accounting exact.
|
|
118
|
+
* `indent` is the leading content indent the row carries (2 for tool rows).
|
|
119
|
+
* Carriage returns are stripped first: pi-tui's wrapTextWithAnsi splits on
|
|
120
|
+
* `/\r\n|\r|\n/`, so a bare \r (progress bars, CRLF tool output) would break
|
|
121
|
+
* the fixed panel rows just like a wrap would — the panel line is one row,
|
|
122
|
+
* not a line record.
|
|
123
|
+
*/
|
|
124
|
+
export function clipPanelLine(text, indent = 0) {
|
|
125
|
+
return clipToWidth(text.replace(/\r/g, ''), panelLineCap(process.stdout.columns, indent));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Compose the bordered body Text content (boxed rows plus the bottom border)
|
|
129
|
+
* from already-styled, already-clipped lines: keep the tail — newest rows
|
|
130
|
+
* win — pad short content with empty boxed rows, then append the bottom
|
|
131
|
+
* border. `bodyRows` is the panel's body-row budget (default PANEL_BODY_LINES)
|
|
132
|
+
* or 'all': with 'all' every line is kept verbatim, nothing is padded, and
|
|
133
|
+
* only the bottom border is appended (the box stays closed). Every row is
|
|
134
|
+
* one `boxWidth`-wide boxed line (`│ ` … ` │`, see borderedRow);
|
|
135
|
+
* `borderFg` is the panelBorder SGR prefix (no trailing RESET — the panel
|
|
136
|
+
* bg function terminates the row). Pad rows carry the box characters, so
|
|
137
|
+
* they survive Text's `text.trim() === ''` fast path, which would otherwise
|
|
138
|
+
* drop a body of only empty rows; the border SGR does not touch the
|
|
139
|
+
* background, so the panel bg function still paints the full row width.
|
|
140
|
+
* Callers clip each line with `clipPanelLine` BEFORE styling — otherwise a
|
|
141
|
+
* styled line that outgrows `width - paddingX*2` wraps and the panel
|
|
142
|
+
* exceeds its configured rows.
|
|
143
|
+
*/
|
|
144
|
+
export function panelBodyText(lines, boxWidth, borderFg, bodyRows = PANEL_BODY_LINES) {
|
|
145
|
+
const visible = bodyRows === 'all'
|
|
146
|
+
? [...lines]
|
|
147
|
+
: lines.length > bodyRows ? lines.slice(-bodyRows) : [...lines];
|
|
148
|
+
if (bodyRows !== 'all') {
|
|
149
|
+
while (visible.length < bodyRows)
|
|
150
|
+
visible.push('');
|
|
151
|
+
}
|
|
152
|
+
return [...visible.map(line => borderedRow(boxWidth, borderFg, line)), panelBottomBorder(boxWidth, borderFg)].join('\n');
|
|
153
|
+
}
|
|
154
|
+
/** First text content of a tool result, raw lines. */
|
|
155
|
+
function resultTextLines(content) {
|
|
156
|
+
for (const block of content) {
|
|
157
|
+
if (block.type === 'text' && block.text !== undefined) {
|
|
158
|
+
return block.text.replace(/\s+$/u, '').split('\n');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The tool header's subject word: the file path for read/write-style tools,
|
|
165
|
+
* the command's first word for cli-style tools ('git', 'python') — the first
|
|
166
|
+
* whitespace token of the highest-priority string argument (same key
|
|
167
|
+
* priority as callDetail's summary). '' when the arguments carry no usable
|
|
168
|
+
* string (the header then shows the bare tool name).
|
|
169
|
+
*/
|
|
170
|
+
export function toolSubject(rawArguments) {
|
|
171
|
+
const firstWord = (value) => value.trim().split(/\s+/u)[0] ?? '';
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(rawArguments);
|
|
174
|
+
for (const key of ['command', 'file_path', 'path', 'query', 'url', 'pattern', 'description']) {
|
|
175
|
+
const value = parsed[key];
|
|
176
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
177
|
+
return firstWord(value);
|
|
178
|
+
}
|
|
179
|
+
for (const value of Object.values(parsed)) {
|
|
180
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
181
|
+
return firstWord(value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Model-controlled rawArguments; non-JSON yields no subject.
|
|
186
|
+
}
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
/** One-line summary of the call arguments, per common tool shape. */
|
|
190
|
+
function callDetail(rawArguments, limit = 120) {
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(rawArguments);
|
|
193
|
+
const parts = [];
|
|
194
|
+
if (typeof parsed.command === 'string')
|
|
195
|
+
parts.push(`$ ${parsed.command}`);
|
|
196
|
+
if (typeof parsed.file_path === 'string')
|
|
197
|
+
parts.push(parsed.file_path);
|
|
198
|
+
if (typeof parsed.path === 'string' && parts.length === 0)
|
|
199
|
+
parts.push(parsed.path);
|
|
200
|
+
if (typeof parsed.pattern === 'string')
|
|
201
|
+
parts.push(`pattern: ${parsed.pattern}`);
|
|
202
|
+
if (typeof parsed.query === 'string')
|
|
203
|
+
parts.push(`query: ${parsed.query}`);
|
|
204
|
+
if (typeof parsed.url === 'string')
|
|
205
|
+
parts.push(parsed.url);
|
|
206
|
+
if (typeof parsed.description === 'string' && parts.length === 0)
|
|
207
|
+
parts.push(parsed.description);
|
|
208
|
+
if (parts.length === 0) {
|
|
209
|
+
const flat = rawArguments.replace(/\s+/g, ' ');
|
|
210
|
+
parts.push(flat);
|
|
211
|
+
}
|
|
212
|
+
const joined = parts.join(' ').replace(/\n/g, ' ⏎ ');
|
|
213
|
+
return clipToWidth(joined, limit);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return '';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export class TranscriptRenderer {
|
|
220
|
+
doc;
|
|
221
|
+
theme;
|
|
222
|
+
requestRender;
|
|
223
|
+
/** Configured panel height ('5'/'7'/'10' total rows, or 'all' = uncapped). */
|
|
224
|
+
panelHeight;
|
|
225
|
+
streaming;
|
|
226
|
+
toolCards = new Map();
|
|
227
|
+
/** Text of the prompt echoed locally on submit; the matching session event is deduped. */
|
|
228
|
+
lastEcho;
|
|
229
|
+
/**
|
|
230
|
+
* Append-only buffer of every applied operation (O(1) per event). The
|
|
231
|
+
* render path never scans it; `setTheme` — an explicit user action — is
|
|
232
|
+
* the only reader, replaying it once against the new theme.
|
|
233
|
+
*/
|
|
234
|
+
replay = [];
|
|
235
|
+
/**
|
|
236
|
+
* The session's daily quote — rolled once here, so every rebuild
|
|
237
|
+
* (relayout/setTheme replay) re-renders the same line and only a fresh
|
|
238
|
+
* session rolls a new one (see quotes.ts).
|
|
239
|
+
*/
|
|
240
|
+
dailyQuote = pickDailyQuote();
|
|
241
|
+
constructor(doc, theme, requestRender, panelHeight = DEFAULT_PANEL_HEIGHT) {
|
|
242
|
+
this.doc = doc;
|
|
243
|
+
this.theme = theme;
|
|
244
|
+
this.requestRender = requestRender;
|
|
245
|
+
this.panelHeight = panelHeight;
|
|
246
|
+
// The welcome banner is the first operation: render it now (replacing the
|
|
247
|
+
// startup placeholder line startTui added — the banner is the new startup
|
|
248
|
+
// screen) and buffer it as the first replay op, so relayout/setTheme
|
|
249
|
+
// rebuild it at the top of the doc while events keep appending after it.
|
|
250
|
+
this.replay.push({ kind: 'welcome' });
|
|
251
|
+
this.doc.clear();
|
|
252
|
+
this.renderWelcome();
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Content-row budget for the configured panel height: the displayed row
|
|
256
|
+
* count minus the header row ('5' → 4 content rows), or 'all' when the
|
|
257
|
+
* panel prints its full body. The box borders are not part of the budget.
|
|
258
|
+
*/
|
|
259
|
+
panelBodyRows() {
|
|
260
|
+
return this.panelHeight === 'all' ? 'all' : Number(this.panelHeight) - 1;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Switch the configured panel height. Returns whether the height actually
|
|
264
|
+
* changed — the settings watch sink relayouts only then; `relayout` is the
|
|
265
|
+
* replay rebuild that repaints every panel (streaming, tool cards, settled
|
|
266
|
+
* cards) at the new row budget.
|
|
267
|
+
*/
|
|
268
|
+
setPanelHeight(panelHeight) {
|
|
269
|
+
if (panelHeight === this.panelHeight)
|
|
270
|
+
return false;
|
|
271
|
+
this.panelHeight = panelHeight;
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
applyEvent(event) {
|
|
275
|
+
this.replay.push({ kind: 'event', event });
|
|
276
|
+
switch (event.type) {
|
|
277
|
+
case 'user/message':
|
|
278
|
+
this.dropStreaming();
|
|
279
|
+
this.renderUserMessage(event);
|
|
280
|
+
break;
|
|
281
|
+
case 'assistant/chunk':
|
|
282
|
+
this.applyChunk(event.data.turn, event.data.step, event.data.chunk);
|
|
283
|
+
break;
|
|
284
|
+
case 'assistant/message':
|
|
285
|
+
this.finalizeStreaming();
|
|
286
|
+
this.renderAssistantMessage(event);
|
|
287
|
+
break;
|
|
288
|
+
case 'tool/call':
|
|
289
|
+
this.addToolCard(event.data.callId, event.data.name, event.data.arguments);
|
|
290
|
+
break;
|
|
291
|
+
case 'tool/result':
|
|
292
|
+
this.settleToolCard(event);
|
|
293
|
+
break;
|
|
294
|
+
case 'todo/write':
|
|
295
|
+
// Todos render in the fixed live widget (LiveWidgets), not the
|
|
296
|
+
// transcript; index.ts routes the event there.
|
|
297
|
+
break;
|
|
298
|
+
case 'turn/end':
|
|
299
|
+
this.renderTurnEnd(event.data.reason);
|
|
300
|
+
break;
|
|
301
|
+
case 'command/run':
|
|
302
|
+
case 'command/done':
|
|
303
|
+
// Command flow nodes: rendered once the slash-command phase lands.
|
|
304
|
+
break;
|
|
305
|
+
default:
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Render a submitted prompt immediately, before the session echoes it back. */
|
|
310
|
+
renderPromptEcho(text) {
|
|
311
|
+
// Buffer the raw text: the echo bubble renders it verbatim, while the
|
|
312
|
+
// session-echo dedup key is the trimmed form (lastEcho in renderUserText).
|
|
313
|
+
this.replay.push({ kind: 'promptEcho', text });
|
|
314
|
+
this.lastEcho = text.trim();
|
|
315
|
+
this.renderUserText(text);
|
|
316
|
+
}
|
|
317
|
+
/** Render one executed slash command line with its outcome. */
|
|
318
|
+
renderCommandEcho(line, error, text) {
|
|
319
|
+
this.replay.push({ kind: 'commandEcho', line, error, text });
|
|
320
|
+
this.appendLine(ansiFg(this.theme.palette.accent) + `⌘ ${line}` + RESET);
|
|
321
|
+
if (error !== undefined) {
|
|
322
|
+
this.appendLine(ansiFg(this.theme.palette.danger) + `✘ ${error}` + RESET);
|
|
323
|
+
}
|
|
324
|
+
else if (text !== undefined && text.trim() !== '') {
|
|
325
|
+
this.appendLine(ansiFg(this.theme.palette.fgMuted) + text + RESET);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Append a transcript line that has no matching session event (a
|
|
330
|
+
* transient status notice or the sole on-screen record of an error).
|
|
331
|
+
* Buffered as a replay op like echoes, so a theme-switch rebuild keeps it.
|
|
332
|
+
* `error` lines get the ✘ danger treatment; `info` lines the attention
|
|
333
|
+
* color (the Ctrl+C cancel hint) without a prefix.
|
|
334
|
+
*/
|
|
335
|
+
renderNotice(text, level = 'error') {
|
|
336
|
+
this.replay.push({ kind: 'notice', text, level });
|
|
337
|
+
if (level === 'error') {
|
|
338
|
+
this.appendLine(ansiFg(this.theme.palette.danger) + `✘ ${text}` + RESET);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
this.appendLine(ansiFg(this.theme.palette.attention) + text + RESET);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Repaint the whole transcript against a new theme: clear the doc and
|
|
346
|
+
* replay the buffered operations. Per-op requestRenders coalesce into a
|
|
347
|
+
* single pi-tui frame (requestRender is nextTick-throttled), so the switch
|
|
348
|
+
* repaints once, with no intermediate flicker. An in-flight stream keeps
|
|
349
|
+
* its accumulated text — the replay rebuilds its Text and later chunks
|
|
350
|
+
* continue setText on it. No-op when the theme bundle is unchanged
|
|
351
|
+
* (themes are module singletons; the settings watcher may echo our own
|
|
352
|
+
* write).
|
|
353
|
+
*/
|
|
354
|
+
setTheme(theme) {
|
|
355
|
+
if (theme === this.theme)
|
|
356
|
+
return;
|
|
357
|
+
this.theme = theme;
|
|
358
|
+
const ops = [...this.replay];
|
|
359
|
+
this.clear();
|
|
360
|
+
for (const op of ops)
|
|
361
|
+
this.applyOp(op);
|
|
362
|
+
this.requestRender();
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Repaint the whole transcript at the current terminal width — the resize
|
|
366
|
+
* counterpart of `setTheme`. On stdout `resize` pi-tui re-renders every
|
|
367
|
+
* component with the new columns, but bordered panel rows were padded to
|
|
368
|
+
* the OLD box width, so a narrowing terminal wraps every row and shatters
|
|
369
|
+
* the fixed-height panels. Clear and re-apply the buffered operations
|
|
370
|
+
* exactly like a theme switch: an in-flight stream keeps its accumulated
|
|
371
|
+
* text (the replay rebuilds its Text and later chunks continue setText on
|
|
372
|
+
* it), tool cards keep their settle state, todos reappear. No-op when the
|
|
373
|
+
* replay is empty — that guards the doc emptied by /new (clear()), which
|
|
374
|
+
* must stay empty until the next prompt: the welcome banner is the startup
|
|
375
|
+
* screen of a TUI run and must not resurrect here.
|
|
376
|
+
*/
|
|
377
|
+
relayout() {
|
|
378
|
+
if (this.replay.length === 0)
|
|
379
|
+
return;
|
|
380
|
+
const ops = [...this.replay];
|
|
381
|
+
this.clear();
|
|
382
|
+
for (const op of ops)
|
|
383
|
+
this.applyOp(op);
|
|
384
|
+
this.requestRender();
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Drop everything rendered so far (`/new`). The next prompt opens a fresh
|
|
388
|
+
* agent; the welcome banner goes with the rest — it is the startup screen
|
|
389
|
+
* of a TUI run, not persistent transcript chrome.
|
|
390
|
+
*/
|
|
391
|
+
clear() {
|
|
392
|
+
this.streaming = undefined;
|
|
393
|
+
this.toolCards.clear();
|
|
394
|
+
this.lastEcho = undefined;
|
|
395
|
+
this.replay.length = 0;
|
|
396
|
+
this.doc.clear();
|
|
397
|
+
}
|
|
398
|
+
/** Re-apply one buffered operation against the current theme. */
|
|
399
|
+
applyOp(op) {
|
|
400
|
+
switch (op.kind) {
|
|
401
|
+
case 'welcome':
|
|
402
|
+
// Mirror applyEvent's self-push: relayout/setTheme replay would
|
|
403
|
+
// otherwise consume the welcome op on the first rebuild and freeze
|
|
404
|
+
// the banner at that width (it is width-dependent since the pixel
|
|
405
|
+
// letters — a narrowing resize would degrade, a widening one never
|
|
406
|
+
// restore, and later theme switches could not repaint it either).
|
|
407
|
+
this.replay.push({ kind: 'welcome' });
|
|
408
|
+
this.renderWelcome();
|
|
409
|
+
break;
|
|
410
|
+
case 'event':
|
|
411
|
+
this.applyEvent(op.event);
|
|
412
|
+
break;
|
|
413
|
+
case 'promptEcho':
|
|
414
|
+
this.renderPromptEcho(op.text);
|
|
415
|
+
break;
|
|
416
|
+
case 'commandEcho':
|
|
417
|
+
this.renderCommandEcho(op.line, op.error, op.text);
|
|
418
|
+
break;
|
|
419
|
+
case 'notice':
|
|
420
|
+
this.renderNotice(op.text, op.level);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// ---------------------------------------------------------------- banner --
|
|
425
|
+
/**
|
|
426
|
+
* The startup welcome banner (whale pixel art + pixel-letter wordmark)
|
|
427
|
+
* with the daily quote caption beneath it, as the doc's first content:
|
|
428
|
+
* a leading spacer, the banner Text, a spacer, the quote Text, then the
|
|
429
|
+
* trailing spacer that matches the message-block rhythm. The leading
|
|
430
|
+
* spacer keeps the banner from pressing against the top of the transcript
|
|
431
|
+
* (the startup placeholder line it replaces sat flush at row 0). The
|
|
432
|
+
* whale and the letters keep their brand blue across themes — the banner
|
|
433
|
+
* is theme-independent (gaps stay transparent over the terminal default
|
|
434
|
+
* background — see welcome.ts); the quote is the one theme-tinted line
|
|
435
|
+
* (fgSubtle, rebuilt with the live theme by the replay). The banner is
|
|
436
|
+
* built at the current terminal width: below 96 columns it degrades to
|
|
437
|
+
* the whale alone, and every rebuild (relayout/setTheme replay) reads the
|
|
438
|
+
* width afresh, so narrowing drops the wordmark and widening restores it.
|
|
439
|
+
* The quote line is clipped to the terminal width before styling (the
|
|
440
|
+
* repo rule — ANSI never goes through the clipper), so it never wraps.
|
|
441
|
+
*/
|
|
442
|
+
renderWelcome() {
|
|
443
|
+
this.doc.addChild(new Spacer(1));
|
|
444
|
+
this.doc.addChild(new Text(buildWelcomeBanner(process.stdout.columns), 1, 0));
|
|
445
|
+
this.doc.addChild(new Spacer(1));
|
|
446
|
+
// (columns ?? Infinity): non-TTY contexts (tests) get the full line.
|
|
447
|
+
const quote = clipToWidth(formatDailyQuote(this.dailyQuote), (process.stdout.columns ?? Infinity) - 2);
|
|
448
|
+
this.doc.addChild(new Text(ansiFg(this.theme.palette.fgSubtle) + quote + RESET, 1, 0));
|
|
449
|
+
this.doc.addChild(new Spacer(1));
|
|
450
|
+
this.requestRender();
|
|
451
|
+
}
|
|
452
|
+
// ------------------------------------------------------------------ user --
|
|
453
|
+
renderUserMessage(event) {
|
|
454
|
+
const message = event.data;
|
|
455
|
+
const textParts = message.content
|
|
456
|
+
.filter((block) => block.type === 'text')
|
|
457
|
+
.map(block => block.text);
|
|
458
|
+
const text = textParts.join('\n').trim();
|
|
459
|
+
if (text === '')
|
|
460
|
+
return;
|
|
461
|
+
const kind = message.source.kind;
|
|
462
|
+
if (kind === 'user') {
|
|
463
|
+
// Dedup the session echo of a prompt we already rendered locally on submit.
|
|
464
|
+
if (this.lastEcho === text) {
|
|
465
|
+
this.lastEcho = undefined;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
this.lastEcho = undefined;
|
|
469
|
+
this.renderUserText(text);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
// Injected context (agent.inject): file-change notices, skill content, …
|
|
473
|
+
const first = text.split('\n')[0] ?? '';
|
|
474
|
+
const preview = clipToWidth(first, 120);
|
|
475
|
+
this.appendLine(ansiFg(this.theme.palette.fgSubtle) + `ⓘ ${preview}` + RESET);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
renderUserText(text) {
|
|
479
|
+
const prefixed = text.split('\n').map(line => `▎ ${line}`).join('\n');
|
|
480
|
+
const bubble = new Text(prefixed, 1, 0, this.theme.chat.userMessageBg);
|
|
481
|
+
this.doc.addChild(bubble);
|
|
482
|
+
this.doc.addChild(new Spacer(1));
|
|
483
|
+
this.requestRender();
|
|
484
|
+
}
|
|
485
|
+
// ------------------------------------------------------------- streaming --
|
|
486
|
+
applyChunk(turn, step, chunk) {
|
|
487
|
+
if (chunk.type !== 'text-delta' && chunk.type !== 'reasoning-delta')
|
|
488
|
+
return;
|
|
489
|
+
const delta = chunk.text ?? '';
|
|
490
|
+
if (delta === '')
|
|
491
|
+
return;
|
|
492
|
+
if (this.streaming === undefined || this.streaming.turn !== turn || this.streaming.step !== step) {
|
|
493
|
+
this.finalizeStreaming();
|
|
494
|
+
this.streaming = { turn, step, text: '', reasoning: '' };
|
|
495
|
+
}
|
|
496
|
+
const state = this.streaming;
|
|
497
|
+
if (chunk.type === 'text-delta') {
|
|
498
|
+
if (state.textComponent === undefined) {
|
|
499
|
+
state.textComponent = new Text('', 1, 0);
|
|
500
|
+
this.doc.addChild(state.textComponent);
|
|
501
|
+
}
|
|
502
|
+
state.text += delta;
|
|
503
|
+
state.textComponent.setText(ansiFg(this.theme.palette.fgDefault) + state.text + RESET);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
if (state.reasoningPanel === undefined) {
|
|
507
|
+
state.reasoningPanel = this.createThinkingPanel();
|
|
508
|
+
this.doc.addChild(state.reasoningPanel.container);
|
|
509
|
+
}
|
|
510
|
+
state.reasoning += delta;
|
|
511
|
+
// Live tail: replace only the body text of the existing panel — O(1),
|
|
512
|
+
// never rebuild the block. In 'all' mode the body is the bounded
|
|
513
|
+
// streaming tail (see STREAMING_TAIL_LINES), so per-chunk cost stays
|
|
514
|
+
// O(tail), not O(accumulated).
|
|
515
|
+
state.reasoningPanel.setBody(this.thinkingBody(state.reasoning, true));
|
|
516
|
+
}
|
|
517
|
+
this.requestRender();
|
|
518
|
+
}
|
|
519
|
+
finalizeStreaming() {
|
|
520
|
+
const state = this.streaming;
|
|
521
|
+
if (state === undefined)
|
|
522
|
+
return;
|
|
523
|
+
this.streaming = undefined;
|
|
524
|
+
if (state.textComponent !== undefined)
|
|
525
|
+
this.doc.removeChild(state.textComponent);
|
|
526
|
+
if (state.reasoningPanel !== undefined)
|
|
527
|
+
this.doc.removeChild(state.reasoningPanel.container);
|
|
528
|
+
}
|
|
529
|
+
/** Keep streaming components as-is but detach state (user message arrived). */
|
|
530
|
+
dropStreaming() {
|
|
531
|
+
this.streaming = undefined;
|
|
532
|
+
}
|
|
533
|
+
// --------------------------------------------------------------- panels --
|
|
534
|
+
/** Full box width and panelBorder SGR prefix for one panel, per current theme. */
|
|
535
|
+
panelBox() {
|
|
536
|
+
return {
|
|
537
|
+
boxWidth: panelBoxWidth(process.stdout.columns),
|
|
538
|
+
borderFg: ansiFg(this.theme.palette.panelBorder),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/** Top border line plus bordered header row — the header Text's two lines. */
|
|
542
|
+
panelTop(boxWidth, borderFg, headerInner) {
|
|
543
|
+
return `${panelTopBorder(boxWidth, borderFg)}\n${borderedRow(boxWidth, borderFg, headerInner)}`;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Thinking color style, italic-on. The style terminates with a targeted
|
|
547
|
+
* italic-off (`\x1b[23m`) — NOT a full RESET: the panel bg function paints
|
|
548
|
+
* the whole row width, and a `\x1b[0m` here would clear the background and
|
|
549
|
+
* leave the row's right side unpainted. Without the italic-off the leak is
|
|
550
|
+
* visible in the box chrome: wrapTextWithAnsi carries ANSI state across
|
|
551
|
+
* lines within one Text, so the row's right border, the following body
|
|
552
|
+
* rows and the bottom border would all render italic.
|
|
553
|
+
*/
|
|
554
|
+
thinkStyle(text) {
|
|
555
|
+
return `\x1b[3m${ansiFg(this.theme.palette.thinking)}${text}\x1b[23m`;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Styled, boxed tail of a reasoning text at the configured height (bottom
|
|
559
|
+
* border included). `streaming` marks the in-flight live path (per-chunk
|
|
560
|
+
* setBody): 'all' then boxes only the bounded STREAMING_TAIL_LINES tail so
|
|
561
|
+
* every chunk stays O(tail) — the full body renders once the assembled
|
|
562
|
+
* `assistant/message` (and the replay rebuilds) call without the flag.
|
|
563
|
+
* Fixed heights are already tail-bounded and behave identically either way.
|
|
564
|
+
*/
|
|
565
|
+
thinkingBody(reasoning, streaming = false) {
|
|
566
|
+
const { boxWidth, borderFg } = this.panelBox();
|
|
567
|
+
const bodyRows = this.panelBodyRows();
|
|
568
|
+
const lines = reasoning.trim().split('\n');
|
|
569
|
+
// Tail slice before styling: lines dropped by the panel are never styled
|
|
570
|
+
// (or clipped). 'all' keeps every line — except the transient streaming
|
|
571
|
+
// tail. Clip BEFORE styling — see clipPanelLine's contract.
|
|
572
|
+
let tail = bodyRows === 'all' ? lines : lines.slice(-bodyRows);
|
|
573
|
+
if (bodyRows === 'all' && streaming && lines.length > STREAMING_TAIL_LINES) {
|
|
574
|
+
tail = tail.slice(-STREAMING_TAIL_LINES);
|
|
575
|
+
}
|
|
576
|
+
return panelBodyText(tail.map(line => this.thinkStyle(clipPanelLine(line))), boxWidth, borderFg, bodyRows);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Build the thinking panel (default 5 rows, configurable height): top
|
|
580
|
+
* border + header row + body rows + bottom border, all on the thinking
|
|
581
|
+
* panel background.
|
|
582
|
+
* Header icon: '⟡' (U+27E1) renders as a tofu box on the user's terminal;
|
|
583
|
+
* emoji render fine there (footer ⚙✔✘⏹ all verified), so '💭' is used.
|
|
584
|
+
* The fixed header text is clipped at the plain-text stage like every
|
|
585
|
+
* other panel line — below 17 terminal columns it would otherwise outgrow
|
|
586
|
+
* the header row's budget and wrap, breaking the panel shape.
|
|
587
|
+
*/
|
|
588
|
+
createThinkingPanel() {
|
|
589
|
+
const { boxWidth, borderFg } = this.panelBox();
|
|
590
|
+
const header = new Text(this.panelTop(boxWidth, borderFg, this.thinkStyle(clipPanelLine(THINKING_HEADER))), 1, 0, this.theme.chat.thinkingPanelBg);
|
|
591
|
+
const body = new Text('', 1, 0, this.theme.chat.thinkingPanelBg);
|
|
592
|
+
const container = new Container();
|
|
593
|
+
container.addChild(header);
|
|
594
|
+
container.addChild(body);
|
|
595
|
+
return {
|
|
596
|
+
container,
|
|
597
|
+
setBody: (text) => { body.setText(text); },
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// ------------------------------------------------------------- assistant --
|
|
601
|
+
renderAssistantMessage(event) {
|
|
602
|
+
const message = event.data.message;
|
|
603
|
+
let rendered = false;
|
|
604
|
+
// The whale speaks: the first text block is prefixed inline (`🐳: text`)
|
|
605
|
+
// instead of taking its own avatar line — thinking panels and tool cards
|
|
606
|
+
// do not carry it, and later text blocks render plain.
|
|
607
|
+
let whaleShown = false;
|
|
608
|
+
for (const block of message.content) {
|
|
609
|
+
if (block.type === 'text' && block.text.trim() !== '') {
|
|
610
|
+
// trimStart keeps the prefix on the same line as the reply when the
|
|
611
|
+
// block opens with a newline.
|
|
612
|
+
const text = whaleShown ? block.text : `🐳: ${block.text.trimStart()}`;
|
|
613
|
+
whaleShown = true;
|
|
614
|
+
const md = new Markdown(text, 1, 0, this.theme.markdown, {
|
|
615
|
+
color: text => ansiFg(this.theme.palette.fgDefault) + text + RESET,
|
|
616
|
+
});
|
|
617
|
+
this.doc.addChild(md);
|
|
618
|
+
rendered = true;
|
|
619
|
+
}
|
|
620
|
+
else if (block.type === 'reasoning' && block.text.trim() !== '') {
|
|
621
|
+
// Final thinking: full reasoning through the same height-configurable
|
|
622
|
+
// panel (fixed heights show the body-row tail, padded rows keep the
|
|
623
|
+
// panel shape; 'all' prints every line).
|
|
624
|
+
const panel = this.createThinkingPanel();
|
|
625
|
+
panel.setBody(this.thinkingBody(block.text));
|
|
626
|
+
this.doc.addChild(panel.container);
|
|
627
|
+
rendered = true;
|
|
628
|
+
}
|
|
629
|
+
// tool-call blocks render through tool/call events — never duplicated here.
|
|
630
|
+
}
|
|
631
|
+
if (rendered)
|
|
632
|
+
this.doc.addChild(new Spacer(1));
|
|
633
|
+
this.requestRender();
|
|
634
|
+
}
|
|
635
|
+
// ----------------------------------------------------------------- tools --
|
|
636
|
+
/**
|
|
637
|
+
* Styled tool header content (icon + name + subject, no box chrome, no
|
|
638
|
+
* trailing RESET). The subject is the argument's first word — the file
|
|
639
|
+
* path for read/write, the command for cli (see toolSubject) — so the
|
|
640
|
+
* first line reads like "⚙ read src/welcome.ts" / "⚙ cli python".
|
|
641
|
+
*/
|
|
642
|
+
toolHeader(status, name, subject) {
|
|
643
|
+
const icon = status === 'pending' ? '⚙' : status === 'success' ? '✔' : '✘';
|
|
644
|
+
const color = status === 'pending'
|
|
645
|
+
? this.theme.palette.fgMuted
|
|
646
|
+
: status === 'success'
|
|
647
|
+
? this.theme.palette.success
|
|
648
|
+
: this.theme.palette.danger;
|
|
649
|
+
// Clip the model-controlled name+subject at the plain-text stage before
|
|
650
|
+
// styling (indent 2 = the icon + space): an unbounded line would outgrow
|
|
651
|
+
// the header row's budget and wrap, breaking the fixed-height panel.
|
|
652
|
+
const clipped = clipPanelLine(subject === '' ? name : `${name} ${subject}`, 2);
|
|
653
|
+
// No trailing RESET: the card bg function terminates the row so the
|
|
654
|
+
// background covers the full header width.
|
|
655
|
+
return ansiFg(color) + `${icon} ${clipped}`;
|
|
656
|
+
}
|
|
657
|
+
addToolCard(callId, name, rawArguments) {
|
|
658
|
+
const { boxWidth, borderFg } = this.panelBox();
|
|
659
|
+
const subject = toolSubject(rawArguments);
|
|
660
|
+
const header = new Text(this.panelTop(boxWidth, borderFg, this.toolHeader('pending', name, subject)), 1, 0, this.theme.chat.toolPendingBg);
|
|
661
|
+
const body = new Text('', 1, 0, this.theme.chat.toolBodyBg);
|
|
662
|
+
const container = new Container();
|
|
663
|
+
container.addChild(header);
|
|
664
|
+
container.addChild(body);
|
|
665
|
+
const detail = callDetail(rawArguments);
|
|
666
|
+
// callDetail already clipped to 120; clip again to the panel cap so the
|
|
667
|
+
// row never wraps on a narrow terminal.
|
|
668
|
+
const detailLines = detail === '' ? [] : [ansiFg(this.theme.palette.fgMuted) + ` ${clipPanelLine(detail, 2)}`];
|
|
669
|
+
body.setText(panelBodyText(detailLines, boxWidth, borderFg, this.panelBodyRows()));
|
|
670
|
+
this.doc.addChild(container);
|
|
671
|
+
this.toolCards.set(callId, { header, body, name, subject, detailLines });
|
|
672
|
+
this.requestRender();
|
|
673
|
+
}
|
|
674
|
+
settleToolCard(event) {
|
|
675
|
+
const block = event.data.message.content[0];
|
|
676
|
+
const callId = block?.toolCallId ?? '';
|
|
677
|
+
const card = this.toolCards.get(callId);
|
|
678
|
+
if (card === undefined)
|
|
679
|
+
return;
|
|
680
|
+
this.toolCards.delete(callId);
|
|
681
|
+
const { boxWidth, borderFg } = this.panelBox();
|
|
682
|
+
const isError = event.data.error !== undefined || (block?.isError ?? false);
|
|
683
|
+
// Rebuild both header Text lines: the top border (unchanged chrome) and
|
|
684
|
+
// the swapped-status header row; the status bg fn repaints the border
|
|
685
|
+
// row too, so the whole box top takes the success/error tint.
|
|
686
|
+
card.header.setText(this.panelTop(boxWidth, borderFg, this.toolHeader(isError ? 'error' : 'success', card.name, card.subject)));
|
|
687
|
+
card.header.setCustomBgFn(isError ? this.theme.chat.toolErrorBg : this.theme.chat.toolSuccessBg);
|
|
688
|
+
const bodyLines = [...card.detailLines];
|
|
689
|
+
if (event.data.error !== undefined) {
|
|
690
|
+
bodyLines.push(ansiFg(this.theme.palette.danger)
|
|
691
|
+
+ ` ${clipPanelLine(`${event.data.error.name}: ${event.data.error.code}`, 2)}`);
|
|
692
|
+
}
|
|
693
|
+
if (block !== undefined) {
|
|
694
|
+
for (const line of resultTextLines(block.content)) {
|
|
695
|
+
bodyLines.push(ansiFg(this.theme.palette.fgMuted) + ` ${clipPanelLine(line, 2)}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// Body keeps the tail at the configured row budget; when lines are
|
|
699
|
+
// dropped, the first visible row reports the count so the newest result
|
|
700
|
+
// lines stay on screen. 'all' keeps every line up to the ALL_TOOL_RESULT_LINES
|
|
701
|
+
// cap (an unlimited body would hitch the frame and balloon memory on a
|
|
702
|
+
// huge result) and shows the marker beyond it; under the cap there is no
|
|
703
|
+
// marker. The marker is clipped at the plain-text stage (indent 2, like
|
|
704
|
+
// every tool row): on a narrow terminal a 3-digit dropped count exceeds
|
|
705
|
+
// the row's budget and would wrap, breaking the fixed-height panel.
|
|
706
|
+
const bodyRows = this.panelBodyRows();
|
|
707
|
+
const cap = bodyRows === 'all' ? ALL_TOOL_RESULT_LINES : bodyRows;
|
|
708
|
+
if (bodyLines.length > cap) {
|
|
709
|
+
const dropped = bodyLines.length - cap;
|
|
710
|
+
bodyLines.splice(0, dropped);
|
|
711
|
+
bodyLines[0] = ansiFg(this.theme.palette.fgSubtle) + clipPanelLine(` … (+${dropped} lines)`, 2);
|
|
712
|
+
}
|
|
713
|
+
card.body.setText(panelBodyText(bodyLines, boxWidth, borderFg, bodyRows));
|
|
714
|
+
this.requestRender();
|
|
715
|
+
}
|
|
716
|
+
// -------------------------------------------------------------- turn end --
|
|
717
|
+
renderTurnEnd(reason) {
|
|
718
|
+
if (reason.kind === 'error') {
|
|
719
|
+
this.appendLine(ansiFg(this.theme.palette.danger) + `✘ ${reason.error?.message ?? 'turn failed'}` + RESET);
|
|
720
|
+
}
|
|
721
|
+
else if (reason.kind === 'aborted') {
|
|
722
|
+
this.appendLine(ansiFg(this.theme.palette.fgSubtle) + '⏹ interrupted' + RESET);
|
|
723
|
+
}
|
|
724
|
+
else if (reason.kind === 'max-tokens') {
|
|
725
|
+
this.appendLine(ansiFg(this.theme.palette.attention) + '⚠ output token limit reached' + RESET);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// --------------------------------------------------------------- helpers --
|
|
729
|
+
appendLine(line) {
|
|
730
|
+
this.doc.addChild(new Text(line, 1, 0));
|
|
731
|
+
this.requestRender();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
//# sourceMappingURL=messages.js.map
|