pi-optchat 0.6.3 → 0.6.4
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 +2 -0
- package/package.json +1 -1
- package/src/agent-view.ts +2 -1
- package/src/agents.ts +1 -1
- package/src/connected-window.ts +102 -17
- package/src/index.ts +20 -2
- package/src/report-message.ts +9 -7
- package/src/title.ts +31 -0
- package/src/window-bridge.ts +51 -9
package/README.md
CHANGED
|
@@ -140,6 +140,7 @@ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/artic
|
|
|
140
140
|
Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent.
|
|
141
141
|
|
|
142
142
|
- Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
|
|
143
|
+
- The window looks like a normal Pi chat: replies, tool calls with their output and running time, the working spinner, and reports from the subagent's own agents in the dark box. Ctrl+O expands tool output.
|
|
143
144
|
- The subagent runs inside the original process, which stays the only writer of memory. It appears in the original window's inspector and uses one of the 8 agent slots. While it is open, the original window can't switch profile or import.
|
|
144
145
|
- The main agent is told when the conversation starts. Use `/tell-main <message>` to message it yourself; the subagent can use `tell_parent`, and the main agent replies with `tell`. Routine turns don't wake the main agent.
|
|
145
146
|
- Run `/complete` when done. The window closes, remaining work stops, and the compactor writes a handoff for the main agent: decisions, changes, evidence, failures, unfinished work, and links to the transcripts.
|
|
@@ -174,6 +175,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
|
|
|
174
175
|
## Good to know
|
|
175
176
|
|
|
176
177
|
- **Profiles separate memory and instructions only.** Agents keep full filesystem access and share provider credentials.
|
|
178
|
+
- **Tab title**: the terminal tab shows the profile and what it is doing: `π personal` while waiting for you, `● π personal` while the agent works, plus `· 2 agents` while subagents run. A connected window shows `↳ personal`, `● ↳ personal`, then `↳ personal · done` or `↳ personal · disconnected`. OptChat replaces Pi's default title and puts its own back when Pi resets it (new session, reload, rename).
|
|
177
179
|
- **Use worktrees** when parallel agents edit the same repository; they share the filesystem.
|
|
178
180
|
- **Instructions**: the main agent and subagents get Pi's usual global and repository `AGENTS.md` files and your skills, followed by the profile's `AGENTS.md`, which comes last and wins. OptChat replaces only Pi's opening prompt. Prompt templates work in the main session only.
|
|
179
181
|
- **Images** are available during the current run but stored in memory as text placeholders.
|
package/package.json
CHANGED
package/src/agent-view.ts
CHANGED
|
@@ -60,7 +60,8 @@ export class TranscriptView {
|
|
|
60
60
|
private streaming?: AssistantMessageComponent;
|
|
61
61
|
private expanded = false;
|
|
62
62
|
constructor(private readonly tui: TUI, private readonly cwd: string, private readonly definition: (name: string) => Renderers = () => undefined) {}
|
|
63
|
-
toggleTools() { this.
|
|
63
|
+
toggleTools() { this.setExpanded(!this.expanded); }
|
|
64
|
+
setExpanded(expanded: boolean) { if (expanded === this.expanded) return; this.expanded = expanded; for (const tool of this.tools.values()) tool.setExpanded(expanded); }
|
|
64
65
|
private tool(id: string, name: string, args: unknown) {
|
|
65
66
|
let tool = this.tools.get(id);
|
|
66
67
|
if (!tool) {
|
package/src/agents.ts
CHANGED
|
@@ -203,7 +203,7 @@ export class Children {
|
|
|
203
203
|
private async execute(live: LiveRun, view: string) {
|
|
204
204
|
const { session, info } = live;
|
|
205
205
|
try {
|
|
206
|
-
if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. Initial
|
|
206
|
+
if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. That agent is handling this request with the user directly; don't do it yourself. Initial message: ${info.task}\n\nUse tell with this agent ID only if you know something it needs. It stays open between replies and sends a final handoff on completion or disconnect.`);
|
|
207
207
|
if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
|
|
208
208
|
await session.prompt(`${view}\n\nYour task:\n${info.task}`);
|
|
209
209
|
while (info.state !== 'stopping') {
|
package/src/connected-window.ts
CHANGED
|
@@ -1,42 +1,127 @@
|
|
|
1
|
-
import { getMarkdownTheme, UserMessageComponent, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { Markdown } from '@earendil-works/pi-tui';
|
|
3
|
-
import {
|
|
1
|
+
import { getMarkdownTheme, UserMessageComponent, type ExtensionAPI, type ExtensionContext, type Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Container, Loader, Markdown, type Component, type TUI } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
4
|
+
import { connectWindow, type LiveState, type WindowEvent } from './window-bridge.ts';
|
|
4
5
|
import { profilePath } from './profiles.ts';
|
|
6
|
+
import { TranscriptView } from './agent-view.ts';
|
|
7
|
+
import { isReport, reportBox } from './report-message.ts';
|
|
8
|
+
import { statusState, windowTitle, type WindowState } from './title.ts';
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
/** `turn` is one finished reply of the agent with the results of its tool calls, drawn like Pi draws its own turns. */
|
|
11
|
+
type Details = { from?: WindowEvent['from']; turn?: AgentMessage[] };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One transcript for the window, shared by the chat and the live widget, so tool calls keep their timings when they settle into the chat.
|
|
15
|
+
* Pi's built-in tools keep their own renderers. Other tools' definitions live in the owner's process, out of reach here, so they get the
|
|
16
|
+
* look Pi gives any registered tool without a renderer (`spawn tasks=[…]`) instead of the raw-JSON look of an unknown one.
|
|
17
|
+
*/
|
|
18
|
+
let transcript: TranscriptView | undefined;
|
|
19
|
+
const transcriptOn = (tui: TUI) => new TranscriptView(tui, process.cwd(), () => ({}));
|
|
20
|
+
const view = (tui: TUI) => transcript ??= transcriptOn(tui);
|
|
21
|
+
/** Turns drawn before this window has a live view (after /reload) still get their tool boxes; there is just no live screen to redraw. */
|
|
22
|
+
let detached: TranscriptView | undefined;
|
|
23
|
+
const NO_SCREEN = { requestRender() {} } as unknown as TUI;
|
|
7
24
|
|
|
8
25
|
/** The conversation itself renders like a normal chat; everything else keeps Pi's boxed custom-message look. */
|
|
9
26
|
export function registerConnectedRenderer(pi: ExtensionAPI) {
|
|
10
|
-
pi.registerMessageRenderer<Details>('optchat-connected', (message, { outputPad }) => {
|
|
27
|
+
pi.registerMessageRenderer<Details>('optchat-connected', (message, { expanded, outputPad }, theme) => {
|
|
11
28
|
const text = typeof message.content === 'string' ? message.content : '';
|
|
12
29
|
if (message.details?.from === 'user') return new UserMessageComponent(text, getMarkdownTheme(), outputPad);
|
|
13
|
-
|
|
30
|
+
const turn = message.details?.turn;
|
|
31
|
+
if (turn) {
|
|
32
|
+
const shown = transcript ?? (detached ??= transcriptOn(NO_SCREEN));
|
|
33
|
+
shown.setExpanded(expanded);
|
|
34
|
+
const container = new Container();
|
|
35
|
+
for (const part of shown.build('', turn)) container.addChild(part);
|
|
36
|
+
return container;
|
|
37
|
+
}
|
|
38
|
+
if (message.details?.from === 'agent') return new Markdown(text.trim(), outputPad, 0, getMarkdownTheme());
|
|
39
|
+
// Reports from the agent's own subagents get the same dark box as in the main window.
|
|
40
|
+
return isReport(text) ? reportBox(text, outputPad, theme) : undefined;
|
|
14
41
|
});
|
|
15
42
|
}
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
44
|
+
const toolCalls = (message: AgentMessage) => message.role === 'assistant' ? message.content.flatMap(part => part.type === 'toolCall' ? [part.id] : []) : [];
|
|
45
|
+
|
|
46
|
+
/** What normally sits at the bottom of Pi's chat while it works: the streaming reply, running tools and the working spinner. */
|
|
47
|
+
class LiveTurn implements Component {
|
|
48
|
+
private readonly loader: Loader;
|
|
49
|
+
private readonly body = new Container();
|
|
50
|
+
private spinning = false;
|
|
51
|
+
turn: AgentMessage[] = [];
|
|
52
|
+
live?: LiveState;
|
|
53
|
+
constructor(private readonly tui: TUI, theme: Theme) {
|
|
54
|
+
this.loader = new Loader(tui, text => theme.fg('accent', text), text => theme.fg('muted', text), 'Working');
|
|
55
|
+
// Running tools show their elapsed time, which only moves when they are redrawn.
|
|
56
|
+
this.timer = setInterval(() => { if (this.live?.tools.length) this.update(); }, 1000); this.timer.unref();
|
|
57
|
+
}
|
|
58
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
59
|
+
update() {
|
|
60
|
+
const streaming = this.live?.streaming;
|
|
61
|
+
const running = new Map((this.live?.tools ?? []).map(t => [t.id, { output: t.output, started: t.started }] as const));
|
|
62
|
+
this.body.clear();
|
|
63
|
+
for (const part of view(this.tui).build('', streaming ? [...this.turn, streaming] : this.turn, streaming, running)) this.body.addChild(part);
|
|
64
|
+
const state = this.live?.state;
|
|
65
|
+
// Waiting with no agents of its own means it is waiting for you, so nothing spins, like an idle Pi.
|
|
66
|
+
const agents = this.live?.agents ?? 0;
|
|
67
|
+
const spin = !!state && statusState(state, agents) === 'working';
|
|
68
|
+
this.loader.setMessage(state === 'waiting' ? `Waiting for ${agents === 1 ? '1 agent' : `${agents} agents`}` : state === 'stopping' ? 'Stopping' : 'Working');
|
|
69
|
+
if (spin && !this.spinning) this.loader.start(); else if (!spin && this.spinning) this.loader.stop();
|
|
70
|
+
this.spinning = spin;
|
|
71
|
+
this.tui.requestRender();
|
|
72
|
+
}
|
|
73
|
+
render(width: number) { return [...this.body.render(width), ...(this.spinning ? this.loader.render(width) : [])]; }
|
|
74
|
+
invalidate() { this.body.invalidate(); this.loader.invalidate(); }
|
|
75
|
+
dispose() { clearInterval(this.timer); this.loader.stop(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContext, profile: string, setTitle: (title: string) => void = title => ctx.ui.setTitle(title)) {
|
|
79
|
+
// Each window draws with its own transcript, so nothing from an earlier window carries over.
|
|
80
|
+
transcript = undefined;
|
|
81
|
+
let started = false, ended = false, liveTurn: LiveTurn | undefined, live: LiveState | undefined, turn: AgentMessage[] = [];
|
|
82
|
+
const title = (state: WindowState) => setTitle(windowTitle(profile, state));
|
|
83
|
+
const display = (text: string, details: Details = {}) => pi.sendMessage<Details>({ customType: 'optchat-connected', content: text, display: true, details }, { triggerTurn: false });
|
|
84
|
+
const refresh = () => { if (liveTurn) { liveTurn.turn = turn; liveTurn.live = live; liveTurn.update(); } };
|
|
85
|
+
/** A reply moves into the chat once all its tool calls have results, as Pi's own chat does when a tool finishes. */
|
|
86
|
+
const commit = () => {
|
|
87
|
+
if (!turn.length) return;
|
|
88
|
+
const [reply] = turn;
|
|
89
|
+
display(reply.role === 'assistant' ? reply.content.flatMap(p => p.type === 'text' ? [p.text] : []).join('\n') : '', { from: 'agent', turn });
|
|
90
|
+
turn = [];
|
|
91
|
+
};
|
|
92
|
+
const settled = () => { const done = new Set(turn.flatMap(m => m.role === 'toolResult' ? [m.toolCallId] : [])); return turn.length > 0 && toolCalls(turn[0]).every(id => done.has(id)); };
|
|
93
|
+
const showLive = () => ctx.ui.setWidget('optchat-connected', (tui, theme) => { view(tui); liveTurn = new LiveTurn(tui, theme); refresh(); return liveTurn; });
|
|
94
|
+
const hideLive = () => { live = undefined; liveTurn = undefined; ctx.ui.setWidget('optchat-connected', undefined); };
|
|
20
95
|
const connection = await connectWindow(profilePath(profile), event => {
|
|
21
96
|
if (event.name === 'started') {
|
|
22
|
-
started = true;
|
|
97
|
+
started = true; showLive(); title('working');
|
|
23
98
|
ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected agent ${event.text} · /complete`);
|
|
24
99
|
} else if (event.name === 'status') {
|
|
25
|
-
|
|
100
|
+
live = event.live; refresh();
|
|
101
|
+
// The tab says working exactly when the spinner spins.
|
|
102
|
+
if (!ended && live) title(statusState(live.state, live.agents));
|
|
103
|
+
} else if (event.message?.role === 'assistant') {
|
|
104
|
+
commit(); turn = [event.message];
|
|
105
|
+
if (settled()) commit();
|
|
106
|
+
refresh();
|
|
107
|
+
} else if (event.message?.role === 'toolResult') {
|
|
108
|
+
if (turn.length) { turn.push(event.message); if (settled()) commit(); }
|
|
109
|
+
refresh();
|
|
26
110
|
} else {
|
|
27
|
-
|
|
111
|
+
commit();
|
|
112
|
+
display(event.text, { from: event.from });
|
|
28
113
|
if (event.name === 'finished') {
|
|
29
|
-
ended = true;
|
|
114
|
+
ended = true; hideLive(); title('done');
|
|
30
115
|
ctx.ui.setStatus('optchat', `OptChat: ${profile} · conversation ended · /complete to exit`);
|
|
31
|
-
}
|
|
116
|
+
} else refresh();
|
|
32
117
|
}
|
|
33
118
|
}, () => {
|
|
34
119
|
if (ended) return;
|
|
35
|
-
ended = true;
|
|
36
|
-
ctx.ui.setStatus('optchat', `OptChat: ${profile} · disconnected`);
|
|
120
|
+
ended = true; commit(); hideLive();
|
|
121
|
+
ctx.ui.setStatus('optchat', `OptChat: ${profile} · disconnected`); title('disconnected');
|
|
37
122
|
ctx.ui.notify('Connection closed. The owner saves the interrupted conversation and handoff; no local agent will run here.', 'info');
|
|
38
123
|
});
|
|
39
|
-
ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected window · send a task to start`);
|
|
124
|
+
ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected window · send a task to start`); title('waiting');
|
|
40
125
|
display(`Connected to ${profile}'s original window. Messages here go to one subagent using the profile's subagent model. /tell-main sends a message to the main agent; /complete ends this conversation and sends a handoff. Closing this window interrupts it.`);
|
|
41
126
|
return {
|
|
42
127
|
async submit(text: string) {
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { inspectorShortcut, mountNavigation } from './navigation.ts';
|
|
|
28
28
|
import { serveWindows } from './window-bridge.ts';
|
|
29
29
|
import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
|
|
30
30
|
import { createHandoffSummarizer } from './handoff.ts';
|
|
31
|
+
import { mainTitle, TabTitle } from './title.ts';
|
|
31
32
|
|
|
32
33
|
const binding = 'optchat.profile';
|
|
33
34
|
interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
@@ -54,6 +55,13 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
54
55
|
let unmountNavigation: (() => void) | undefined;
|
|
55
56
|
let inspectorController: AbortController | undefined;
|
|
56
57
|
const shortcut = inspectorShortcut();
|
|
58
|
+
const title = new TabTitle();
|
|
59
|
+
let working = false;
|
|
60
|
+
let untitle: (() => void) | undefined;
|
|
61
|
+
const showTitle = (ctx: ExtensionContext) => {
|
|
62
|
+
const a = active;
|
|
63
|
+
if (a) title.show(text => ctx.ui.setTitle(text), mainTitle(a.name, working, a.children.ids.length));
|
|
64
|
+
};
|
|
57
65
|
const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
|
|
58
66
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
59
67
|
pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
|
|
@@ -103,6 +111,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
103
111
|
} finally {
|
|
104
112
|
await old.memory.close(); await old.unlock(); active = undefined;
|
|
105
113
|
run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
|
|
114
|
+
untitle?.(); untitle = undefined; title.clear(); working = false;
|
|
106
115
|
}
|
|
107
116
|
};
|
|
108
117
|
const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
|
|
@@ -146,6 +155,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
146
155
|
active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
|
|
147
156
|
if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
|
|
148
157
|
closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
|
|
158
|
+
untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
|
|
149
159
|
status(ctx);
|
|
150
160
|
ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
|
|
151
161
|
const queuedReports = [...reports];
|
|
@@ -153,6 +163,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
153
163
|
setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
|
|
154
164
|
} catch (error) {
|
|
155
165
|
await closeWindows?.(); closeWindows = undefined;
|
|
166
|
+
untitle?.(); untitle = undefined; title.clear();
|
|
156
167
|
if (active && active.memory === openingMemory) {
|
|
157
168
|
unmountNavigation?.(); unmountNavigation = undefined;
|
|
158
169
|
await active.children.close(); active = undefined;
|
|
@@ -175,14 +186,17 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
175
186
|
catch (error) {
|
|
176
187
|
if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
|
|
177
188
|
if (!await ctx.ui.confirm('Profile open in another window', `${error.owner}\nStart a connected subagent conversation here?`)) throw error;
|
|
178
|
-
remote = await openConnectedWindow(pi, ctx, name); fault = undefined;
|
|
189
|
+
remote = await openConnectedWindow(pi, ctx, name, text => title.show(t => ctx.ui.setTitle(t), text)); fault = undefined;
|
|
179
190
|
}
|
|
180
191
|
if (!boundName) pi.appendEntry(binding, { name });
|
|
181
192
|
} catch (error) {
|
|
182
193
|
if (active) await stop().catch(() => {});
|
|
183
194
|
fault = errorText(error); ctx.ui.notify(fault, 'error');
|
|
184
195
|
}
|
|
196
|
+
// Pi sets its own title once every session_start handler has finished, so put ours back afterwards.
|
|
197
|
+
for (const ms of [0, 250, 1000]) setTimeout(() => title.reapply(), ms).unref();
|
|
185
198
|
});
|
|
199
|
+
pi.on('session_info_changed', () => title.reapply()); // Pi retitles the tab on session renames, just before this.
|
|
186
200
|
pi.on('session_shutdown', stop);
|
|
187
201
|
pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
|
|
188
202
|
pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
|
|
@@ -208,7 +222,10 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
208
222
|
pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
|
|
209
223
|
};
|
|
210
224
|
// A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
|
|
211
|
-
pi.on('agent_start', (_event, ctx) => {
|
|
225
|
+
pi.on('agent_start', (_event, ctx) => {
|
|
226
|
+
working = true; showTitle(ctx);
|
|
227
|
+
if (active && !runStarted) startRun(ctx);
|
|
228
|
+
});
|
|
212
229
|
pi.on('before_agent_start', (event, ctx) => {
|
|
213
230
|
startRun(ctx);
|
|
214
231
|
const a = required();
|
|
@@ -274,6 +291,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
274
291
|
try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
|
|
275
292
|
if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
|
|
276
293
|
runStarted = false; status(ctx);
|
|
294
|
+
working = false; showTitle(ctx);
|
|
277
295
|
if (active) {
|
|
278
296
|
const dir = active.dir;
|
|
279
297
|
checkpoints = checkpoints.then(() => checkpoint(dir)).catch(error => ctx.ui.notify(`Local checkpoint failed: ${errorText(error)}`, 'error'));
|
package/src/report-message.ts
CHANGED
|
@@ -21,12 +21,14 @@ function darkBackground(theme: Theme) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Background traffic: a dark neutral box with dim text, so it never looks like something the user typed. */
|
|
24
|
+
export function reportBox(text: string, outputPad: number, theme: Theme) {
|
|
25
|
+
const { label, body } = reportParts(text);
|
|
26
|
+
const box = new Box(outputPad, 1, darkBackground(theme));
|
|
27
|
+
box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
|
|
28
|
+
box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
|
|
29
|
+
return box;
|
|
30
|
+
}
|
|
31
|
+
export const isReport = (text: string) => HEAD.test(text);
|
|
24
32
|
export function registerReportRenderer(pi: ExtensionAPI) {
|
|
25
|
-
pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) =>
|
|
26
|
-
const { label, body } = reportParts(textContent(message.content));
|
|
27
|
-
const box = new Box(outputPad, 1, darkBackground(theme));
|
|
28
|
-
box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
|
|
29
|
-
box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
|
|
30
|
-
return box;
|
|
31
|
-
});
|
|
33
|
+
pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => reportBox(textContent(message.content), outputPad, theme));
|
|
32
34
|
}
|
package/src/title.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Terminal tab titles: `● π personal · 2 agents` in the main window, `● ↳ personal` in a connected window. */
|
|
2
|
+
export type StatusState = 'waiting' | 'working';
|
|
3
|
+
export type WindowState = StatusState | 'done' | 'disconnected';
|
|
4
|
+
|
|
5
|
+
const agents = (count: number) => count > 0 ? ` · ${count} ${count === 1 ? 'agent' : 'agents'}` : '';
|
|
6
|
+
export const mainTitle = (profile: string, working: boolean, running: number) => `${working ? '● ' : ''}π ${profile}${agents(running)}`;
|
|
7
|
+
export const windowTitle = (profile: string, state: WindowState) =>
|
|
8
|
+
`${state === 'working' ? '● ' : ''}↳ ${profile}${state === 'done' || state === 'disconnected' ? ` · ${state}` : ''}`;
|
|
9
|
+
/** A connected window's agent is working while it runs or stops, or waits on agents of its own; waiting with none means waiting for you. */
|
|
10
|
+
export const statusState = (state: string, agents = 0): StatusState =>
|
|
11
|
+
state === 'running' || state === 'stopping' || state === 'waiting' && agents > 0 ? 'working' : 'waiting';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pi writes its own "π - <cwd>" title after binding a session (startup, reload, profile switch) and on
|
|
15
|
+
* session renames, so the last title is kept and re-applied at those points.
|
|
16
|
+
*/
|
|
17
|
+
export class TabTitle {
|
|
18
|
+
private title: string | undefined;
|
|
19
|
+
private set: ((title: string) => void) | undefined;
|
|
20
|
+
show(set: (title: string) => void, title: string) {
|
|
21
|
+
this.set = set;
|
|
22
|
+
if (title === this.title) return;
|
|
23
|
+
this.title = title; this.apply();
|
|
24
|
+
}
|
|
25
|
+
reapply() { this.apply(); }
|
|
26
|
+
clear() { this.title = undefined; this.set = undefined; }
|
|
27
|
+
private apply() {
|
|
28
|
+
if (!this.title || !this.set) return;
|
|
29
|
+
try { this.set(this.title); } catch { /* A ctx captured before a session replacement is stale; the next session sets its own. */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/window-bridge.ts
CHANGED
|
@@ -5,19 +5,36 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
|
5
5
|
import { record } from './cache.ts';
|
|
6
6
|
import { profileSocket } from './profiles.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
8
|
+
import { isActiveRun } from './runs.ts';
|
|
8
9
|
|
|
9
10
|
type Action = 'start' | 'say' | 'tell-main' | 'complete';
|
|
10
11
|
interface Request { kind: 'request'; id: number; action: Action; text?: string; cwd?: string }
|
|
11
12
|
interface Reply { kind: 'reply'; id: number; error?: string }
|
|
12
|
-
/**
|
|
13
|
-
export interface
|
|
13
|
+
/** A tool the subagent is running right now, with its streamed output so far. */
|
|
14
|
+
export interface LiveTool { id: string; name: string; args: unknown; output?: unknown; started: number }
|
|
15
|
+
/** What the subagent is doing between finished messages: its streaming reply and running tools. */
|
|
16
|
+
export interface LiveState { state: string; model: string; streaming?: AgentMessage; tools: LiveTool[]; agents?: number }
|
|
17
|
+
/**
|
|
18
|
+
* `from` marks the conversation itself (your messages and the agent's replies); other messages have no `from`.
|
|
19
|
+
* `message` carries the agent's own messages (replies with tool calls, tool results) so the window can draw them with Pi's components.
|
|
20
|
+
*/
|
|
21
|
+
export interface WindowEvent {
|
|
22
|
+
kind: 'event'; name: 'started' | 'message' | 'status' | 'finished'; text: string; from?: 'user' | 'agent';
|
|
23
|
+
message?: AgentMessage; live?: LiveState;
|
|
24
|
+
}
|
|
14
25
|
type Frame = Request | Reply | WindowEvent;
|
|
15
26
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
27
|
+
const isMessage = (value: unknown): value is AgentMessage => record(value) && (value.role === 'assistant' || value.role === 'toolResult') && Array.isArray(value.content);
|
|
28
|
+
const isLive = (value: unknown): value is LiveState => record(value) && typeof value.state === 'string' && typeof value.model === 'string'
|
|
29
|
+
&& (value.streaming === undefined || isMessage(value.streaming))
|
|
30
|
+
&& Array.isArray(value.tools) && value.tools.every(t => record(t) && typeof t.id === 'string' && typeof t.name === 'string' && typeof t.started === 'number')
|
|
31
|
+
&& (value.agents === undefined || typeof value.agents === 'number');
|
|
16
32
|
function parse(value: unknown): Frame {
|
|
17
33
|
if (!record(value)) throw new Error('Invalid window message');
|
|
18
34
|
const name = value.name, action = value.action, from = value.from;
|
|
19
35
|
if (value.kind === 'event' && (name === 'started' || name === 'message' || name === 'status' || name === 'finished') && typeof value.text === 'string')
|
|
20
|
-
return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined
|
|
36
|
+
return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined,
|
|
37
|
+
message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined };
|
|
21
38
|
if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
|
|
22
39
|
if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string')) return { kind: 'reply', id: value.id, error: value.error };
|
|
23
40
|
if (value.kind === 'request' && (action === 'start' || action === 'say' || action === 'tell-main' || action === 'complete')
|
|
@@ -25,6 +42,23 @@ function parse(value: unknown): Frame {
|
|
|
25
42
|
return { kind: 'request', id: value.id, action, text: value.text, cwd: value.cwd };
|
|
26
43
|
throw new Error('Invalid window message');
|
|
27
44
|
}
|
|
45
|
+
const SHORTENED = '\n[Display shortened; full text is saved in the transcript.]';
|
|
46
|
+
const shorten = (text: string, limit: number) => text.length > limit ? `${text.slice(0, limit)}${SHORTENED}` : text;
|
|
47
|
+
/** Long text parts are cut and images dropped: the window only draws them, the owner keeps the full transcript. */
|
|
48
|
+
function clipContent(content: unknown): unknown {
|
|
49
|
+
if (!Array.isArray(content)) return content;
|
|
50
|
+
return content.map(part => !record(part) ? part : part.type === 'text' && typeof part.text === 'string' ? { ...part, text: shorten(part.text, 50_000) }
|
|
51
|
+
: part.type === 'image' ? { type: 'text', text: '[image]' } : part);
|
|
52
|
+
}
|
|
53
|
+
const clip = (message: AgentMessage) => ({ ...message, content: clipContent('content' in message ? message.content : []) }) as AgentMessage;
|
|
54
|
+
const clipOutput = (output: unknown) => record(output) ? { ...output, content: clipContent(output.content) } : output;
|
|
55
|
+
/**
|
|
56
|
+
* Huge tool arguments (a large file write) would choke the socket; the window then falls back to text.
|
|
57
|
+
* Partial tool output comes from any extension and may not serialize (a cycle, a BigInt); that falls back too
|
|
58
|
+
* instead of throwing inside the status timer, which would take the owner's Pi down with it.
|
|
59
|
+
*/
|
|
60
|
+
const fits = (value: unknown) => { try { return JSON.stringify(value).length < 1_000_000; } catch { return false; } };
|
|
61
|
+
|
|
28
62
|
/** Local JSONL protocol, bounded before parsing; the socket is accessible only by its OS user. */
|
|
29
63
|
function wire(socket: Socket, receive: (frame: Frame) => void) {
|
|
30
64
|
let buffer = '';
|
|
@@ -94,13 +128,15 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
94
128
|
track(queue);
|
|
95
129
|
});
|
|
96
130
|
const drainMessages = (messages: AgentMessage[], task: string) => {
|
|
97
|
-
const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant');
|
|
131
|
+
const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult');
|
|
98
132
|
while (cursor < displayable.length) {
|
|
99
133
|
const message = displayable[cursor++];
|
|
100
|
-
let text = textContent(message.content);
|
|
134
|
+
let text = message.role === 'toolResult' ? '' : textContent(message.content);
|
|
101
135
|
if (message.role === 'user' && firstUser) { text = task; firstUser = false; said.add(task.trim()); }
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
if (message.role === 'user') { if (text) send({ kind: 'event', name: 'message', from: said.has(text.trim()) ? 'user' : undefined, text: shorten(text, 200_000) }); continue; }
|
|
137
|
+
const clipped = clip(message);
|
|
138
|
+
if (fits(clipped)) send({ kind: 'event', name: 'message', from: message.role === 'assistant' ? 'agent' : undefined, text: shorten(text, 200_000), message: clipped });
|
|
139
|
+
else if (text) send({ kind: 'event', name: 'message', from: 'agent', text: shorten(text, 200_000) });
|
|
104
140
|
}
|
|
105
141
|
};
|
|
106
142
|
const timer = setInterval(() => {
|
|
@@ -109,8 +145,14 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
109
145
|
if (!info) return;
|
|
110
146
|
if (live) drainMessages(live.session.messages, info.task);
|
|
111
147
|
const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
|
|
112
|
-
const
|
|
113
|
-
|
|
148
|
+
const text = `${info.state === 'waiting' ? 'Awaiting user or child reports' : info.state} · ${info.model}\n${live ? [...live.tools.values()].map(t => t.name).join(', ') : ''}\n${preview}`;
|
|
149
|
+
let state: LiveState = { state: info.state, model: info.model,
|
|
150
|
+
streaming: live?.streaming?.role === 'assistant' ? clip(live.streaming) : undefined,
|
|
151
|
+
tools: live ? [...live.tools].map(([id, t]) => ({ id, name: t.name, args: t.args, output: clipOutput(t.output), started: t.started })) : [],
|
|
152
|
+
agents: [...children.history.records.values()].filter(run => run.parentId === child && isActiveRun(run)).length };
|
|
153
|
+
if (!fits(state)) state = { ...state, streaming: undefined, tools: state.tools.map(t => ({ ...t, args: {}, output: undefined })) };
|
|
154
|
+
const status = JSON.stringify(state);
|
|
155
|
+
if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text, live: state }); }
|
|
114
156
|
if (!live && info.handoff?.delivered) {
|
|
115
157
|
try { drainMessages(children.messages(child), info.task); }
|
|
116
158
|
catch (error) { send({ kind: 'event', name: 'message', text: `Could not read the final transcript: ${errorText(error)}` }); }
|