@signalridge/pi-subagents 1.5.0 → 1.7.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 +44 -0
- package/README.md +7 -4
- package/package.json +7 -6
- package/src/agent-color.ts +188 -0
- package/src/agent-file-toggle.ts +8 -0
- package/src/agent-manager.ts +793 -62
- package/src/agent-runner.ts +313 -21
- package/src/agent-tiers.ts +82 -3
- package/src/agent-types.ts +3 -0
- package/src/ask-tools.ts +114 -0
- package/src/cross-extension-rpc.ts +16 -5
- package/src/custom-agents.ts +67 -2
- package/src/default-agents.ts +6 -5
- package/src/gate.ts +0 -0
- package/src/index.ts +3165 -1195
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/output-file.ts +23 -1
- package/src/settings.ts +159 -3
- package/src/supervisor.ts +115 -0
- package/src/types.ts +59 -2
- package/src/ui/agent-mention.ts +163 -0
- package/src/ui/conversation-viewer.ts +5 -4
- package/src/ui/fleet-list.ts +9 -7
- package/src/worktree.ts +128 -648
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supervisor.ts — `contact_supervisor`, the child→parent direction.
|
|
3
|
+
*
|
|
4
|
+
* `steer_subagent` sends guidance downward. Nothing sent anything upward: a
|
|
5
|
+
* subagent that hit a genuine fork in the road could only guess, finish wrong,
|
|
6
|
+
* and have the guess discovered later in its result.
|
|
7
|
+
*
|
|
8
|
+
* The answer comes from the HUMAN, not from a supervising model. Our subagents
|
|
9
|
+
* share the parent's `ExtensionContext`, so the parent's UI is directly
|
|
10
|
+
* reachable — asking the person who is sitting there is both cheaper and more
|
|
11
|
+
* correct than delegating the judgement to another model, and it is the same
|
|
12
|
+
* reason this package has no LLM arbitrator anywhere else.
|
|
13
|
+
*
|
|
14
|
+
* That reachability is also why this is ~80 lines rather than a filesystem
|
|
15
|
+
* channel with a poller on each end: in-process, the question is one promise.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Type } from "@sinclair/typebox";
|
|
20
|
+
import { sanitizeDisplayText, truncateCodePoints } from "./ui/safe-text.js";
|
|
21
|
+
|
|
22
|
+
/** Longest question a child may put to the parent, in characters. */
|
|
23
|
+
const MAX_QUESTION = 2_000;
|
|
24
|
+
/** Longest option label offered in a choice prompt. */
|
|
25
|
+
const MAX_OPTION = 200;
|
|
26
|
+
/** Most options a child may offer, so the picker stays usable. */
|
|
27
|
+
const MAX_OPTIONS = 8;
|
|
28
|
+
|
|
29
|
+
export interface SupervisorAsk {
|
|
30
|
+
/** Prompt the human with a free-text question; undefined = dismissed. */
|
|
31
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
32
|
+
/** Prompt the human to pick one option; undefined = dismissed. */
|
|
33
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SupervisorToolContext {
|
|
37
|
+
/** The parent's UI. Omitted when there is no human to ask. */
|
|
38
|
+
ask?: SupervisorAsk;
|
|
39
|
+
/** Display name of the asking agent, for the prompt title. */
|
|
40
|
+
agentLabel: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function textResult(text: string, isError = false) {
|
|
44
|
+
return { content: [{ type: "text" as const, text }], isError, details: {} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the `contact_supervisor` tool, or nothing when no human can answer.
|
|
49
|
+
*
|
|
50
|
+
* Returning an empty array rather than a tool that always fails is deliberate,
|
|
51
|
+
* and matches how nested tools are handled: injecting a tool whose every call
|
|
52
|
+
* is an error spends context to teach the model an affordance it does not have.
|
|
53
|
+
* Headless and RPC sessions therefore see no such tool at all.
|
|
54
|
+
*/
|
|
55
|
+
export function createSupervisorTool(context: SupervisorToolContext) {
|
|
56
|
+
if (!context.ask) return [];
|
|
57
|
+
const ask = context.ask;
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
defineTool({
|
|
61
|
+
name: "contact_supervisor",
|
|
62
|
+
label: "Ask Supervisor",
|
|
63
|
+
description:
|
|
64
|
+
"Ask the human who started you a question and wait for their answer. Use ONLY for a decision you cannot make yourself and cannot defer: an ambiguous requirement where the readings lead to materially different work, a destructive step that needs confirmation, or missing information nothing available to you can supply. It interrupts a person, so prefer stating an assumption and continuing. Returns their answer, or tells you they declined — in which case proceed on your best judgement and say what you assumed.",
|
|
65
|
+
parameters: Type.Object({
|
|
66
|
+
question: Type.String({
|
|
67
|
+
description: "The question, self-contained. The human cannot see your conversation.",
|
|
68
|
+
maxLength: MAX_QUESTION,
|
|
69
|
+
}),
|
|
70
|
+
options: Type.Optional(
|
|
71
|
+
Type.Array(Type.String({ maxLength: MAX_OPTION }), {
|
|
72
|
+
description: `Offer up to ${MAX_OPTIONS} concrete choices instead of free text. Prefer this when the answers are known.`,
|
|
73
|
+
maxItems: MAX_OPTIONS,
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
}),
|
|
77
|
+
execute: async (_toolCallId, params) => {
|
|
78
|
+
// Both the question and any option labels are model-authored text about
|
|
79
|
+
// to be drawn into the user's terminal, so they are sanitized and
|
|
80
|
+
// bounded here — the same treatment any other child-supplied string
|
|
81
|
+
// gets before it reaches a UI surface.
|
|
82
|
+
const question = truncateCodePoints(sanitizeDisplayText(params.question), MAX_QUESTION, "…").trim();
|
|
83
|
+
if (!question) return textResult("Ask a non-empty question.", true);
|
|
84
|
+
|
|
85
|
+
const title = `${context.agentLabel} asks`;
|
|
86
|
+
const options = (params.options ?? [])
|
|
87
|
+
.map((option) => truncateCodePoints(sanitizeDisplayText(option), MAX_OPTION, "…").trim())
|
|
88
|
+
.filter((option) => option.length > 0)
|
|
89
|
+
.slice(0, MAX_OPTIONS);
|
|
90
|
+
|
|
91
|
+
let answer: string | undefined;
|
|
92
|
+
try {
|
|
93
|
+
answer =
|
|
94
|
+
options.length > 0
|
|
95
|
+
? await ask.select(`${title}: ${question}`, options)
|
|
96
|
+
: await ask.input(title, question);
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
// A dialog that cannot open must not fail the child's whole run; it
|
|
99
|
+
// is told nobody answered and carries on under its own judgement.
|
|
100
|
+
return textResult(
|
|
101
|
+
`Could not reach the supervisor (${error instanceof Error ? error.message : String(error)}). Proceed on your best judgement and state the assumption you made.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const reply = answer?.trim();
|
|
106
|
+
if (!reply) {
|
|
107
|
+
return textResult(
|
|
108
|
+
"The supervisor did not answer. Proceed on your best judgement and state the assumption you made.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return textResult(`Supervisor answered: ${reply}`);
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
];
|
|
115
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -30,13 +30,32 @@ export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as con
|
|
|
30
30
|
/** Memory scope for persistent agent memory. */
|
|
31
31
|
export type MemoryScope = "user" | "project" | "local";
|
|
32
32
|
|
|
33
|
-
/** Isolation mode for agent execution. */
|
|
34
|
-
export type IsolationMode = "worktree";
|
|
33
|
+
/** Isolation mode for agent execution. "off" explicitly opts out of worktree isolation. */
|
|
34
|
+
export type IsolationMode = "worktree" | "off";
|
|
35
35
|
|
|
36
36
|
/** Unified agent configuration — used for both default and user-defined agents. */
|
|
37
37
|
export interface AgentConfig {
|
|
38
38
|
name: string;
|
|
39
39
|
displayName?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Badge colour for this agent, as a Claude Code palette name or `#RRGGBB`.
|
|
42
|
+
* Anything else renders without a badge rather than failing the load — a
|
|
43
|
+
* colour typo must not cost the user their agent.
|
|
44
|
+
*/
|
|
45
|
+
color?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Tools whose every call needs the user to agree first (`ask_tools:`). The
|
|
48
|
+
* third answer between `tools:` and `disallowed_tools:`, for tools that are
|
|
49
|
+
* usually fine and occasionally not. Approval comes from the human, never
|
|
50
|
+
* from a model — see `ask-tools.ts`.
|
|
51
|
+
*/
|
|
52
|
+
askTools?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Shell command run by the HOST after this agent finishes, whose pass/fail is
|
|
55
|
+
* appended to its result (`gate:`). The one acceptance signal the agent
|
|
56
|
+
* cannot author — see `gate.ts`.
|
|
57
|
+
*/
|
|
58
|
+
gate?: string;
|
|
40
59
|
description: string;
|
|
41
60
|
builtinToolNames?: string[];
|
|
42
61
|
/** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
|
|
@@ -69,6 +88,14 @@ export interface AgentConfig {
|
|
|
69
88
|
/** Programmatic-only, for the same reason as `model` above. */
|
|
70
89
|
thinking?: ThinkingLevel;
|
|
71
90
|
maxTurns?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Token budget for one run of this agent (`max_tokens`). `0`/omitted =
|
|
93
|
+
* unlimited, matching `maxTurns`. Bounds what a single turn can spend, which
|
|
94
|
+
* a turn count cannot: one turn can burn an arbitrary number of tokens.
|
|
95
|
+
*/
|
|
96
|
+
maxTokens?: number;
|
|
97
|
+
/** Tool-call budget for one run of this agent (`max_tool_calls`). `0`/omitted = unlimited. */
|
|
98
|
+
maxToolCalls?: number;
|
|
72
99
|
/** Persist this subagent as a normal pi session instead of keeping it in memory only. */
|
|
73
100
|
persistSession?: boolean;
|
|
74
101
|
/** Write the subagent's .output transcript. Defaults to true; false suppresses only that transcript. */
|
|
@@ -133,6 +160,17 @@ export interface AgentRecord {
|
|
|
133
160
|
outputFile?: string;
|
|
134
161
|
/** Cleanup function for the output file stream subscription. */
|
|
135
162
|
outputCleanup?: () => void;
|
|
163
|
+
/**
|
|
164
|
+
* Session file path when the agent's conversation is persisted to disk
|
|
165
|
+
* (session_file in the agent definition or pi's session persistence). Set at
|
|
166
|
+
* spawn so an evicted record can be reopened as a resumable entry.
|
|
167
|
+
*/
|
|
168
|
+
sessionFile?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Resumable mention handle, when the agent was addressable by `@handle`.
|
|
171
|
+
* Only top-level agents carry one.
|
|
172
|
+
*/
|
|
173
|
+
handle?: string;
|
|
136
174
|
/**
|
|
137
175
|
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
|
|
138
176
|
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
|
|
@@ -179,6 +217,25 @@ export interface AgentRecord {
|
|
|
179
217
|
detached?: boolean;
|
|
180
218
|
}
|
|
181
219
|
|
|
220
|
+
/**
|
|
221
|
+
* What survives a record's eviction so `@handle` keeps working. The live record
|
|
222
|
+
* is discarded after the cleanup timer, but the pi session it wrote is still on
|
|
223
|
+
* disk, and this is the little that is needed to find and describe it again.
|
|
224
|
+
*
|
|
225
|
+
* Named `ResumableAgentEntry` (not "tombstone") because this codebase already
|
|
226
|
+
* uses `ManagedSpawnTombstone` for the managed-spawn idempotency record — two
|
|
227
|
+
* different concepts must not share a name.
|
|
228
|
+
*/
|
|
229
|
+
export interface ResumableAgentEntry {
|
|
230
|
+
handle: string;
|
|
231
|
+
id: string;
|
|
232
|
+
type: SubagentType;
|
|
233
|
+
description: string;
|
|
234
|
+
/** Always set — a record with no session file is never indexed. */
|
|
235
|
+
sessionFile: string;
|
|
236
|
+
completedAt: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
182
239
|
export interface AgentInvocation {
|
|
183
240
|
/** Short display name, e.g. "haiku" — only set when different from parent. */
|
|
184
241
|
modelName?: string;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-mention.ts — what `@` can address, and the suggestions pi renders for it.
|
|
3
|
+
*
|
|
4
|
+
* A subagent is addressable whether or not it is currently running: a live
|
|
5
|
+
* record is messaged or resumed, an evicted one whose session is still on disk
|
|
6
|
+
* is reopened, and an agent *type* with no instance at all is started. That is
|
|
7
|
+
* the point of the handle — `@explore` means the Explore agent, not "the
|
|
8
|
+
* Explore process that happens to exist right now" — so the roster below unions
|
|
9
|
+
* all three, and the dispatcher and the popup read the same list.
|
|
10
|
+
*
|
|
11
|
+
* pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
|
|
12
|
+
* file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
|
|
13
|
+
* provider answers the `@` tokens that name an agent and delegates every other
|
|
14
|
+
* one — including all of `applyCompletion`, whose `@`-branch already inserts
|
|
15
|
+
* `item.value` plus a trailing space, which is exactly what a handle needs.
|
|
16
|
+
*
|
|
17
|
+
* Matching is case-insensitive prefix (not fuzzy), and when any agent matches,
|
|
18
|
+
* files are dropped from the list rather than mixed in — an `@name` that names
|
|
19
|
+
* an agent is never also a path.
|
|
20
|
+
*
|
|
21
|
+
* Every string that reaches a row passes through `sanitizeDisplayText` first:
|
|
22
|
+
* an agent description comes from a `.pi/agents/*.md` file that may not be
|
|
23
|
+
* trustworthy, and the popup draws into the user's terminal.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
|
|
27
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
28
|
+
import { handleBase, MENTION_TRIGGER } from "../mention.js";
|
|
29
|
+
import type { AgentRecordSnapshot, ResumableAgentEntry } from "../types.js";
|
|
30
|
+
import { sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One thing `@` can address, and what sending to it will do. `typeLabel` is the
|
|
34
|
+
* agent's `display_name`, resolved by the caller: this module stays independent
|
|
35
|
+
* of the type registry, but the popup must agree with FleetView and the widget,
|
|
36
|
+
* which both render the label rather than the raw type.
|
|
37
|
+
*/
|
|
38
|
+
export type MentionTarget =
|
|
39
|
+
| { kind: "record"; handle: string; record: AgentRecordSnapshot; typeLabel: string }
|
|
40
|
+
| { kind: "resumable"; handle: string; entry: ResumableAgentEntry; typeLabel: string }
|
|
41
|
+
| { kind: "type"; handle: string; type: string; description: string };
|
|
42
|
+
|
|
43
|
+
/** The registry facts the roster needs, so it stays independent of agent-types. */
|
|
44
|
+
export type TypeInfo = { name: string; description: string };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Everything `@` can reach, in the order the popup lists it: steerable agents
|
|
48
|
+
* first, then the other live ones earliest-launched, then evicted conversations
|
|
49
|
+
* that can be reopened, then agent types with no live instance. A type whose
|
|
50
|
+
* handle a record already holds is omitted — that name addresses the existing
|
|
51
|
+
* agent, which is what makes `@explore` mean "message the one that's running"
|
|
52
|
+
* and only otherwise "start one".
|
|
53
|
+
*/
|
|
54
|
+
export function mentionRoster(
|
|
55
|
+
manager: AgentManager,
|
|
56
|
+
types: readonly TypeInfo[],
|
|
57
|
+
// Identity by default: a caller with no registry to consult gets the raw
|
|
58
|
+
// type, which is also what the config lookup falls back to when no label is set.
|
|
59
|
+
displayNameOf: (type: string) => string = (type) => type,
|
|
60
|
+
): MentionTarget[] {
|
|
61
|
+
const isLive = (r: AgentRecordSnapshot) => r.status === "running" || r.status === "queued";
|
|
62
|
+
const records = manager
|
|
63
|
+
.listAgents()
|
|
64
|
+
.filter((r) => r.handle !== undefined && r.parentAgentId === undefined)
|
|
65
|
+
.sort((a, b) => Number(isLive(b)) - Number(isLive(a)) || a.startedAt - b.startedAt);
|
|
66
|
+
|
|
67
|
+
const taken = new Set<string>();
|
|
68
|
+
const targets: MentionTarget[] = [];
|
|
69
|
+
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
const handle = record.handle as string;
|
|
72
|
+
taken.add(handle.toLowerCase());
|
|
73
|
+
targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Then agents that are gone but whose conversation can be reopened. After the
|
|
77
|
+
// live ones: a running agent is the likelier target, and this keeps the
|
|
78
|
+
// ordering "what exists now, then what can be brought back, then what can be
|
|
79
|
+
// started".
|
|
80
|
+
for (const entry of manager.listResumable()) {
|
|
81
|
+
if (taken.has(entry.handle.toLowerCase())) continue;
|
|
82
|
+
taken.add(entry.handle.toLowerCase());
|
|
83
|
+
targets.push({ kind: "resumable", handle: entry.handle, entry, typeLabel: displayNameOf(entry.type) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const type of types) {
|
|
87
|
+
const handle = handleBase(type.name);
|
|
88
|
+
if (taken.has(handle)) continue;
|
|
89
|
+
taken.add(handle);
|
|
90
|
+
targets.push({ kind: "type", handle, type: type.name, description: type.description });
|
|
91
|
+
}
|
|
92
|
+
return targets;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createMentionProvider(
|
|
96
|
+
current: AutocompleteProvider,
|
|
97
|
+
roster: () => MentionTarget[],
|
|
98
|
+
isEnabled: () => boolean,
|
|
99
|
+
): AutocompleteProvider {
|
|
100
|
+
return {
|
|
101
|
+
// Only `@` — the contract is "characters that should naturally trigger THIS
|
|
102
|
+
// provider", and pi unions each wrapper's own set onto the outermost one
|
|
103
|
+
// itself, so re-declaring the wrapped provider's characters here would both
|
|
104
|
+
// misreport us and duplicate that.
|
|
105
|
+
triggerCharacters: ["@"],
|
|
106
|
+
|
|
107
|
+
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
|
|
108
|
+
const items = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null;
|
|
109
|
+
if (items) return items;
|
|
110
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
114
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
118
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
|
|
124
|
+
export function mentionItems(
|
|
125
|
+
roster: readonly MentionTarget[],
|
|
126
|
+
line: string,
|
|
127
|
+
cursorCol: number,
|
|
128
|
+
): AutocompleteSuggestions | null {
|
|
129
|
+
const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol));
|
|
130
|
+
if (!match) return null;
|
|
131
|
+
|
|
132
|
+
const typed = match[2].toLowerCase();
|
|
133
|
+
const items: AutocompleteItem[] = [];
|
|
134
|
+
for (const target of roster) {
|
|
135
|
+
if (!target.handle.toLowerCase().startsWith(typed)) continue;
|
|
136
|
+
items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) });
|
|
137
|
+
}
|
|
138
|
+
return items.length > 0 ? { items, prefix: `@${match[2]}` } : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Name the action that will actually happen, so the list never mispromises. */
|
|
142
|
+
function describeTarget(target: MentionTarget): string {
|
|
143
|
+
if (target.kind === "type") return `start agent · ${summarize(target.description)}`;
|
|
144
|
+
if (target.kind === "resumable") {
|
|
145
|
+
// No status: the record is gone, and "completed" would imply one is still
|
|
146
|
+
// being tracked. The type carries the identity the handle may not.
|
|
147
|
+
return `resume · ${sanitizeDisplayText(target.typeLabel)} · ${summarize(target.entry.description)}`;
|
|
148
|
+
}
|
|
149
|
+
const { status, description } = target.record;
|
|
150
|
+
const action = status === "running" || status === "queued" ? "send message" : "resume";
|
|
151
|
+
return `${action} · ${status} · ${summarize(description)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* First sentence of a description, sanitized then clipped — agent descriptions
|
|
156
|
+
* run to paragraphs, and they come from files this extension did not write.
|
|
157
|
+
* Sanitize BEFORE truncating: cutting first can sever an escape sequence and
|
|
158
|
+
* leave a live introducer behind.
|
|
159
|
+
*/
|
|
160
|
+
function summarize(description: string): string {
|
|
161
|
+
const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim();
|
|
162
|
+
return truncateCodePoints(sanitizeDisplayText(first), 60, "…");
|
|
163
|
+
}
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
|
+
import { renderAgentName } from "../agent-color.js";
|
|
10
11
|
import { extractText } from "../context.js";
|
|
11
12
|
import type { AgentRecordSnapshot } from "../types.js";
|
|
12
13
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
13
14
|
import type { Theme } from "./agent-display.js";
|
|
14
|
-
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens,
|
|
15
|
+
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
|
|
15
16
|
import { PREVIEW_SCAN_LIMIT, safeTerminalText, sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
|
|
16
17
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
17
18
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
@@ -182,12 +183,12 @@ export class ConversationViewer implements Component {
|
|
|
182
183
|
// agent's conversation ends and the parent's resumes. An inner rule would be
|
|
183
184
|
// a third horizontal line competing with the two that mark the boundary, and
|
|
184
185
|
// a four-sided box would cost two columns on every row for the same job.
|
|
185
|
-
const rule = () => th.fg("
|
|
186
|
+
const rule = () => th.fg("borderAccent", "─".repeat(Math.max(0, width)));
|
|
186
187
|
const hrMid = row("");
|
|
187
188
|
|
|
188
189
|
lines.push(rule());
|
|
189
190
|
lines.push(row(th.bold("Agent conversation")));
|
|
190
|
-
const name =
|
|
191
|
+
const name = renderAgentName(this.record.type, th, { bold: true });
|
|
191
192
|
const modeLabel = getPromptModeLabel(this.record.type);
|
|
192
193
|
const modeTag = modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined;
|
|
193
194
|
const statusText = getAgentStatusLabel(this.record.status);
|
|
@@ -205,7 +206,7 @@ export class ConversationViewer implements Component {
|
|
|
205
206
|
|
|
206
207
|
const headerParts = [
|
|
207
208
|
th.fg(statusColor, `${getAgentStatusMark(this.record.status)} ${statusText}`),
|
|
208
|
-
|
|
209
|
+
name,
|
|
209
210
|
modeTag,
|
|
210
211
|
th.fg("muted", sanitizeDisplayText(this.record.description)),
|
|
211
212
|
fgPreservingNestedStyles(th, "dim", headerStats.join(" · ")),
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { Editor, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { borderedComponent } from "@signalridge/pi-ui";
|
|
16
|
+
import { renderAgentName } from "../agent-color.js";
|
|
15
17
|
import type { AgentManager } from "../agent-manager.js";
|
|
16
18
|
import type { AgentRecord } from "../types.js";
|
|
17
19
|
import { getLifetimeTotal } from "../usage.js";
|
|
18
|
-
import { type AgentActivity,
|
|
20
|
+
import { type AgentActivity, type Theme } from "./agent-display.js";
|
|
19
21
|
import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
|
|
20
22
|
import { sanitizeDisplayText } from "./safe-text.js";
|
|
21
23
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
@@ -301,7 +303,7 @@ export class FleetList {
|
|
|
301
303
|
void this.ui.custom<undefined>(
|
|
302
304
|
(tui, theme, keybindings, done) => {
|
|
303
305
|
this.viewerClose = () => done(undefined);
|
|
304
|
-
return new ConversationViewer(
|
|
306
|
+
return borderedComponent(new ConversationViewer(
|
|
305
307
|
tui,
|
|
306
308
|
session,
|
|
307
309
|
record,
|
|
@@ -314,7 +316,7 @@ export class FleetList {
|
|
|
314
316
|
keybindings,
|
|
315
317
|
(message: string) => this.manager.steer(record.id, message),
|
|
316
318
|
() => this.manager.getRecord?.(record.id),
|
|
317
|
-
);
|
|
319
|
+
), (text) => theme.fg("borderAccent", text));
|
|
318
320
|
},
|
|
319
321
|
{
|
|
320
322
|
overlay: true,
|
|
@@ -379,11 +381,11 @@ export class FleetList {
|
|
|
379
381
|
|
|
380
382
|
private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
|
|
381
383
|
const selected = rosterIndex === sel;
|
|
382
|
-
const name = getDisplayName(record.type);
|
|
383
384
|
const status = getAgentStatusLabel(record.status);
|
|
384
|
-
const nameText =
|
|
385
|
-
?
|
|
386
|
-
:
|
|
385
|
+
const nameText = renderAgentName(record.type, theme, {
|
|
386
|
+
fallbackColor: selected ? "accent" : "muted",
|
|
387
|
+
bold: selected,
|
|
388
|
+
});
|
|
387
389
|
const description = sanitizeDisplayText(record.description);
|
|
388
390
|
const descriptionText = selected ? theme.fg("accent", description) : description;
|
|
389
391
|
const semanticStatusColor = getAgentStatusColor(record.status);
|