@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,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-navigation.ts — Pure selection and transcript-sourcing for native session navigation.
|
|
3
|
+
*
|
|
4
|
+
* Splits the unit-testable core of the `/subagents:sessions` command from its TUI
|
|
5
|
+
* wiring (`session-navigator.ts`): which subagents are navigable and how a picked
|
|
6
|
+
* agent's transcript is sourced (live, in this slice).
|
|
7
|
+
*
|
|
8
|
+
* The `TranscriptSource` seam decouples *how messages are sourced* (live record
|
|
9
|
+
* here; a file snapshot in a follow-up) from *how they render* — the renderer
|
|
10
|
+
* (`session-navigator.ts`, which mounts Pi's per-entry components) talks only to
|
|
11
|
+
* this seam. Rendering lives in the SDK/TUI module because the per-entry
|
|
12
|
+
* components require a `TUI`, `cwd`, and markdown theme.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { buildSessionContext, parseSessionEntries, type SessionEntry, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
17
|
+
import type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent-state";
|
|
18
|
+
import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
|
|
19
|
+
import { formatDuration, getDisplayName } from "#src/ui/display";
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** The record fields the navigator reads to label and live-source a transcript. */
|
|
24
|
+
export interface NavigableSubagent {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly type: SubagentType;
|
|
27
|
+
readonly description: string;
|
|
28
|
+
readonly phase: SubagentPhase;
|
|
29
|
+
readonly outcome?: SubagentOutcome;
|
|
30
|
+
readonly startedAt: number;
|
|
31
|
+
readonly completedAt: number | undefined;
|
|
32
|
+
readonly toolUses: number;
|
|
33
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
34
|
+
readonly responseText: string;
|
|
35
|
+
readonly agentMessages: readonly SessionMessage[];
|
|
36
|
+
/** Persisted transcript path, retained after the live session is released. */
|
|
37
|
+
readonly outputFile: string | undefined;
|
|
38
|
+
isSessionReady(): boolean;
|
|
39
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
|
|
40
|
+
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A navigable entry plus the label shown in the picker.
|
|
45
|
+
*
|
|
46
|
+
* A `live` entry sources its transcript from the in-memory record; a `snapshot`
|
|
47
|
+
* entry sources it from the persisted session file (the session was released by
|
|
48
|
+
* the retention sweep, but the record and its transcript pointer survive).
|
|
49
|
+
*/
|
|
50
|
+
export type NavigationEntry =
|
|
51
|
+
| { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
|
|
52
|
+
| { readonly kind: "snapshot"; readonly label: string; readonly outputFile: string };
|
|
53
|
+
|
|
54
|
+
/** The fields `buildLabel` reads — shared by the live and snapshot (released-session) label paths. */
|
|
55
|
+
interface LabelFields {
|
|
56
|
+
readonly type: SubagentType;
|
|
57
|
+
readonly description: string;
|
|
58
|
+
readonly phase: SubagentPhase;
|
|
59
|
+
readonly outcome?: SubagentOutcome;
|
|
60
|
+
readonly startedAt: number;
|
|
61
|
+
readonly completedAt: number | undefined;
|
|
62
|
+
readonly toolUses: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Running-agent streaming state, surfaced by a live source. */
|
|
66
|
+
export interface StreamingState {
|
|
67
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
68
|
+
readonly responseText: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Liveness-agnostic transcript source consumed by the renderer. */
|
|
72
|
+
export interface TranscriptSource {
|
|
73
|
+
/** Current message history. */
|
|
74
|
+
getMessages(): readonly SessionMessage[];
|
|
75
|
+
/**
|
|
76
|
+
* Subscribe to changes; returns an unsubscribe, or undefined for a static
|
|
77
|
+
* snapshot. The session event is forwarded so a consumer can route on it —
|
|
78
|
+
* a streaming delta and a settled message warrant very different work.
|
|
79
|
+
*/
|
|
80
|
+
subscribe(onChange: (event?: AgentSessionEvent) => void): (() => void) | undefined;
|
|
81
|
+
/** Running-agent streaming state, or undefined when not streaming. */
|
|
82
|
+
streaming(): StreamingState | undefined;
|
|
83
|
+
/** Resolve a registered tool definition by name, for Pi's tool-execution components. */
|
|
84
|
+
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Label every navigable subagent for the picker: records with a live session
|
|
89
|
+
* source their transcript in-memory (`live`); records whose session the
|
|
90
|
+
* retention sweep released but which retain a transcript pointer source it from
|
|
91
|
+
* disk (`snapshot`). Records with neither are not navigable. Live entries first.
|
|
92
|
+
*/
|
|
93
|
+
export function listNavigableAgents(
|
|
94
|
+
agents: readonly NavigableSubagent[],
|
|
95
|
+
registry: AgentConfigLookup,
|
|
96
|
+
): NavigationEntry[] {
|
|
97
|
+
const live: NavigationEntry[] = [];
|
|
98
|
+
const snapshots: NavigationEntry[] = [];
|
|
99
|
+
for (const record of agents) {
|
|
100
|
+
if (record.isSessionReady()) {
|
|
101
|
+
live.push({ kind: "live", record, label: buildLabel(record, registry) });
|
|
102
|
+
} else if (record.outputFile) {
|
|
103
|
+
snapshots.push({ kind: "snapshot", outputFile: record.outputFile, label: buildLabel(record, registry, true) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...live, ...snapshots];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Source a transcript from a persisted child-session JSONL snapshot.
|
|
111
|
+
*
|
|
112
|
+
* For an agent whose live session the retention sweep released: the in-memory
|
|
113
|
+
* message history is gone, but the session file survives on disk (and the
|
|
114
|
+
* record retains its path). Reads the file, drops the `SessionHeader`, and resolves the
|
|
115
|
+
* message list via Pi's own parser. A static snapshot — no subscription, no
|
|
116
|
+
* streaming, no live tool registry. `readFile` is injected so this module makes
|
|
117
|
+
* no `fs` calls.
|
|
118
|
+
*/
|
|
119
|
+
export function fileSnapshotSource(
|
|
120
|
+
outputFile: string,
|
|
121
|
+
readFile: (path: string) => string,
|
|
122
|
+
): TranscriptSource {
|
|
123
|
+
const entries = parseSessionEntries(readFile(outputFile));
|
|
124
|
+
const sessionEntries = entries.filter((entry): entry is SessionEntry => entry.type !== "session");
|
|
125
|
+
const { messages } = buildSessionContext(sessionEntries);
|
|
126
|
+
return {
|
|
127
|
+
getMessages: () => messages,
|
|
128
|
+
subscribe: () => undefined,
|
|
129
|
+
streaming: () => undefined,
|
|
130
|
+
getToolDefinition: () => undefined,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Source a transcript live from an in-memory record (this slice's only source). */
|
|
135
|
+
export function liveSource(record: NavigableSubagent): TranscriptSource {
|
|
136
|
+
return {
|
|
137
|
+
getMessages: () => record.agentMessages,
|
|
138
|
+
subscribe: (onChange) => record.subscribeToUpdates(onChange),
|
|
139
|
+
streaming: () =>
|
|
140
|
+
record.phase === "running"
|
|
141
|
+
? { activeTools: record.activeTools, responseText: record.responseText }
|
|
142
|
+
: undefined,
|
|
143
|
+
getToolDefinition: (name) => record.getToolDefinition(name),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildLabel(fields: LabelFields, registry: AgentConfigLookup, released = false): string {
|
|
148
|
+
const name = getDisplayName(fields.type, registry);
|
|
149
|
+
const duration = formatDuration(fields.startedAt, fields.completedAt);
|
|
150
|
+
const marker = released ? " · session released (snapshot)" : "";
|
|
151
|
+
return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.outcome ?? fields.phase} · ${duration}${marker}`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-navigator.ts — The `/subagents:sessions` command: pick a subagent and
|
|
3
|
+
* read its transcript through Pi's own per-entry session components.
|
|
4
|
+
*
|
|
5
|
+
* SDK/TUI consumer half of native session navigation. The unit-testable core
|
|
6
|
+
* (selection, sourcing) lives in `session-navigation.ts`; this module wires that
|
|
7
|
+
* core to the command picker and a read-only scrollable overlay, and owns the
|
|
8
|
+
* renderer — it mounts Pi's interactive components (`AssistantMessageComponent`,
|
|
9
|
+
* `ToolExecutionComponent`, …) into a `Container`, mirroring Pi's own
|
|
10
|
+
* `renderSessionContext` mapping. Rendering lives here, not in the pure module,
|
|
11
|
+
* because the components require a `TUI`, `cwd`, and markdown theme.
|
|
12
|
+
*
|
|
13
|
+
* The overlay is strictly read-only — steering stays in the `steer_subagent` tool
|
|
14
|
+
* and the widget. It consumes a `TranscriptSource`, so a released agent's disk
|
|
15
|
+
* snapshot (`fileSnapshotSource`) swaps in without touching the renderer or the overlay.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import {
|
|
20
|
+
type Component,
|
|
21
|
+
type MarkdownTheme,
|
|
22
|
+
matchesKey,
|
|
23
|
+
type TUI,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
visibleWidth,
|
|
26
|
+
} from "@earendil-works/pi-tui";
|
|
27
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
28
|
+
import type { Theme } from "#src/ui/display";
|
|
29
|
+
import { fileSnapshotSource, listNavigableAgents, liveSource, type NavigableSubagent, type TranscriptSource } from "#src/ui/session-navigation";
|
|
30
|
+
import { TranscriptContent } from "#src/ui/transcript-content";
|
|
31
|
+
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Chrome lines: top border + header + header sep + footer sep + footer + bottom border. */
|
|
35
|
+
const CHROME_LINES = 6;
|
|
36
|
+
const MIN_VIEWPORT = 3;
|
|
37
|
+
const VIEWPORT_HEIGHT_PCT = 70;
|
|
38
|
+
/** Overlay width as a share of the terminal, as handed to Pi's overlay compositor. */
|
|
39
|
+
const OVERLAY_WIDTH_PCT = 90;
|
|
40
|
+
|
|
41
|
+
/** Component factory shape Pi's `ui.custom` invokes to mount an overlay. */
|
|
42
|
+
export type OverlayComponentFactory<R> = (
|
|
43
|
+
tui: TUI,
|
|
44
|
+
theme: Theme,
|
|
45
|
+
keybindings: unknown,
|
|
46
|
+
done: (result: R) => void,
|
|
47
|
+
) => Component;
|
|
48
|
+
|
|
49
|
+
/** Narrow UI interface — only the `ctx.ui` methods the navigator calls. */
|
|
50
|
+
export interface SessionNavigatorUI {
|
|
51
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
52
|
+
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
53
|
+
custom<R>(component: OverlayComponentFactory<R>, options?: unknown): Promise<R>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parameters for one `/subagents:sessions` invocation. */
|
|
57
|
+
export interface SessionNavigatorParams {
|
|
58
|
+
ui: SessionNavigatorUI;
|
|
59
|
+
agents: readonly NavigableSubagent[];
|
|
60
|
+
registry: AgentConfigLookup;
|
|
61
|
+
/** Working directory for tool-call rendering (relative path display). */
|
|
62
|
+
cwd: string;
|
|
63
|
+
/** Reads a persisted session file for the file-snapshot source. */
|
|
64
|
+
readFile: (path: string) => string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Options for the read-only transcript overlay. */
|
|
68
|
+
export interface TranscriptOverlayOptions {
|
|
69
|
+
tui: TUI;
|
|
70
|
+
theme: Theme;
|
|
71
|
+
source: TranscriptSource;
|
|
72
|
+
done: (result: undefined) => void;
|
|
73
|
+
cwd: string;
|
|
74
|
+
markdownTheme: MarkdownTheme;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handler for the `/subagents:sessions` slash command.
|
|
79
|
+
*
|
|
80
|
+
* Lists navigable subagents, lets the operator pick one, and opens its transcript
|
|
81
|
+
* read-only. Receives the agent snapshot (`manager.listAgents()`) rather than the
|
|
82
|
+
* manager, so it stays a reactive consumer with no inbound call into the core.
|
|
83
|
+
*/
|
|
84
|
+
export class SessionNavigatorHandler {
|
|
85
|
+
async handle({ ui, agents, registry, cwd, readFile }: SessionNavigatorParams): Promise<void> {
|
|
86
|
+
const entries = listNavigableAgents(agents, registry);
|
|
87
|
+
if (entries.length === 0) {
|
|
88
|
+
ui.notify("No subagent sessions to view.", "info");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const choice = await ui.select(
|
|
93
|
+
"Subagent sessions",
|
|
94
|
+
entries.map((entry) => entry.label),
|
|
95
|
+
);
|
|
96
|
+
const entry = entries.find((candidate) => candidate.label === choice);
|
|
97
|
+
if (!entry) return;
|
|
98
|
+
|
|
99
|
+
let source: TranscriptSource;
|
|
100
|
+
try {
|
|
101
|
+
source = entry.kind === "live" ? liveSource(entry.record) : fileSnapshotSource(entry.outputFile, readFile);
|
|
102
|
+
} catch {
|
|
103
|
+
ui.notify("Could not read the session transcript file.", "error");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const markdownTheme = getMarkdownTheme();
|
|
107
|
+
await ui.custom<undefined>(
|
|
108
|
+
(tui, theme, _keybindings, done) =>
|
|
109
|
+
new TranscriptOverlay({ tui, theme, source, done, cwd, markdownTheme }),
|
|
110
|
+
{
|
|
111
|
+
overlay: true,
|
|
112
|
+
overlayOptions: {
|
|
113
|
+
anchor: "center",
|
|
114
|
+
width: `${OVERLAY_WIDTH_PCT}%`,
|
|
115
|
+
maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read-only scrollable transcript overlay.
|
|
124
|
+
*
|
|
125
|
+
* Owns scroll state, chrome, and key handling; the rows it paints come from a
|
|
126
|
+
* `TranscriptContent` collaborator, which holds the transcript's components and
|
|
127
|
+
* refreshes them when the source changes (live agents).
|
|
128
|
+
*/
|
|
129
|
+
export class TranscriptOverlay implements Component {
|
|
130
|
+
private scrollOffset = 0;
|
|
131
|
+
private autoScroll = true;
|
|
132
|
+
private unsubscribe: (() => void) | undefined;
|
|
133
|
+
private closed = false;
|
|
134
|
+
|
|
135
|
+
private readonly tui: TUI;
|
|
136
|
+
private readonly theme: Theme;
|
|
137
|
+
private readonly done: (result: undefined) => void;
|
|
138
|
+
private readonly content: TranscriptContent;
|
|
139
|
+
/** Inner width the compositor last rendered at; input must use the same layout. */
|
|
140
|
+
private renderedInnerWidth: number | undefined;
|
|
141
|
+
|
|
142
|
+
constructor({ tui, theme, source, done, cwd, markdownTheme }: TranscriptOverlayOptions) {
|
|
143
|
+
this.tui = tui;
|
|
144
|
+
this.theme = theme;
|
|
145
|
+
this.done = done;
|
|
146
|
+
this.content = new TranscriptContent({ tui, cwd, markdownTheme, source });
|
|
147
|
+
this.unsubscribe = source.subscribe((event) => {
|
|
148
|
+
if (this.closed) return;
|
|
149
|
+
this.content.apply(event);
|
|
150
|
+
this.tui.requestRender();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
handleInput(data: string): void {
|
|
155
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
156
|
+
this.closed = true;
|
|
157
|
+
this.done(undefined);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const totalLines = this.content.lineCount(this.inputWidth());
|
|
162
|
+
const viewportHeight = this.viewportHeight();
|
|
163
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
164
|
+
|
|
165
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
166
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
167
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
168
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
169
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
170
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
171
|
+
} else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
172
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
173
|
+
this.autoScroll = false;
|
|
174
|
+
} else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
175
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
176
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
177
|
+
} else if (matchesKey(data, "home")) {
|
|
178
|
+
this.scrollOffset = 0;
|
|
179
|
+
this.autoScroll = false;
|
|
180
|
+
} else if (matchesKey(data, "end")) {
|
|
181
|
+
this.scrollOffset = maxScroll;
|
|
182
|
+
this.autoScroll = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
render(width: number): string[] {
|
|
187
|
+
if (width < 6) return [];
|
|
188
|
+
const th = this.theme;
|
|
189
|
+
const innerW = width - 4;
|
|
190
|
+
this.renderedInnerWidth = innerW;
|
|
191
|
+
const lines: string[] = [];
|
|
192
|
+
|
|
193
|
+
const pad = (s: string, len: number): string => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
|
|
194
|
+
const row = (content: string): string =>
|
|
195
|
+
th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
|
|
196
|
+
const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
|
|
197
|
+
const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
|
|
198
|
+
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
199
|
+
|
|
200
|
+
lines.push(hrTop);
|
|
201
|
+
lines.push(row(th.bold("Subagent session")));
|
|
202
|
+
lines.push(hrMid);
|
|
203
|
+
|
|
204
|
+
const totalLines = this.content.lineCount(innerW);
|
|
205
|
+
const viewportHeight = this.viewportHeight();
|
|
206
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
207
|
+
if (this.autoScroll) this.scrollOffset = maxScroll;
|
|
208
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
209
|
+
const visible = this.content.slice(innerW, visibleStart, viewportHeight);
|
|
210
|
+
for (let i = 0; i < viewportHeight; i++) lines.push(row(visible[i] ?? ""));
|
|
211
|
+
|
|
212
|
+
lines.push(hrMid);
|
|
213
|
+
const scrollPct =
|
|
214
|
+
totalLines <= viewportHeight
|
|
215
|
+
? "100%"
|
|
216
|
+
: `${Math.round(((visibleStart + viewportHeight) / totalLines) * 100)}%`;
|
|
217
|
+
const footerLeft = th.fg("dim", `${totalLines} lines · ${scrollPct}`);
|
|
218
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
|
|
219
|
+
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
220
|
+
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
221
|
+
lines.push(hrBot);
|
|
222
|
+
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// fallow-ignore-next-line unused-class-member
|
|
227
|
+
invalidate(): void {
|
|
228
|
+
this.content.invalidate();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
dispose(): void {
|
|
232
|
+
this.closed = true;
|
|
233
|
+
if (this.unsubscribe) {
|
|
234
|
+
this.unsubscribe();
|
|
235
|
+
this.unsubscribe = undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- Private ----
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The width `handleInput` must lay out at: the one the compositor actually
|
|
243
|
+
* supplied, so scroll bounds match the layout on screen. Before the first
|
|
244
|
+
* paint there is none, so fall back to the overlay's share of the terminal.
|
|
245
|
+
*/
|
|
246
|
+
private inputWidth(): number {
|
|
247
|
+
return (
|
|
248
|
+
this.renderedInnerWidth ??
|
|
249
|
+
Math.max(0, Math.floor((this.tui.terminal.columns * OVERLAY_WIDTH_PCT) / 100) - 4)
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private viewportHeight(): number {
|
|
254
|
+
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
255
|
+
return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { AgentEnablementScope, AgentEnablementState } from "#src/config/agent-enablement";
|
|
2
|
+
import type { AgentTypeRegistry } from "#src/config/agent-types";
|
|
3
|
+
import { BUILTIN_TOOL_NAMES } from "#src/config/agent-types";
|
|
4
|
+
import type { AgentConfig } from "#src/types";
|
|
5
|
+
|
|
6
|
+
interface AgentSettings {
|
|
7
|
+
getState(name: string): AgentEnablementState;
|
|
8
|
+
getPath(scope: AgentEnablementScope): string;
|
|
9
|
+
set(scope: AgentEnablementScope, name: string, enabled: boolean | undefined): void | Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SubagentsAgentsUI {
|
|
13
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
14
|
+
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SOURCE_LABELS = { project: "当前项目", global: "全局", default: "默认" } as const;
|
|
18
|
+
const BACK = "返回代理列表";
|
|
19
|
+
const ACTIONS = ["启用", "禁用", "恢复继承"];
|
|
20
|
+
|
|
21
|
+
function stateLabel(enabled: boolean): string {
|
|
22
|
+
return enabled ? "启用" : "禁用";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function overrideLabel(enabled: boolean | undefined): string {
|
|
26
|
+
return enabled === undefined ? "继承" : stateLabel(enabled);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Native dialogs keep keyboard navigation, filtering and terminal sizing in Pi. */
|
|
30
|
+
export class SubagentsAgentsHandler {
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly registry: Pick<AgentTypeRegistry, "getAllTypes" | "resolveAgentConfig">,
|
|
33
|
+
private readonly settings: AgentSettings,
|
|
34
|
+
private readonly refresh: () => void,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
async handle({ ui }: { ui: SubagentsAgentsUI }): Promise<void> {
|
|
38
|
+
while (true) {
|
|
39
|
+
this.refresh();
|
|
40
|
+
const entries = this.registry.getAllTypes().map((name) => {
|
|
41
|
+
const state = this.settings.getState(name);
|
|
42
|
+
return { name, label: `[${stateLabel(state.enabled)}] ${name} · ${SOURCE_LABELS[state.source]}` };
|
|
43
|
+
});
|
|
44
|
+
if (entries.length === 0) {
|
|
45
|
+
ui.notify("没有可管理的子代理。", "info");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const enabled = entries.filter(({ name }) => this.settings.getState(name).enabled).length;
|
|
49
|
+
const choice = await ui.select(
|
|
50
|
+
`子代理(${enabled} 启用 / ${entries.length - enabled} 禁用)`,
|
|
51
|
+
entries.map(({ label }) => label),
|
|
52
|
+
);
|
|
53
|
+
const entry = entries.find(({ label }) => label === choice);
|
|
54
|
+
if (!entry) return;
|
|
55
|
+
await this.manageAgent(entry.name, ui);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async manageAgent(name: string, ui: SubagentsAgentsUI): Promise<void> {
|
|
60
|
+
while (true) {
|
|
61
|
+
this.refresh();
|
|
62
|
+
// 用户可能在菜单打开期间删除定义;不要把回退配置显示成原代理。
|
|
63
|
+
if (!this.registry.getAllTypes().includes(name)) {
|
|
64
|
+
ui.notify(`代理 ${name} 的定义已移除。`, "warning");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const state = this.settings.getState(name);
|
|
68
|
+
const config = this.registry.resolveAgentConfig(name);
|
|
69
|
+
const scopes = [
|
|
70
|
+
{ scope: "project" as const, label: `当前项目(${overrideLabel(state.project)})` },
|
|
71
|
+
{ scope: "global" as const, label: `全局(${overrideLabel(state.global)})` },
|
|
72
|
+
];
|
|
73
|
+
const choice = await ui.select(this.details(config, state), [...scopes.map(({ label }) => label), BACK]);
|
|
74
|
+
const selected = scopes.find(({ label }) => label === choice);
|
|
75
|
+
if (!selected) return;
|
|
76
|
+
await this.changeState(name, selected.scope, ui);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private details(config: AgentConfig, state: AgentEnablementState): string {
|
|
81
|
+
const tools = config.toolNames ?? BUILTIN_TOOL_NAMES;
|
|
82
|
+
return [
|
|
83
|
+
config.displayName ? `${config.displayName} (${config.name})` : config.name,
|
|
84
|
+
config.description,
|
|
85
|
+
`状态:${stateLabel(state.enabled)} · 来源:${SOURCE_LABELS[state.source]}`,
|
|
86
|
+
`模型:${config.model ?? "继承父代理"} · 思考:${config.thinking ?? "继承父代理"}`,
|
|
87
|
+
`工具:${tools.length ? tools.join(", ") : "无"}`,
|
|
88
|
+
`定义:${config.filePath ?? "内置"}`,
|
|
89
|
+
"选择启停设置的作用域:",
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async changeState(name: string, scope: AgentEnablementScope, ui: SubagentsAgentsUI): Promise<void> {
|
|
94
|
+
const choice = await ui.select(
|
|
95
|
+
`${name} · ${SOURCE_LABELS[scope]}\n保存到:${this.settings.getPath(scope)}\n恢复继承:${scope === "project" ? "跟随全局设置" : "回到默认启用"}`,
|
|
96
|
+
ACTIONS,
|
|
97
|
+
);
|
|
98
|
+
if (!choice || !ACTIONS.includes(choice)) return;
|
|
99
|
+
const enabled = choice === "恢复继承" ? undefined : choice === "启用";
|
|
100
|
+
try {
|
|
101
|
+
await this.settings.set(scope, name, enabled);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
ui.notify(`保存失败:${error instanceof Error ? error.message : String(error)}`, "error");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.refresh();
|
|
107
|
+
const state = this.settings.getState(name);
|
|
108
|
+
const overridden = scope === "global" && state.project !== undefined;
|
|
109
|
+
ui.notify(
|
|
110
|
+
`${name}:已在${SOURCE_LABELS[scope]}${choice === "恢复继承" ? "恢复继承" : `设为${choice}`}。` +
|
|
111
|
+
`当前项目${stateLabel(state.enabled)}(${SOURCE_LABELS[state.source]})。` +
|
|
112
|
+
(overridden ? "全局设置被当前项目设置覆盖。" : ""),
|
|
113
|
+
overridden ? "warning" : "info",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|