@yefengr/pi-subagents 0.0.0-stage → 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/CHANGELOG.md +2411 -0
- package/LICENSE +21 -0
- package/README.md +374 -2
- package/UPSTREAM.md +48 -0
- package/dist/public.d.ts +201 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +833 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +79 -0
- package/docs/configuration.md +215 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/index.ts +1 -0
- package/package.json +91 -4
- package/src/config/agent-enablement.ts +132 -0
- package/src/config/agent-types.ts +144 -0
- package/src/config/custom-agents.ts +114 -0
- package/src/config/default-agents.ts +120 -0
- package/src/config/invocation-config.ts +39 -0
- package/src/config/resource-limits.ts +26 -0
- package/src/config/settings-file.ts +174 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +65 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +257 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +89 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +123 -0
- package/src/lifecycle/create-subagent-session.ts +265 -0
- package/src/lifecycle/invocation-control.ts +109 -0
- package/src/lifecycle/lifecycle-record.ts +159 -0
- package/src/lifecycle/parent-snapshot.ts +46 -0
- package/src/lifecycle/run-listeners.ts +41 -0
- package/src/lifecycle/subagent-manager.ts +392 -0
- package/src/lifecycle/subagent-session.ts +287 -0
- package/src/lifecycle/subagent-state.ts +307 -0
- package/src/lifecycle/subagent.ts +854 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +53 -0
- package/src/observation/notification.ts +233 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +106 -0
- package/src/observation/subagent-events-observer.ts +75 -0
- package/src/runtime.ts +78 -0
- package/src/service/service-adapter.ts +159 -0
- package/src/service/service.ts +147 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +135 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +145 -0
- package/src/session/provider-inheritance.ts +38 -0
- package/src/session/session-config.ts +177 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +436 -0
- package/src/tools/agent-tool.ts +289 -0
- package/src/tools/background-spawner.ts +73 -0
- package/src/tools/foreground-runner.ts +135 -0
- package/src/tools/get-result-report.ts +73 -0
- package/src/tools/get-result-tool.ts +109 -0
- package/src/tools/helpers.ts +134 -0
- package/src/tools/result-renderer.ts +121 -0
- package/src/tools/spawn-config.ts +182 -0
- package/src/tools/steer-tool.ts +120 -0
- package/src/types.ts +145 -0
- package/src/ui/agent-widget.ts +313 -0
- package/src/ui/display.ts +214 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +152 -0
- package/src/ui/session-navigator.ts +257 -0
- package/src/ui/subagents-agents.ts +116 -0
- package/src/ui/subagents-settings.ts +207 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +289 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ---- Narrow interfaces ----
|
|
2
|
+
|
|
3
|
+
/** The toast a settings mutation returns for the UI to display. */
|
|
4
|
+
export interface SettingsToast {
|
|
5
|
+
message: string;
|
|
6
|
+
level: "info" | "warning";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Narrow settings interface required by the subagents:settings command. */
|
|
10
|
+
export interface SubagentsSettingsManager {
|
|
11
|
+
readonly maxConcurrent: number;
|
|
12
|
+
readonly maxTurns: number;
|
|
13
|
+
readonly wrapUpTurns: number;
|
|
14
|
+
readonly maxDurationSeconds: number;
|
|
15
|
+
readonly maxQueueWaitSeconds: number;
|
|
16
|
+
readonly abortGraceSeconds: number;
|
|
17
|
+
readonly consumedSessionRetentionMinutes: number;
|
|
18
|
+
readonly unconsumedSessionRetentionMinutes: number;
|
|
19
|
+
readonly abortAllOnInterrupt: boolean;
|
|
20
|
+
applyMaxConcurrent(n: number): SettingsToast;
|
|
21
|
+
applyMaxTurns(n: number): SettingsToast;
|
|
22
|
+
applyWrapUpTurns(n: number): SettingsToast;
|
|
23
|
+
applyMaxDurationSeconds(n: number): SettingsToast;
|
|
24
|
+
applyMaxQueueWaitSeconds(n: number): SettingsToast;
|
|
25
|
+
applyAbortGraceSeconds(n: number): SettingsToast;
|
|
26
|
+
applyConsumedSessionRetentionMinutes(n: number): SettingsToast;
|
|
27
|
+
applyUnconsumedSessionRetentionMinutes(n: number): SettingsToast;
|
|
28
|
+
toggleAbortAllOnInterrupt(): SettingsToast;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Narrow UI interface — only the ctx.ui methods the settings handler calls. */
|
|
32
|
+
export interface SubagentsSettingsUI {
|
|
33
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
34
|
+
input(title: string, defaultValue?: string): Promise<string | undefined>;
|
|
35
|
+
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---- Descriptor table ----
|
|
39
|
+
|
|
40
|
+
/** Fields every setting needs to render its line in the select list. */
|
|
41
|
+
interface SettingDescriptorBase {
|
|
42
|
+
/** Prefix used both to build the select option and to match the user's choice. */
|
|
43
|
+
label: string;
|
|
44
|
+
/** Current value rendered in the select option. */
|
|
45
|
+
currentDisplay: (settings: SubagentsSettingsManager) => string | number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Describes one numeric setting's prompt, validation, and apply behavior. */
|
|
49
|
+
interface NumericSettingDescriptor extends SettingDescriptorBase {
|
|
50
|
+
kind: "numeric";
|
|
51
|
+
/** Title shown on the input prompt. */
|
|
52
|
+
inputTitle: string;
|
|
53
|
+
/** Value pre-filled into the input box. */
|
|
54
|
+
inputDefault: (settings: SubagentsSettingsManager) => string;
|
|
55
|
+
/** Minimum accepted integer, inclusive. */
|
|
56
|
+
minimum: number;
|
|
57
|
+
/** Warning shown when the parsed value is below the minimum. */
|
|
58
|
+
validationMessage: string;
|
|
59
|
+
/** Applies the validated value and returns the toast to display. */
|
|
60
|
+
apply: (settings: SubagentsSettingsManager, n: number) => SettingsToast;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Describes one boolean setting, flipped directly from the select list. */
|
|
64
|
+
interface ToggleSettingDescriptor extends SettingDescriptorBase {
|
|
65
|
+
kind: "toggle";
|
|
66
|
+
/** Flips the setting and returns the toast to display. */
|
|
67
|
+
toggle: (settings: SubagentsSettingsManager) => SettingsToast;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type SettingDescriptor = NumericSettingDescriptor | ToggleSettingDescriptor;
|
|
71
|
+
|
|
72
|
+
const SETTINGS: readonly SettingDescriptor[] = [
|
|
73
|
+
{
|
|
74
|
+
kind: "numeric",
|
|
75
|
+
label: "Max concurrency",
|
|
76
|
+
currentDisplay: (settings) => settings.maxConcurrent,
|
|
77
|
+
inputTitle: "Max concurrent background agents",
|
|
78
|
+
inputDefault: (settings) => String(settings.maxConcurrent),
|
|
79
|
+
minimum: 1,
|
|
80
|
+
validationMessage: "Must be a positive integer.",
|
|
81
|
+
apply: (settings, n) => settings.applyMaxConcurrent(n),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
kind: "numeric",
|
|
85
|
+
label: "Max turns",
|
|
86
|
+
currentDisplay: (settings) => settings.maxTurns,
|
|
87
|
+
inputTitle: "Max turns before the hard stop",
|
|
88
|
+
inputDefault: (settings) => String(settings.maxTurns),
|
|
89
|
+
minimum: 1,
|
|
90
|
+
validationMessage: "Must be a positive integer.",
|
|
91
|
+
apply: (settings, n) => settings.applyMaxTurns(n),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
kind: "numeric",
|
|
95
|
+
label: "Wrap-up turns",
|
|
96
|
+
currentDisplay: (settings) => settings.wrapUpTurns,
|
|
97
|
+
inputTitle: "Turns reserved for wrap-up before the hard stop (0 = stop at the limit)",
|
|
98
|
+
inputDefault: (settings) => String(settings.wrapUpTurns),
|
|
99
|
+
minimum: 0,
|
|
100
|
+
validationMessage: "Must be 0 or a positive integer.",
|
|
101
|
+
apply: (settings, n) => settings.applyWrapUpTurns(n),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
kind: "numeric",
|
|
105
|
+
label: "Max duration",
|
|
106
|
+
currentDisplay: (settings) => `${settings.maxDurationSeconds} s`,
|
|
107
|
+
inputTitle: "Max agent duration in seconds",
|
|
108
|
+
inputDefault: (settings) => String(settings.maxDurationSeconds),
|
|
109
|
+
minimum: 1,
|
|
110
|
+
validationMessage: "Must be a positive integer.",
|
|
111
|
+
apply: (settings, n) => settings.applyMaxDurationSeconds(n),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
kind: "numeric",
|
|
115
|
+
label: "Max queue wait",
|
|
116
|
+
currentDisplay: (settings) => `${settings.maxQueueWaitSeconds} s`,
|
|
117
|
+
inputTitle: "Max queue wait in seconds",
|
|
118
|
+
inputDefault: (settings) => String(settings.maxQueueWaitSeconds),
|
|
119
|
+
minimum: 1,
|
|
120
|
+
validationMessage: "Must be a positive integer.",
|
|
121
|
+
apply: (settings, n) => settings.applyMaxQueueWaitSeconds(n),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
kind: "numeric",
|
|
125
|
+
label: "Abort grace",
|
|
126
|
+
currentDisplay: (settings) => `${settings.abortGraceSeconds} s`,
|
|
127
|
+
inputTitle: "Abort grace period in seconds",
|
|
128
|
+
inputDefault: (settings) => String(settings.abortGraceSeconds),
|
|
129
|
+
minimum: 1,
|
|
130
|
+
validationMessage: "Must be a positive integer.",
|
|
131
|
+
apply: (settings, n) => settings.applyAbortGraceSeconds(n),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
kind: "numeric",
|
|
135
|
+
label: "Consumed-session retention",
|
|
136
|
+
currentDisplay: (settings) => `${settings.consumedSessionRetentionMinutes} min`,
|
|
137
|
+
inputTitle: "Minutes to retain a consumed agent's session",
|
|
138
|
+
inputDefault: (settings) => String(settings.consumedSessionRetentionMinutes),
|
|
139
|
+
minimum: 1,
|
|
140
|
+
validationMessage: "Must be a positive integer.",
|
|
141
|
+
apply: (settings, n) => settings.applyConsumedSessionRetentionMinutes(n),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
kind: "numeric",
|
|
145
|
+
label: "Unconsumed-session retention",
|
|
146
|
+
currentDisplay: (settings) => `${settings.unconsumedSessionRetentionMinutes} min`,
|
|
147
|
+
inputTitle: "Minutes to retain an unconsumed agent's session (safety cap)",
|
|
148
|
+
inputDefault: (settings) => String(settings.unconsumedSessionRetentionMinutes),
|
|
149
|
+
minimum: 1,
|
|
150
|
+
validationMessage: "Must be a positive integer.",
|
|
151
|
+
apply: (settings, n) => settings.applyUnconsumedSessionRetentionMinutes(n),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
kind: "toggle",
|
|
155
|
+
label: "Abort all subagents on ESC",
|
|
156
|
+
currentDisplay: (settings) => (settings.abortAllOnInterrupt ? "on" : "off"),
|
|
157
|
+
toggle: (settings) => settings.toggleAbortAllOnInterrupt(),
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// ---- Class ----
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Handler for the `/subagents:settings` slash command.
|
|
165
|
+
*
|
|
166
|
+
* Call `handle({ ui })` from the Pi command registration to open the interactive
|
|
167
|
+
* settings list. Lifted from `AgentsMenuHandler.showSettings`.
|
|
168
|
+
*/
|
|
169
|
+
export class SubagentsSettingsHandler {
|
|
170
|
+
constructor(private readonly settings: SubagentsSettingsManager) {}
|
|
171
|
+
|
|
172
|
+
async handle({ ui }: { ui: SubagentsSettingsUI }): Promise<void> {
|
|
173
|
+
const options = SETTINGS.map(
|
|
174
|
+
(d) => `${d.label} (current: ${d.currentDisplay(this.settings)})`,
|
|
175
|
+
);
|
|
176
|
+
const choice = await ui.select("Settings", options);
|
|
177
|
+
if (!choice) return;
|
|
178
|
+
|
|
179
|
+
const descriptor = SETTINGS.find((d) => choice.startsWith(d.label));
|
|
180
|
+
if (!descriptor) return;
|
|
181
|
+
|
|
182
|
+
if (descriptor.kind === "toggle") {
|
|
183
|
+
const toast = descriptor.toggle(this.settings);
|
|
184
|
+
ui.notify(toast.message, toast.level);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await this.promptNumeric(ui, descriptor);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Ask for a number, validate it against the descriptor, apply it, and notify. */
|
|
192
|
+
private async promptNumeric(
|
|
193
|
+
ui: SubagentsSettingsUI,
|
|
194
|
+
descriptor: NumericSettingDescriptor,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const val = await ui.input(descriptor.inputTitle, descriptor.inputDefault(this.settings));
|
|
197
|
+
if (!val) return;
|
|
198
|
+
|
|
199
|
+
const n = parseInt(val, 10);
|
|
200
|
+
if (n >= descriptor.minimum) {
|
|
201
|
+
const toast = descriptor.apply(this.settings, n);
|
|
202
|
+
ui.notify(toast.message, toast.level);
|
|
203
|
+
} else {
|
|
204
|
+
ui.notify(descriptor.validationMessage, "warning");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript-content.ts — the rows the `/subagents:sessions` overlay paints.
|
|
3
|
+
*
|
|
4
|
+
* Owns the transcript's content model: the `SessionMessage` → Pi-component
|
|
5
|
+
* mapping (mirroring Pi's own interactive-mode `renderSessionContext`), and the
|
|
6
|
+
* rendered rows those components produce at a given width. The overlay
|
|
7
|
+
* (`session-navigator.ts`) owns scroll state, chrome, and key handling, and
|
|
8
|
+
* asks this collaborator only for rows — it never reaches into the components.
|
|
9
|
+
*
|
|
10
|
+
* Lives in the SDK/TUI layer rather than the pure `session-navigation.ts` core
|
|
11
|
+
* because Pi's per-entry components require a `TUI`, `cwd`, and markdown theme.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
AssistantMessageComponent,
|
|
16
|
+
BashExecutionComponent,
|
|
17
|
+
BranchSummaryMessageComponent,
|
|
18
|
+
CompactionSummaryMessageComponent,
|
|
19
|
+
parseSkillBlock,
|
|
20
|
+
SkillInvocationMessageComponent,
|
|
21
|
+
ToolExecutionComponent,
|
|
22
|
+
UserMessageComponent,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Container, type MarkdownTheme, Spacer, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
25
|
+
import type { AgentSessionEvent, SessionMessage } from "#src/types";
|
|
26
|
+
import { describeActivity } from "#src/ui/display";
|
|
27
|
+
import { GLYPHS } from "#src/ui/glyphs";
|
|
28
|
+
import type { TranscriptSource } from "#src/ui/session-navigation";
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** The SDK/TUI environment Pi's per-entry components need, plus the transcript's source. */
|
|
33
|
+
export interface TranscriptContentOptions {
|
|
34
|
+
tui: TUI;
|
|
35
|
+
cwd: string;
|
|
36
|
+
markdownTheme: MarkdownTheme;
|
|
37
|
+
source: TranscriptSource;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The transcript's renderable rows: settled history rendered through Pi's
|
|
42
|
+
* per-entry components, followed by the running agent's activity row.
|
|
43
|
+
*
|
|
44
|
+
* Settled rows are rendered once per width and cached, so a paint or a scroll
|
|
45
|
+
* costs a slice rather than a walk of the whole component tree. The activity
|
|
46
|
+
* row is recomputed per call — it is two rows, and it tracks live state the
|
|
47
|
+
* source updates without a rebuild.
|
|
48
|
+
*
|
|
49
|
+
* Settled history is held as one block of components per message, so a message
|
|
50
|
+
* that settles appends a block and a tool result touches only its own — neither
|
|
51
|
+
* discards the rest of the transcript's rendering. This is sound because the
|
|
52
|
+
* agent core pushes a finished message into its message array before notifying
|
|
53
|
+
* listeners, so at event time the settled prefix is already visible.
|
|
54
|
+
*
|
|
55
|
+
* The message currently being streamed is held as its own component, updated
|
|
56
|
+
* per delta exactly as Pi's interactive mode does, so a token never touches
|
|
57
|
+
* settled history. The two provably cannot overlap: the agent core keeps the
|
|
58
|
+
* in-flight message outside its message array until it settles.
|
|
59
|
+
*/
|
|
60
|
+
export class TranscriptContent {
|
|
61
|
+
private readonly options: TranscriptContentOptions;
|
|
62
|
+
/** Settled history, one block per message that produced components. */
|
|
63
|
+
private blocks: SettledBlock[] = [];
|
|
64
|
+
/** How many source messages have been consumed into `blocks`. */
|
|
65
|
+
private consumedCount = 0;
|
|
66
|
+
/** The last consumed message; a mismatch means history was rewritten. */
|
|
67
|
+
private lastConsumed: SessionMessage | undefined;
|
|
68
|
+
/** Whether any consumed block holds components, driving user-message spacing. */
|
|
69
|
+
private hasVisibleContent = false;
|
|
70
|
+
/** In-flight tool components by tool-call id, pairing a later result to its block. */
|
|
71
|
+
private readonly pendingTools = new Map<string, PendingTool>();
|
|
72
|
+
/** Width `blocks` and `settledRows` were rendered at. */
|
|
73
|
+
private settledWidth: number | undefined;
|
|
74
|
+
/** Concatenation of every block's rows, or undefined when the cache is cold. */
|
|
75
|
+
private settledRows: readonly string[] | undefined;
|
|
76
|
+
/** The message being streamed right now, rendered below settled history. */
|
|
77
|
+
private inFlight: AssistantMessageComponent | undefined;
|
|
78
|
+
/** Width `inFlightRows` was rendered at. */
|
|
79
|
+
private inFlightWidth: number | undefined;
|
|
80
|
+
private inFlightRows: readonly string[] | undefined;
|
|
81
|
+
|
|
82
|
+
constructor(options: TranscriptContentOptions) {
|
|
83
|
+
this.options = options;
|
|
84
|
+
this.consumeSettled();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Total content rows at `width`: settled history plus the live tail. */
|
|
88
|
+
lineCount(width: number): number {
|
|
89
|
+
if (width <= 0) return 0;
|
|
90
|
+
return this.settled(width).length + this.tail(width).length;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Rows `[start, start + count)` at `width`, clamped to what exists. */
|
|
94
|
+
slice(width: number, start: number, count: number): string[] {
|
|
95
|
+
if (width <= 0) return [];
|
|
96
|
+
const settled = this.settled(width);
|
|
97
|
+
const end = start + count;
|
|
98
|
+
const rows = settled.slice(start, Math.min(end, settled.length));
|
|
99
|
+
if (end > settled.length) {
|
|
100
|
+
const tail = this.tail(width);
|
|
101
|
+
rows.push(...tail.slice(Math.max(0, start - settled.length), end - settled.length));
|
|
102
|
+
}
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Route one session event to the narrowest update it allows. A delta on the
|
|
108
|
+
* in-flight message touches only that component. A run or compaction boundary
|
|
109
|
+
* may have rewritten or mutated history in place, so it rebuilds wholesale.
|
|
110
|
+
* Everything else consumes whatever settled since the last check.
|
|
111
|
+
*/
|
|
112
|
+
apply(event?: AgentSessionEvent): void {
|
|
113
|
+
const partial = inFlightAssistantMessage(event);
|
|
114
|
+
if (partial) {
|
|
115
|
+
this.updateInFlight(partial);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (event?.type === "agent_end" || event?.type === "compaction_end") {
|
|
119
|
+
this.reset();
|
|
120
|
+
} else if (event?.type === "message_end") {
|
|
121
|
+
this.clearInFlight();
|
|
122
|
+
}
|
|
123
|
+
this.consumeSettled();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Drop cached rendering held by the mounted components and by this object. */
|
|
127
|
+
invalidate(): void {
|
|
128
|
+
for (const block of this.blocks) {
|
|
129
|
+
block.container.invalidate();
|
|
130
|
+
block.rows = undefined;
|
|
131
|
+
}
|
|
132
|
+
this.settledRows = undefined;
|
|
133
|
+
this.inFlight?.invalidate();
|
|
134
|
+
this.inFlightRows = undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- Private ----
|
|
138
|
+
|
|
139
|
+
/** Settled rows at `width`; each block renders once per width and content change. */
|
|
140
|
+
private settled(width: number): readonly string[] {
|
|
141
|
+
if (this.settledWidth !== width) {
|
|
142
|
+
this.settledWidth = width;
|
|
143
|
+
for (const block of this.blocks) block.rows = undefined;
|
|
144
|
+
this.settledRows = undefined;
|
|
145
|
+
}
|
|
146
|
+
if (this.settledRows) return this.settledRows;
|
|
147
|
+
const rows: string[] = [];
|
|
148
|
+
for (const block of this.blocks) {
|
|
149
|
+
block.rows ??= block.container.render(width).map((row) => truncateToWidth(row, width));
|
|
150
|
+
rows.push(...block.rows);
|
|
151
|
+
}
|
|
152
|
+
this.settledRows = rows;
|
|
153
|
+
return rows;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Rows below settled history: the in-flight message, then the activity row. */
|
|
157
|
+
private tail(width: number): readonly string[] {
|
|
158
|
+
const rows = [...this.inFlightRendered(width)];
|
|
159
|
+
const streaming = this.options.source.streaming();
|
|
160
|
+
if (streaming) {
|
|
161
|
+
const activity = `${GLYPHS.streaming} ${describeActivity(streaming.activeTools, streaming.responseText)}`;
|
|
162
|
+
rows.push("", truncateToWidth(activity, width));
|
|
163
|
+
}
|
|
164
|
+
return rows;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The in-flight message's rows, re-rendered independently of settled history. */
|
|
168
|
+
private inFlightRendered(width: number): readonly string[] {
|
|
169
|
+
if (!this.inFlight) return [];
|
|
170
|
+
if (this.inFlightWidth !== width) {
|
|
171
|
+
this.inFlightWidth = width;
|
|
172
|
+
this.inFlightRows = undefined;
|
|
173
|
+
}
|
|
174
|
+
this.inFlightRows ??= this.inFlight.render(width).map((row) => truncateToWidth(row, width));
|
|
175
|
+
return this.inFlightRows;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private updateInFlight(message: AssistantSessionMessage): void {
|
|
179
|
+
if (this.inFlight) this.inFlight.updateContent(message);
|
|
180
|
+
else this.inFlight = new AssistantMessageComponent(message, false, this.options.markdownTheme);
|
|
181
|
+
this.inFlightRows = undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private clearInFlight(): void {
|
|
185
|
+
this.inFlight = undefined;
|
|
186
|
+
this.inFlightRows = undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Discard all settled state, so the next consume rebuilds from scratch. */
|
|
190
|
+
private reset(): void {
|
|
191
|
+
this.blocks = [];
|
|
192
|
+
this.consumedCount = 0;
|
|
193
|
+
this.lastConsumed = undefined;
|
|
194
|
+
this.hasVisibleContent = false;
|
|
195
|
+
this.pendingTools.clear();
|
|
196
|
+
this.settledRows = undefined;
|
|
197
|
+
this.clearInFlight();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Append blocks for every message that settled since the last check. When the
|
|
202
|
+
* consumed prefix no longer mirrors the source — history replaced wholesale by
|
|
203
|
+
* compaction or branching — start over rather than appending onto stale blocks.
|
|
204
|
+
*/
|
|
205
|
+
private consumeSettled(): void {
|
|
206
|
+
const messages = this.options.source.getMessages();
|
|
207
|
+
if (this.hasRewrittenHistory(messages)) this.reset();
|
|
208
|
+
if (messages.length === this.consumedCount) return;
|
|
209
|
+
for (let i = this.consumedCount; i < messages.length; i++) this.consumeMessage(messages[i]);
|
|
210
|
+
this.consumedCount = messages.length;
|
|
211
|
+
this.lastConsumed = messages.at(-1);
|
|
212
|
+
this.settledRows = undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private hasRewrittenHistory(messages: readonly SessionMessage[]): boolean {
|
|
216
|
+
if (messages.length < this.consumedCount) return true;
|
|
217
|
+
return this.consumedCount > 0 && messages[this.consumedCount - 1] !== this.lastConsumed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Map one settled message onto its own block of Pi's per-entry components,
|
|
222
|
+
* mirroring Pi's own interactive-mode `renderSessionContext` mapping.
|
|
223
|
+
* `custom`-role messages produce no block — rendering them needs the child
|
|
224
|
+
* session's message-renderer registry, which the navigator does not hold.
|
|
225
|
+
*/
|
|
226
|
+
private consumeMessage(message: SessionMessage): void {
|
|
227
|
+
switch (message.role) {
|
|
228
|
+
case "assistant":
|
|
229
|
+
this.consumeAssistant(message);
|
|
230
|
+
break;
|
|
231
|
+
case "toolResult":
|
|
232
|
+
this.consumeToolResult(message);
|
|
233
|
+
break;
|
|
234
|
+
case "user":
|
|
235
|
+
this.consumeUser(message);
|
|
236
|
+
break;
|
|
237
|
+
case "bashExecution":
|
|
238
|
+
this.consumeBashExecution(message);
|
|
239
|
+
break;
|
|
240
|
+
case "compactionSummary":
|
|
241
|
+
this.consumeSummary(new CompactionSummaryMessageComponent(message, this.options.markdownTheme));
|
|
242
|
+
break;
|
|
243
|
+
case "branchSummary":
|
|
244
|
+
this.consumeSummary(new BranchSummaryMessageComponent(message, this.options.markdownTheme));
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private consumeAssistant(message: AssistantSessionMessage): void {
|
|
250
|
+
const block = this.appendBlock();
|
|
251
|
+
block.container.addChild(new AssistantMessageComponent(message, false, this.options.markdownTheme));
|
|
252
|
+
for (const content of message.content) {
|
|
253
|
+
if (content.type !== "toolCall") continue;
|
|
254
|
+
const tool = new ToolExecutionComponent(
|
|
255
|
+
content.name,
|
|
256
|
+
content.id,
|
|
257
|
+
content.arguments,
|
|
258
|
+
{ showImages: false },
|
|
259
|
+
this.options.source.getToolDefinition(content.name),
|
|
260
|
+
this.options.tui,
|
|
261
|
+
this.options.cwd,
|
|
262
|
+
);
|
|
263
|
+
tool.setExpanded(true);
|
|
264
|
+
block.container.addChild(tool);
|
|
265
|
+
this.pendingTools.set(content.id, { component: tool, block });
|
|
266
|
+
}
|
|
267
|
+
this.hasVisibleContent = true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A tool result mutates the block holding its call; no other block changes. */
|
|
271
|
+
private consumeToolResult(message: Extract<SessionMessage, { role: "toolResult" }>): void {
|
|
272
|
+
const pending = this.pendingTools.get(message.toolCallId);
|
|
273
|
+
if (!pending) return;
|
|
274
|
+
pending.component.updateResult(message);
|
|
275
|
+
pending.block.rows = undefined;
|
|
276
|
+
this.settledRows = undefined;
|
|
277
|
+
this.pendingTools.delete(message.toolCallId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private consumeUser(message: Extract<SessionMessage, { role: "user" }>): void {
|
|
281
|
+
const block = this.appendBlock();
|
|
282
|
+
addUserComponents(block.container, message.content, this.options.markdownTheme, this.hasVisibleContent);
|
|
283
|
+
if (block.container.children.length > 0) this.hasVisibleContent = true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private consumeBashExecution(message: Extract<SessionMessage, { role: "bashExecution" }>): void {
|
|
287
|
+
const block = this.appendBlock();
|
|
288
|
+
const bash = new BashExecutionComponent(message.command, this.options.tui, message.excludeFromContext);
|
|
289
|
+
if (message.output) bash.appendOutput(message.output);
|
|
290
|
+
bash.setComplete(message.exitCode, message.cancelled, undefined, message.fullOutputPath);
|
|
291
|
+
block.container.addChild(bash);
|
|
292
|
+
this.hasVisibleContent = true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Compaction and branch summaries share the same spacer-plus-expanded shape. */
|
|
296
|
+
private consumeSummary(
|
|
297
|
+
summary: CompactionSummaryMessageComponent | BranchSummaryMessageComponent,
|
|
298
|
+
): void {
|
|
299
|
+
const block = this.appendBlock();
|
|
300
|
+
block.container.addChild(new Spacer(1));
|
|
301
|
+
summary.setExpanded(true);
|
|
302
|
+
block.container.addChild(summary);
|
|
303
|
+
this.hasVisibleContent = true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private appendBlock(): SettledBlock {
|
|
307
|
+
const block: SettledBlock = { container: new Container(), rows: undefined };
|
|
308
|
+
this.blocks.push(block);
|
|
309
|
+
return block;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One settled message's components plus its rows at the current width. */
|
|
314
|
+
interface SettledBlock {
|
|
315
|
+
readonly container: Container;
|
|
316
|
+
rows: readonly string[] | undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A tool call awaiting its result, and the block whose rows it will invalidate. */
|
|
320
|
+
interface PendingTool {
|
|
321
|
+
readonly component: ToolExecutionComponent;
|
|
322
|
+
readonly block: SettledBlock;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The assistant variant of a session message, as Pi's components consume it. */
|
|
326
|
+
type AssistantSessionMessage = Extract<SessionMessage, { role: "assistant" }>;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The in-flight assistant message a partial event carries, or undefined when the
|
|
330
|
+
* event is anything else. A partial for another role means a message settled
|
|
331
|
+
* elsewhere in the history, which only a rebuild can pick up.
|
|
332
|
+
*/
|
|
333
|
+
function inFlightAssistantMessage(event?: AgentSessionEvent): AssistantSessionMessage | undefined {
|
|
334
|
+
if (event?.type !== "message_start" && event?.type !== "message_update") return undefined;
|
|
335
|
+
return event.message.role === "assistant" ? event.message : undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Render a user message (skill block + text) into the block, mirroring Pi.
|
|
340
|
+
* Whether a leading spacer is needed is a whole-transcript property, so the
|
|
341
|
+
* caller — which alone knows what precedes this block — supplies it.
|
|
342
|
+
*/
|
|
343
|
+
function addUserComponents(
|
|
344
|
+
container: Container,
|
|
345
|
+
content: string | readonly { type: string; text?: string }[],
|
|
346
|
+
markdownTheme: MarkdownTheme,
|
|
347
|
+
hasPrecedingContent: boolean,
|
|
348
|
+
): void {
|
|
349
|
+
const text = userMessageText(content);
|
|
350
|
+
if (!text) return;
|
|
351
|
+
if (hasPrecedingContent) container.addChild(new Spacer(1));
|
|
352
|
+
|
|
353
|
+
const skillBlock = parseSkillBlock(text);
|
|
354
|
+
if (!skillBlock) {
|
|
355
|
+
container.addChild(new UserMessageComponent(text, markdownTheme));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const skill = new SkillInvocationMessageComponent(skillBlock, markdownTheme);
|
|
359
|
+
skill.setExpanded(true);
|
|
360
|
+
container.addChild(skill);
|
|
361
|
+
if (skillBlock.userMessage) {
|
|
362
|
+
container.addChild(new Spacer(1));
|
|
363
|
+
container.addChild(new UserMessageComponent(skillBlock.userMessage, markdownTheme));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Concatenate the text blocks of a user message's content (mirrors Pi). */
|
|
368
|
+
function userMessageText(content: string | readonly { type: string; text?: string }[]): string {
|
|
369
|
+
if (typeof content === "string") return content;
|
|
370
|
+
return content
|
|
371
|
+
.filter((block) => block.type === "text")
|
|
372
|
+
.map((block) => block.text ?? "")
|
|
373
|
+
.join("");
|
|
374
|
+
}
|