@aiwayds/dsh-tui-pi 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -319,16 +319,26 @@ dsh-tui-pi avoids both by construction:
319
319
  - **Streaming strategy**: deltas accumulate in a plain Text via `setText` on
320
320
  the same component (never remove+re-add per token); markdown renders once on
321
321
  the assembled `assistant/message` (no per-token markdown parsing).
322
- - **Configurable think/tool panels** (`dsh-tui.panelHeight`, default `'5'`):
323
- `'5'/'7'/'10'` set the total panel rows (top border + header row + body rows
324
- + bottom border); `'all'` prints the full body, with bounded on-screen
325
- content a streaming reasoning panel boxes a 200-line live tail while
326
- chunks are in flight (the assembled message renders everything) and a
327
- settled tool result keeps at most 2000 lines (a `… (+N lines)` marker
328
- reports the drop). No inner scroll — pi-tui 0.84.2 never lays out nested
329
- components, so a nested ScrollView cannot obtain a viewport. Body lines are
330
- clipped to one physical row *before* styling, so long output can never wrap
331
- the panel past its configured rows.
322
+ - **Fixed think/tool status panels** (pinned above the chat input, like the
323
+ Todos panel): think/tool activity never creates transcript blocks one
324
+ ThinkPanel and one ToolPanel exist for the whole run, every event refreshes
325
+ the same panel in place, and a panel with no content renders zero rows
326
+ (hidden). Delegation spawn tools (`use_agent`, `subagent`, `workflow`,
327
+ `ralph`) never open a tool block their children show in the running-agent
328
+ lines below the editor. The bottom running-agent line shows the child's
329
+ latest CONTENT line (live-refreshed assistant text/reasoning, never a tool
330
+ name), truncated at the right edge without wrapping.
331
+ - **Configurable panel height** (`dsh-tui.panelHeight`, default `'1'`):
332
+ `'1'` renders one borderless row — block identifier + elapsed time + the
333
+ last content line, right-truncated, never wrapped; `'5'/'7'/'10'` box the
334
+ panel (top border + header row + body rows + bottom border); `'all'` prints
335
+ the full body, with bounded on-screen content — a streaming reasoning panel
336
+ boxes a 200-line live tail while chunks are in flight and a settled tool
337
+ result keeps at most 2000 lines (a `… (+N lines)` marker reports the drop).
338
+ No inner scroll — pi-tui 0.84.2 never lays out nested components, so a
339
+ nested ScrollView cannot obtain a viewport. Body lines are clipped to one
340
+ physical row *before* styling, so long output can never wrap the panel past
341
+ its configured rows.
332
342
  - **Width safety**: every truncation goes through `clipToWidth` (src/text.ts)
333
343
  — CJK full-width characters count 2 columns and graphemes are never split.
334
344
  Bare `String.length` clipping is banned.
package/cordis.patch.yml CHANGED
@@ -3,7 +3,19 @@
3
3
  # terminal UI in-process with @earendil-works/pi-tui and talks to the dsh tree
4
4
  # directly (ctx.agents / ctx.commands / session events), keeping dsh's slash
5
5
  # commands untouched.
6
+ #
7
+ # The TUI ships the deterministic compaction backend @aiwayds/dsh-dcp: the
8
+ # default LLM summarizer (compaction-basic) is disabled and dsh-dcp is mounted
9
+ # alongside it, so every profile that bundles this TUI gets zero-LLM
10
+ # compaction out of the box. The dependency is installed as `latest`.
6
11
 
12
+ - id: compaction-basic
13
+ disabled: true
7
14
  - insert:
8
15
  - id: tui-pi
9
16
  name: '@aiwayds/dsh-tui-pi'
17
+ - id: dsh-dcp
18
+ name: '@aiwayds/dsh-dcp'
19
+ config:
20
+ thresholdRatio: 0.7
21
+ language: zh
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Live activity panels — the single fixed think/tool status surfaces pinned
3
+ * ABOVE the chat input (live-widgets.ts mounts them; index.ts routes session
4
+ * events through LiveWidgets.applyEvent). One ThinkPanel and one ToolPanel
5
+ * instance exist for the whole TUI run: think/tool activity NEVER creates
6
+ * transcript blocks — every event refreshes the same panel in place, and a
7
+ * panel with no content renders zero rows (hidden until the next burst).
8
+ *
9
+ * Height modes (`dsh-tui.panelHeight`):
10
+ * - '1' (default): ONE borderless row — block identifier + elapsed time +
11
+ * the last content line (live-refreshed), right-truncated at the terminal
12
+ * width, never wrapped.
13
+ * - '5'/'7'/'10': the boxed panel (top border + header row + content rows +
14
+ * bottom border) at the configured displayed-row budget.
15
+ * - 'all': the boxed panel without a row cap (a streaming reasoning burst
16
+ * still boxes only a bounded live tail; settled tool results cap at
17
+ * ALL_TOOL_RESULT_LINES with a drop marker).
18
+ *
19
+ * Panels are self-drawing Components (render(width) per frame, no cached
20
+ * rows — the TodosPanel pattern): a terminal resize re-lays the box out
21
+ * automatically and a theme hot-switch needs only a repaint, never a replay.
22
+ * Plain text is clipped BEFORE styling everywhere (clipToWidth counts SGR
23
+ * fragments as visible columns — see clipRow's contract).
24
+ */
25
+ import type { Component } from '@earendil-works/pi-tui';
26
+ import { type TuiTheme } from './theme/index.ts';
27
+ /**
28
+ * Configurable think/tool panel height. '1' is a single borderless row;
29
+ * fixed values count the DISPLAYED rows of the box — the header line plus
30
+ * the content rows (the two box borders are not counted); 'all' prints the
31
+ * full body with no row cap.
32
+ */
33
+ export type PanelHeight = '1' | '5' | '7' | '10' | 'all';
34
+ /**
35
+ * Default think/tool panel height: ONE row — block identifier, elapsed time
36
+ * and the last content line. The single default for every '1' fallback (the
37
+ * settings schema default/entry/narrowing, the LiveWidgets constructor);
38
+ * other heights are set through the `panelHeight` setting.
39
+ */
40
+ export declare const DEFAULT_PANEL_HEIGHT: PanelHeight;
41
+ /**
42
+ * 'all' streaming cap: while a reasoning stream is in flight, the boxed panel
43
+ * keeps only this many trailing rows (per-frame cost stays O(tail), never
44
+ * O(accumulated)). Fixed heights are tail-bounded by their row budget.
45
+ */
46
+ export declare const STREAMING_TAIL_LINES = 200;
47
+ /**
48
+ * 'all' settle cap: a settled tool panel keeps at most this many body rows,
49
+ * with a `… (+N lines)` marker for the drop.
50
+ */
51
+ export declare const ALL_TOOL_RESULT_LINES = 2000;
52
+ /**
53
+ * Terminal columns a panel body row's CONTENT may occupy so the whole
54
+ * bordered row renders on exactly one physical line: the body wraps at
55
+ * `width - paddingX*2` (paddingX = 1), every row carries 4 columns of box
56
+ * chrome (`│ ` … ` │`), and tool rows add a 2-column indent — hence the -6
57
+ * (think) and -8 (tool, indent = 2) headroom.
58
+ */
59
+ export declare function panelLineCap(columns: number | undefined, indent?: number): number;
60
+ /** Full visible width of one bordered panel row, box chrome included. */
61
+ export declare function panelBoxWidth(columns: number | undefined): number;
62
+ /**
63
+ * One bordered panel row of exactly `boxWidth` visible columns: side borders
64
+ * in `borderFg`, `inner` (already styled, already clipped) left-aligned and
65
+ * padded with spaces to the full box width. No trailing RESET — the caller
66
+ * wraps the row in the panel background SGR and terminates it.
67
+ */
68
+ export declare function borderedRow(boxWidth: number, borderFg: string, inner: string): string;
69
+ /** Top border line (`┌─…─┐`), `boxWidth` columns wide, in `borderFg`. */
70
+ export declare function panelTopBorder(boxWidth: number, borderFg: string): string;
71
+ /** Bottom border line (`└─…─┘`), `boxWidth` columns wide, in `borderFg`. */
72
+ export declare function panelBottomBorder(boxWidth: number, borderFg: string): string;
73
+ /**
74
+ * Clip an unstyled line to one physical panel row at the CURRENT render
75
+ * width. Must run BEFORE styling: clipToWidth counts per grapheme, so the
76
+ * ASCII fragments of an SGR code would count as visible columns — clipping
77
+ * plain text first, then applying ANSI, keeps the accounting exact.
78
+ * `indent` is the leading content indent the row carries (2 for tool rows).
79
+ * Carriage returns are stripped first: a bare \r (progress bars, CRLF tool
80
+ * output) would break the fixed panel rows just like a wrap would — the
81
+ * panel line is one row, not a line record.
82
+ */
83
+ export declare function clipRow(text: string, width: number, indent?: number): string;
84
+ /**
85
+ * Clip an unstyled line against the process terminal width (the historical
86
+ * clipPanelLine contract — kept for callers outside a render(width) frame,
87
+ * e.g. the running-agent line's label).
88
+ */
89
+ export declare function clipPanelLine(text: string, indent?: number): string;
90
+ /**
91
+ * Compose the bordered body rows (plus the bottom border) from
92
+ * already-styled, already-clipped lines: keep the tail — newest rows win —
93
+ * pad short content with empty boxed rows, then append the bottom border.
94
+ * `bodyRows` is the panel's body-row budget or 'all': with 'all' every line
95
+ * is kept verbatim, nothing is padded. Pad rows carry the box characters so
96
+ * they survive Text's empty-row fast path. Callers clip each line BEFORE
97
+ * styling — otherwise a styled line that outgrows its budget wraps and the
98
+ * panel exceeds its configured rows.
99
+ */
100
+ export declare function panelBodyText(lines: readonly string[], boxWidth: number, borderFg: string, bodyRows?: number | 'all'): string;
101
+ /**
102
+ * The tool header's subject word: the file path for read/write-style tools,
103
+ * the command's first word for cli-style tools ('git', 'python') — the first
104
+ * whitespace token of the highest-priority string argument (same key
105
+ * priority as callDetail's summary). '' when the arguments carry no usable
106
+ * string (the header then shows the bare tool name).
107
+ */
108
+ export declare function toolSubject(rawArguments: string): string;
109
+ /** One-line summary of the call arguments, per common tool shape. */
110
+ export declare function callDetail(rawArguments: string, limit?: number): string;
111
+ /** First text content of a tool result, raw lines. */
112
+ export declare function resultTextLines(content: readonly {
113
+ type: string;
114
+ text?: string;
115
+ }[]): string[];
116
+ /**
117
+ * The live thinking panel: one fixed surface for the WHOLE run, refreshed in
118
+ * place by every reasoning delta. Visible while a reasoning burst streams
119
+ * (feed); hidden by the next phase event (text delta, tool call, message
120
+ * assembly, turn end — LiveWidgets.applyEvent drives those).
121
+ */
122
+ export declare class ThinkPanel implements Component {
123
+ private state;
124
+ private height;
125
+ private readonly getTheme;
126
+ constructor(getTheme: () => TuiTheme);
127
+ invalidate(): void;
128
+ /** Whether the panel currently has content (drives the live tick). */
129
+ isVisible(): boolean;
130
+ setHeight(height: PanelHeight): void;
131
+ /** One reasoning delta; the first delta of a burst opens the panel. */
132
+ feed(delta: string): void;
133
+ hide(): void;
134
+ render(width: number): string[];
135
+ }
136
+ /** The settle payload ToolPanel.settle accepts (structural, event-shape). */
137
+ export interface ToolSettleData {
138
+ error?: {
139
+ name: string;
140
+ code?: string;
141
+ };
142
+ block?: {
143
+ isError?: boolean;
144
+ content: readonly {
145
+ type: string;
146
+ text?: string;
147
+ }[];
148
+ };
149
+ }
150
+ /**
151
+ * The live tool panel: one fixed surface refreshed by every tool call — a
152
+ * new call replaces the tracked tool (sequential/parallel calls all refresh
153
+ * this same panel), a matching result settles it (icon/status/time freeze,
154
+ * body shows the result tail), and any later phase event hides it.
155
+ */
156
+ export declare class ToolPanel implements Component {
157
+ private state;
158
+ private height;
159
+ private readonly getTheme;
160
+ constructor(getTheme: () => TuiTheme);
161
+ invalidate(): void;
162
+ /** Whether the panel currently has content (drives the live tick). */
163
+ isVisible(): boolean;
164
+ setHeight(height: PanelHeight): void;
165
+ /** A new tool call: replaces the tracked tool with a pending one. */
166
+ begin(callId: string, name: string, rawArguments: string): void;
167
+ /**
168
+ * Settle the tracked tool. Results for a callId other than the tracked
169
+ * one (parallel calls; a result racing a newer begin) are ignored.
170
+ * @returns whether the tracked tool settled.
171
+ */
172
+ settle(callId: string, data: ToolSettleData): boolean;
173
+ hide(): void;
174
+ render(width: number): string[];
175
+ }
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Live activity panels — the single fixed think/tool status surfaces pinned
3
+ * ABOVE the chat input (live-widgets.ts mounts them; index.ts routes session
4
+ * events through LiveWidgets.applyEvent). One ThinkPanel and one ToolPanel
5
+ * instance exist for the whole TUI run: think/tool activity NEVER creates
6
+ * transcript blocks — every event refreshes the same panel in place, and a
7
+ * panel with no content renders zero rows (hidden until the next burst).
8
+ *
9
+ * Height modes (`dsh-tui.panelHeight`):
10
+ * - '1' (default): ONE borderless row — block identifier + elapsed time +
11
+ * the last content line (live-refreshed), right-truncated at the terminal
12
+ * width, never wrapped.
13
+ * - '5'/'7'/'10': the boxed panel (top border + header row + content rows +
14
+ * bottom border) at the configured displayed-row budget.
15
+ * - 'all': the boxed panel without a row cap (a streaming reasoning burst
16
+ * still boxes only a bounded live tail; settled tool results cap at
17
+ * ALL_TOOL_RESULT_LINES with a drop marker).
18
+ *
19
+ * Panels are self-drawing Components (render(width) per frame, no cached
20
+ * rows — the TodosPanel pattern): a terminal resize re-lays the box out
21
+ * automatically and a theme hot-switch needs only a repaint, never a replay.
22
+ * Plain text is clipped BEFORE styling everywhere (clipToWidth counts SGR
23
+ * fragments as visible columns — see clipRow's contract).
24
+ */
25
+ import { ansiBg, ansiFg, RESET } from "./theme/index.js";
26
+ import { clipToWidth, lastNonBlankLine, visibleWidth } from "./text.js";
27
+ /**
28
+ * Default think/tool panel height: ONE row — block identifier, elapsed time
29
+ * and the last content line. The single default for every '1' fallback (the
30
+ * settings schema default/entry/narrowing, the LiveWidgets constructor);
31
+ * other heights are set through the `panelHeight` setting.
32
+ */
33
+ export const DEFAULT_PANEL_HEIGHT = '1';
34
+ /** Content rows inside the default boxed panel (a '5' box minus the header row). */
35
+ const PANEL_BODY_LINES = 4;
36
+ /**
37
+ * 'all' streaming cap: while a reasoning stream is in flight, the boxed panel
38
+ * keeps only this many trailing rows (per-frame cost stays O(tail), never
39
+ * O(accumulated)). Fixed heights are tail-bounded by their row budget.
40
+ */
41
+ export const STREAMING_TAIL_LINES = 200;
42
+ /**
43
+ * 'all' settle cap: a settled tool panel keeps at most this many body rows,
44
+ * with a `… (+N lines)` marker for the drop.
45
+ */
46
+ export const ALL_TOOL_RESULT_LINES = 2000;
47
+ /** Thinking panel identifier (icon + label), 11 visible columns. */
48
+ const THINKING_HEADER = '💭 thinking';
49
+ /**
50
+ * Fallback terminal columns when the real width is unknown (non-TTY
51
+ * contexts, e.g. tests): conservative so no sane terminal wraps.
52
+ */
53
+ const PANEL_LINE_CAP_FALLBACK = 200;
54
+ /**
55
+ * Bound of the accumulated reasoning tail a ThinkPanel keeps — enough for
56
+ * the 'all' live tail (STREAMING_TAIL_LINES rows) plus slack, so a runaway
57
+ * stream cannot grow the panel state unboundedly. Trimmed at line
58
+ * boundaries; each delta does amortized O(1) trim work.
59
+ */
60
+ const THINK_TAIL_CAP = 32_768;
61
+ /**
62
+ * Terminal columns a panel body row's CONTENT may occupy so the whole
63
+ * bordered row renders on exactly one physical line: the body wraps at
64
+ * `width - paddingX*2` (paddingX = 1), every row carries 4 columns of box
65
+ * chrome (`│ ` … ` │`), and tool rows add a 2-column indent — hence the -6
66
+ * (think) and -8 (tool, indent = 2) headroom.
67
+ */
68
+ export function panelLineCap(columns, indent = 0) {
69
+ return Math.max(1, (columns === undefined ? PANEL_LINE_CAP_FALLBACK : columns) - 6 - indent);
70
+ }
71
+ /** Full visible width of one bordered panel row, box chrome included. */
72
+ export function panelBoxWidth(columns) {
73
+ return panelLineCap(columns) + 4;
74
+ }
75
+ /**
76
+ * One bordered panel row of exactly `boxWidth` visible columns: side borders
77
+ * in `borderFg`, `inner` (already styled, already clipped) left-aligned and
78
+ * padded with spaces to the full box width. No trailing RESET — the caller
79
+ * wraps the row in the panel background SGR and terminates it.
80
+ */
81
+ export function borderedRow(boxWidth, borderFg, inner) {
82
+ const pad = Math.max(0, boxWidth - 4 - visibleWidth(inner));
83
+ return `${borderFg}│ ${inner}${' '.repeat(pad)}${borderFg} │`;
84
+ }
85
+ /** Top border line (`┌─…─┐`), `boxWidth` columns wide, in `borderFg`. */
86
+ export function panelTopBorder(boxWidth, borderFg) {
87
+ return `${borderFg}┌${'─'.repeat(Math.max(0, boxWidth - 2))}┐`;
88
+ }
89
+ /** Bottom border line (`└─…─┘`), `boxWidth` columns wide, in `borderFg`. */
90
+ export function panelBottomBorder(boxWidth, borderFg) {
91
+ return `${borderFg}└${'─'.repeat(Math.max(0, boxWidth - 2))}┘`;
92
+ }
93
+ /**
94
+ * Clip an unstyled line to one physical panel row at the CURRENT render
95
+ * width. Must run BEFORE styling: clipToWidth counts per grapheme, so the
96
+ * ASCII fragments of an SGR code would count as visible columns — clipping
97
+ * plain text first, then applying ANSI, keeps the accounting exact.
98
+ * `indent` is the leading content indent the row carries (2 for tool rows).
99
+ * Carriage returns are stripped first: a bare \r (progress bars, CRLF tool
100
+ * output) would break the fixed panel rows just like a wrap would — the
101
+ * panel line is one row, not a line record.
102
+ */
103
+ export function clipRow(text, width, indent = 0) {
104
+ return clipToWidth(text.replace(/\r/g, ''), panelLineCap(width, indent));
105
+ }
106
+ /**
107
+ * Clip an unstyled line against the process terminal width (the historical
108
+ * clipPanelLine contract — kept for callers outside a render(width) frame,
109
+ * e.g. the running-agent line's label).
110
+ */
111
+ export function clipPanelLine(text, indent = 0) {
112
+ return clipToWidth(text.replace(/\r/g, ''), panelLineCap(process.stdout.columns, indent));
113
+ }
114
+ /**
115
+ * Compose the bordered body rows (plus the bottom border) from
116
+ * already-styled, already-clipped lines: keep the tail — newest rows win —
117
+ * pad short content with empty boxed rows, then append the bottom border.
118
+ * `bodyRows` is the panel's body-row budget or 'all': with 'all' every line
119
+ * is kept verbatim, nothing is padded. Pad rows carry the box characters so
120
+ * they survive Text's empty-row fast path. Callers clip each line BEFORE
121
+ * styling — otherwise a styled line that outgrows its budget wraps and the
122
+ * panel exceeds its configured rows.
123
+ */
124
+ export function panelBodyText(lines, boxWidth, borderFg, bodyRows = PANEL_BODY_LINES) {
125
+ const visible = bodyRows === 'all'
126
+ ? [...lines]
127
+ : lines.length > bodyRows ? lines.slice(-bodyRows) : [...lines];
128
+ if (bodyRows !== 'all') {
129
+ while (visible.length < bodyRows)
130
+ visible.push('');
131
+ }
132
+ return [...visible.map(line => borderedRow(boxWidth, borderFg, line)), panelBottomBorder(boxWidth, borderFg)].join('\n');
133
+ }
134
+ // ------------------------------------------------------------ tool summary --
135
+ /**
136
+ * The tool header's subject word: the file path for read/write-style tools,
137
+ * the command's first word for cli-style tools ('git', 'python') — the first
138
+ * whitespace token of the highest-priority string argument (same key
139
+ * priority as callDetail's summary). '' when the arguments carry no usable
140
+ * string (the header then shows the bare tool name).
141
+ */
142
+ export function toolSubject(rawArguments) {
143
+ const firstWord = (value) => value.trim().split(/\s+/u)[0] ?? '';
144
+ try {
145
+ const parsed = JSON.parse(rawArguments);
146
+ for (const key of ['command', 'file_path', 'path', 'query', 'url', 'pattern', 'description']) {
147
+ const value = parsed[key];
148
+ if (typeof value === 'string' && value.trim() !== '')
149
+ return firstWord(value);
150
+ }
151
+ for (const value of Object.values(parsed)) {
152
+ if (typeof value === 'string' && value.trim() !== '')
153
+ return firstWord(value);
154
+ }
155
+ }
156
+ catch {
157
+ // Model-controlled rawArguments; non-JSON yields no subject.
158
+ }
159
+ return '';
160
+ }
161
+ /** One-line summary of the call arguments, per common tool shape. */
162
+ export function callDetail(rawArguments, limit = 120) {
163
+ try {
164
+ const parsed = JSON.parse(rawArguments);
165
+ const parts = [];
166
+ if (typeof parsed.command === 'string')
167
+ parts.push(`$ ${parsed.command}`);
168
+ if (typeof parsed.file_path === 'string')
169
+ parts.push(parsed.file_path);
170
+ if (typeof parsed.path === 'string' && parts.length === 0)
171
+ parts.push(parsed.path);
172
+ if (typeof parsed.pattern === 'string')
173
+ parts.push(`pattern: ${parsed.pattern}`);
174
+ if (typeof parsed.query === 'string')
175
+ parts.push(`query: ${parsed.query}`);
176
+ if (typeof parsed.url === 'string')
177
+ parts.push(parsed.url);
178
+ if (typeof parsed.description === 'string' && parts.length === 0)
179
+ parts.push(parsed.description);
180
+ if (parts.length === 0) {
181
+ const flat = rawArguments.replace(/\s+/g, ' ');
182
+ parts.push(flat);
183
+ }
184
+ const joined = parts.join(' ').replace(/\n/g, ' ⏎ ');
185
+ return clipToWidth(joined, limit);
186
+ }
187
+ catch {
188
+ return '';
189
+ }
190
+ }
191
+ /** First text content of a tool result, raw lines. */
192
+ export function resultTextLines(content) {
193
+ for (const block of content) {
194
+ if (block.type === 'text' && block.text !== undefined) {
195
+ return block.text.replace(/\s+$/u, '').split('\n');
196
+ }
197
+ }
198
+ return [];
199
+ }
200
+ // ------------------------------------------------------------ panel pieces --
201
+ /**
202
+ * Append `delta` to the bounded tail buffer, trimming whole head lines past
203
+ * THINK_TAIL_CAP (amortized O(1) per delta — the trim only fires when the
204
+ * buffer outgrows the cap, and each firing drops at least one whole line).
205
+ */
206
+ function boundTail(buffer, delta) {
207
+ let next = buffer + delta;
208
+ while (next.length > THINK_TAIL_CAP) {
209
+ const nl = next.indexOf('\n');
210
+ if (nl === -1) {
211
+ next = next.slice(-Math.floor(THINK_TAIL_CAP / 2));
212
+ break;
213
+ }
214
+ next = next.slice(nl + 1);
215
+ }
216
+ return next;
217
+ }
218
+ /**
219
+ * Assemble one borderless status row from plain pieces: identifier, meta
220
+ * (elapsed), and an optional ` · <tail>` suffix. The tail gets whatever the
221
+ * row has left and is truncated at the right edge (never wrapped); when the
222
+ * pieces do not fit even without a tail, the assembled plain row is clipped
223
+ * and returned in a single muted style so no styled segment can push past
224
+ * the width. Pieces are styled only after every clip — the repo rule.
225
+ */
226
+ function statusRow(width, theme, id, meta, tail, idStyle) {
227
+ const p = theme.palette;
228
+ const subtle = (text) => ansiFg(p.fgSubtle) + text + RESET;
229
+ const muted = (text) => ansiFg(p.fgMuted) + text + RESET;
230
+ const idW = visibleWidth(id);
231
+ const metaW = visibleWidth(meta);
232
+ let tailText = '';
233
+ if (tail !== undefined && tail !== '') {
234
+ const tailBudget = width - idW - metaW - 3;
235
+ if (tailBudget >= 4)
236
+ tailText = ` · ${clipToWidth(tail, tailBudget - 3)}`;
237
+ }
238
+ const plain = id + meta + tailText;
239
+ if (visibleWidth(plain) > width) {
240
+ return muted(clipToWidth(plain, width));
241
+ }
242
+ return idStyle(id) + subtle(meta) + muted(tailText);
243
+ }
244
+ /** Panel background wrapper: prefixes the bg SGR, terminates with RESET. */
245
+ function bgRow(bgPrefix, row) {
246
+ return bgPrefix + row + RESET;
247
+ }
248
+ // ------------------------------------------------------------- ThinkPanel --
249
+ /**
250
+ * The live thinking panel: one fixed surface for the WHOLE run, refreshed in
251
+ * place by every reasoning delta. Visible while a reasoning burst streams
252
+ * (feed); hidden by the next phase event (text delta, tool call, message
253
+ * assembly, turn end — LiveWidgets.applyEvent drives those).
254
+ */
255
+ export class ThinkPanel {
256
+ state;
257
+ height = DEFAULT_PANEL_HEIGHT;
258
+ getTheme;
259
+ constructor(getTheme) {
260
+ this.getTheme = getTheme;
261
+ }
262
+ invalidate() { }
263
+ /** Whether the panel currently has content (drives the live tick). */
264
+ isVisible() {
265
+ return this.state !== undefined;
266
+ }
267
+ setHeight(height) {
268
+ this.height = height;
269
+ }
270
+ /** One reasoning delta; the first delta of a burst opens the panel. */
271
+ feed(delta) {
272
+ if (delta === '')
273
+ return;
274
+ const state = this.state;
275
+ if (state === undefined) {
276
+ this.state = { startedAt: Date.now(), tail: boundTail('', delta) };
277
+ }
278
+ else {
279
+ state.tail = boundTail(state.tail, delta);
280
+ }
281
+ }
282
+ hide() {
283
+ this.state = undefined;
284
+ }
285
+ render(width) {
286
+ const state = this.state;
287
+ if (state === undefined)
288
+ return [];
289
+ const theme = this.getTheme();
290
+ const p = theme.palette;
291
+ const elapsed = `${((Date.now() - state.startedAt) / 1000).toFixed(1)}s`;
292
+ if (this.height === '1') {
293
+ const id = THINKING_HEADER;
294
+ const row = statusRow(width, theme, id, ` · ${elapsed}`, lastNonBlankLine(state.tail), text => `\x1b[3m${ansiFg(p.thinking)}${text}\x1b[23m`);
295
+ return [row];
296
+ }
297
+ // Boxed panel: top border + header + content rows + bottom border, every
298
+ // row on the thinking panel background.
299
+ const boxWidth = panelBoxWidth(width);
300
+ const borderFg = ansiFg(p.panelBorder);
301
+ const bgPrefix = ansiBg(p.thinkingPanelBg);
302
+ const bodyRows = this.height === 'all' ? 'all' : Number(this.height) - 1;
303
+ const thinkStyle = (text) => `\x1b[3m${ansiFg(p.thinking)}${text}\x1b[23m`;
304
+ const headerInner = thinkStyle(clipRow(`${THINKING_HEADER} · ${elapsed}`, width));
305
+ let lines = state.tail.trim().split('\n');
306
+ // Bounded live tail while streaming in 'all' mode — per-frame cost stays
307
+ // O(tail), never O(accumulated).
308
+ if (bodyRows === 'all' && lines.length > STREAMING_TAIL_LINES) {
309
+ lines = lines.slice(-STREAMING_TAIL_LINES);
310
+ }
311
+ const body = panelBodyText(lines.map(line => thinkStyle(clipRow(line, width))), boxWidth, borderFg, bodyRows);
312
+ return [
313
+ bgRow(bgPrefix, panelTopBorder(boxWidth, borderFg)),
314
+ bgRow(bgPrefix, borderedRow(boxWidth, borderFg, headerInner)),
315
+ ...body.split('\n').map(row => bgRow(bgPrefix, row)),
316
+ ];
317
+ }
318
+ }
319
+ /**
320
+ * The live tool panel: one fixed surface refreshed by every tool call — a
321
+ * new call replaces the tracked tool (sequential/parallel calls all refresh
322
+ * this same panel), a matching result settles it (icon/status/time freeze,
323
+ * body shows the result tail), and any later phase event hides it.
324
+ */
325
+ export class ToolPanel {
326
+ state;
327
+ height = DEFAULT_PANEL_HEIGHT;
328
+ getTheme;
329
+ constructor(getTheme) {
330
+ this.getTheme = getTheme;
331
+ }
332
+ invalidate() { }
333
+ /** Whether the panel currently has content (drives the live tick). */
334
+ isVisible() {
335
+ return this.state !== undefined;
336
+ }
337
+ setHeight(height) {
338
+ this.height = height;
339
+ }
340
+ /** A new tool call: replaces the tracked tool with a pending one. */
341
+ begin(callId, name, rawArguments) {
342
+ const detail = callDetail(rawArguments);
343
+ this.state = {
344
+ callId,
345
+ name,
346
+ subject: toolSubject(rawArguments),
347
+ startedAt: Date.now(),
348
+ status: 'pending',
349
+ bodyLines: detail === '' ? [] : [detail],
350
+ };
351
+ }
352
+ /**
353
+ * Settle the tracked tool. Results for a callId other than the tracked
354
+ * one (parallel calls; a result racing a newer begin) are ignored.
355
+ * @returns whether the tracked tool settled.
356
+ */
357
+ settle(callId, data) {
358
+ const state = this.state;
359
+ if (state === undefined || state.callId !== callId || state.status !== 'pending')
360
+ return false;
361
+ const isError = data.error !== undefined || (data.block?.isError ?? false);
362
+ state.status = isError ? 'error' : 'success';
363
+ state.endedAt = Date.now();
364
+ const body = [...state.bodyLines];
365
+ if (data.error !== undefined) {
366
+ body.push(`${data.error.name}: ${data.error.code ?? ''}`.replace(/: $/, ''));
367
+ }
368
+ if (data.block !== undefined) {
369
+ for (const line of resultTextLines(data.block.content))
370
+ body.push(line);
371
+ }
372
+ state.bodyLines = body;
373
+ return true;
374
+ }
375
+ hide() {
376
+ this.state = undefined;
377
+ }
378
+ render(width) {
379
+ const state = this.state;
380
+ if (state === undefined)
381
+ return [];
382
+ const theme = this.getTheme();
383
+ const p = theme.palette;
384
+ const icon = state.status === 'pending' ? '⚙' : state.status === 'success' ? '✔' : '✘';
385
+ const statusColor = state.status === 'pending'
386
+ ? p.fgMuted
387
+ : state.status === 'success' ? p.success : p.danger;
388
+ const end = state.endedAt ?? Date.now();
389
+ const elapsed = `${((end - state.startedAt) / 1000).toFixed(1)}s`;
390
+ const idPlain = clipRow(state.subject === '' ? state.name : `${state.name} ${state.subject}`, width, 2);
391
+ if (this.height === '1') {
392
+ const row = statusRow(width, theme, `${icon} ${idPlain}`, ` · ${elapsed}`, lastNonBlankLine(state.bodyLines.join('\n')), text => ansiFg(statusColor) + text + RESET);
393
+ return [row];
394
+ }
395
+ // Boxed panel: top border + status-colored header + body tail + bottom
396
+ // border, on the pending/success/error tool surface.
397
+ const boxWidth = panelBoxWidth(width);
398
+ const borderFg = ansiFg(p.panelBorder);
399
+ const bg = state.status === 'pending'
400
+ ? p.toolPanelBg
401
+ : state.status === 'success' ? p.successMuted : p.dangerMuted;
402
+ const bgPrefix = ansiBg(bg);
403
+ const bodyRows = this.height === 'all' ? 'all' : Number(this.height) - 1;
404
+ const headerText = state.subject === '' ? `${state.name} · ${elapsed}` : `${state.name} ${state.subject} · ${elapsed}`;
405
+ const headerInner = ansiFg(statusColor) + `${icon} ${clipRow(headerText, width, 2)}`;
406
+ // Body tail + drop marker at the row budget ('all' keeps up to
407
+ // ALL_TOOL_RESULT_LINES rows); the marker replaces the first visible row.
408
+ const cap = bodyRows === 'all' ? ALL_TOOL_RESULT_LINES : bodyRows;
409
+ let lines = state.bodyLines;
410
+ let marker = false;
411
+ if (lines.length > cap) {
412
+ const dropped = lines.length - cap;
413
+ lines = lines.slice(-cap);
414
+ lines[0] = `… (+${dropped} lines)`;
415
+ marker = true;
416
+ }
417
+ const styled = lines.map((line, i) => ansiFg(marker && i === 0 ? p.fgSubtle : p.fgMuted) + ` ${clipRow(line, width, 2)}`);
418
+ const body = panelBodyText(styled, boxWidth, borderFg, bodyRows);
419
+ return [
420
+ bgRow(bgPrefix, panelTopBorder(boxWidth, borderFg)),
421
+ bgRow(bgPrefix, borderedRow(boxWidth, borderFg, headerInner)),
422
+ ...body.split('\n').map(row => bgRow(bgPrefix, row)),
423
+ ];
424
+ }
425
+ }
426
+ //# sourceMappingURL=activity.js.map