@aiwayds/dsh-tui-pi 0.4.0 → 0.4.3
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 +20 -10
- package/cordis.patch.yml +5 -0
- package/lib/activity.d.ts +175 -0
- package/lib/activity.js +426 -0
- package/lib/activity.js.map +1 -0
- package/lib/dsh-events.d.ts +7 -4
- package/lib/index.js +20 -21
- package/lib/index.js.map +1 -1
- package/lib/live-widgets.d.ts +76 -50
- package/lib/live-widgets.js +167 -95
- package/lib/live-widgets.js.map +1 -1
- package/lib/messages.d.ts +22 -171
- package/lib/messages.js +37 -417
- package/lib/messages.js.map +1 -1
- package/lib/session.d.ts +7 -0
- package/lib/session.js +73 -6
- package/lib/session.js.map +1 -1
- package/lib/subagent-viewer.js +1 -1
- package/lib/text.d.ts +7 -0
- package/lib/text.js +17 -0
- package/lib/text.js.map +1 -1
- package/lib/theme-settings.d.ts +1 -1
- package/lib/theme-settings.js +9 -6
- package/lib/theme-settings.js.map +1 -1
- package/package.json +2 -1
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
|
-
- **
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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,6 +3,11 @@
|
|
|
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
|
+
# dsh-dcp is NOT mounted here: it is a dependency of this package and ships
|
|
8
|
+
# its own bundle patch (@aiwayds/dsh-dcp/cordis.patch.yml). Adding dsh-dcp to
|
|
9
|
+
# the profile's bundles mounts it; mounting it here too would duplicate the
|
|
10
|
+
# entry id and crash the loader.
|
|
6
11
|
|
|
7
12
|
- insert:
|
|
8
13
|
- id: tui-pi
|
|
@@ -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
|
+
}
|
package/lib/activity.js
ADDED
|
@@ -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
|