@ogulcancelik/pi-spar 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/LICENSE +21 -0
- package/README.md +58 -0
- package/core.ts +879 -0
- package/index.ts +760 -0
- package/package.json +41 -0
- package/peek.ts +683 -0
package/peek.ts
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spar Peek - Overlay component for viewing spar sessions
|
|
3
|
+
*
|
|
4
|
+
* Renders spar conversations using the same components as pi's interactive mode,
|
|
5
|
+
* so peek looks like "pi inside pi" — same message styling, same tool rendering,
|
|
6
|
+
* same everything.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
SessionManager,
|
|
12
|
+
getMarkdownTheme,
|
|
13
|
+
AssistantMessageComponent,
|
|
14
|
+
ToolExecutionComponent,
|
|
15
|
+
UserMessageComponent,
|
|
16
|
+
} from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { getModelAlias } from "./core.js";
|
|
18
|
+
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
19
|
+
import {
|
|
20
|
+
Container,
|
|
21
|
+
matchesKey,
|
|
22
|
+
truncateToWidth,
|
|
23
|
+
visibleWidth,
|
|
24
|
+
type TUI,
|
|
25
|
+
} from "@mariozechner/pi-tui";
|
|
26
|
+
import * as fs from "fs";
|
|
27
|
+
import * as net from "net";
|
|
28
|
+
import * as os from "os";
|
|
29
|
+
import * as path from "path";
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Constants
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
const SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "spar", "sessions");
|
|
36
|
+
|
|
37
|
+
function getSocketPath(sessionId: string): string {
|
|
38
|
+
return `/tmp/pi-spar-${sessionId}.sock`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getSessionFile(sessionId: string): string {
|
|
42
|
+
return path.join(SESSION_DIR, `${sessionId}.jsonl`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Session Helpers
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
export interface PeekableSession {
|
|
50
|
+
name: string;
|
|
51
|
+
active: boolean;
|
|
52
|
+
messageCount: number;
|
|
53
|
+
model: string;
|
|
54
|
+
lastActivity: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function listPeekableSessions(): PeekableSession[] {
|
|
58
|
+
if (!fs.existsSync(SESSION_DIR)) return [];
|
|
59
|
+
|
|
60
|
+
const sessions: PeekableSession[] = [];
|
|
61
|
+
|
|
62
|
+
for (const f of fs.readdirSync(SESSION_DIR)) {
|
|
63
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
64
|
+
const name = f.replace(".jsonl", "");
|
|
65
|
+
const active = fs.existsSync(getSocketPath(name));
|
|
66
|
+
|
|
67
|
+
let messageCount = 0;
|
|
68
|
+
let model = "";
|
|
69
|
+
let lastActivity = 0;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const infoPath = path.join(SESSION_DIR, `${name}.info.json`);
|
|
73
|
+
if (fs.existsSync(infoPath)) {
|
|
74
|
+
const info = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
|
|
75
|
+
messageCount = info.messageCount ?? 0;
|
|
76
|
+
lastActivity = info.lastActivity ?? info.createdAt ?? 0;
|
|
77
|
+
const fullModel = info.model || info.modelId || "";
|
|
78
|
+
model = getModelAlias(fullModel) || fullModel.split(":").pop()?.slice(0, 8) || "?";
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
|
|
82
|
+
sessions.push({ name, active, messageCount, model, lastActivity });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
sessions.sort((a, b) => {
|
|
86
|
+
if (a.active !== b.active) return a.active ? -1 : 1;
|
|
87
|
+
return b.lastActivity - a.lastActivity;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return sessions;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function formatAge(timestamp: number): string {
|
|
94
|
+
if (!timestamp) return "";
|
|
95
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
96
|
+
if (seconds < 60) return `${seconds}s`;
|
|
97
|
+
const minutes = Math.floor(seconds / 60);
|
|
98
|
+
if (minutes < 60) return `${minutes}m`;
|
|
99
|
+
const hours = Math.floor(minutes / 60);
|
|
100
|
+
if (hours < 24) return `${hours}h`;
|
|
101
|
+
const days = Math.floor(hours / 24);
|
|
102
|
+
return `${days}d`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function sessionExists(name: string): boolean {
|
|
106
|
+
return fs.existsSync(getSessionFile(name));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isSessionActive(name: string): boolean {
|
|
110
|
+
return fs.existsSync(getSocketPath(name));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function findRecentSession(sessionManager: any): string | null {
|
|
114
|
+
try {
|
|
115
|
+
const entries = sessionManager.getEntries();
|
|
116
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
117
|
+
const entry = entries[i];
|
|
118
|
+
if (entry.type === "message") {
|
|
119
|
+
const msg = (entry as any).message;
|
|
120
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
121
|
+
for (const c of msg.content) {
|
|
122
|
+
if (c.type === "toolCall" && c.name === "spar" && c.arguments?.session) {
|
|
123
|
+
return c.arguments.session;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function findActiveSession(): string | null {
|
|
134
|
+
try {
|
|
135
|
+
const sockets = fs.readdirSync("/tmp").filter(f => f.startsWith("pi-spar-") && f.endsWith(".sock"));
|
|
136
|
+
if (sockets.length > 0) {
|
|
137
|
+
return sockets[0].replace("pi-spar-", "").replace(".sock", "");
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// Peek Overlay — "pi inside pi"
|
|
145
|
+
//
|
|
146
|
+
// Uses the same UserMessageComponent, AssistantMessageComponent, and
|
|
147
|
+
// ToolExecutionComponent that pi's interactive mode uses, wrapped in a
|
|
148
|
+
// scrollable bordered overlay.
|
|
149
|
+
// =============================================================================
|
|
150
|
+
|
|
151
|
+
export class SparPeekOverlay {
|
|
152
|
+
private tui: TUI;
|
|
153
|
+
private theme: Theme;
|
|
154
|
+
private done: () => void;
|
|
155
|
+
private sessionId: string;
|
|
156
|
+
private sessionFile: string;
|
|
157
|
+
private modelName: string = "";
|
|
158
|
+
|
|
159
|
+
// Session state
|
|
160
|
+
private sm: SessionManager | null = null;
|
|
161
|
+
private lastFileSize: number = 0;
|
|
162
|
+
|
|
163
|
+
// UI state — the inner chat container holds the real pi components
|
|
164
|
+
private chatContainer: Container;
|
|
165
|
+
private scrollOffset = 0;
|
|
166
|
+
private followMode = true;
|
|
167
|
+
|
|
168
|
+
// Streaming state (from socket)
|
|
169
|
+
private socket: net.Socket | null = null;
|
|
170
|
+
private socketBuffer: string = "";
|
|
171
|
+
private status: "thinking" | "streaming" | "tool" | "done" = "done";
|
|
172
|
+
private toolName?: string;
|
|
173
|
+
|
|
174
|
+
// Streaming components — mirrors interactive-mode's approach
|
|
175
|
+
private streamingComponent: AssistantMessageComponent | null = null;
|
|
176
|
+
private streamingMessage: AssistantMessage | null = null;
|
|
177
|
+
private pendingTools = new Map<string, ToolExecutionComponent>();
|
|
178
|
+
|
|
179
|
+
// Polling
|
|
180
|
+
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
181
|
+
|
|
182
|
+
// Render cache
|
|
183
|
+
private cachedLines: string[] | null = null;
|
|
184
|
+
private cachedWidth: number | null = null;
|
|
185
|
+
|
|
186
|
+
constructor(tui: TUI, theme: Theme, sessionId: string, done: () => void) {
|
|
187
|
+
this.tui = tui;
|
|
188
|
+
this.theme = theme;
|
|
189
|
+
this.sessionId = sessionId;
|
|
190
|
+
this.sessionFile = getSessionFile(sessionId);
|
|
191
|
+
this.done = done;
|
|
192
|
+
|
|
193
|
+
this.chatContainer = new Container();
|
|
194
|
+
|
|
195
|
+
this.loadModelName();
|
|
196
|
+
this.loadSession();
|
|
197
|
+
this.rebuildChat();
|
|
198
|
+
this.connectSocket();
|
|
199
|
+
this.pollInterval = setInterval(() => this.poll(), 200);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// =========================================================================
|
|
203
|
+
// Session loading
|
|
204
|
+
// =========================================================================
|
|
205
|
+
|
|
206
|
+
private loadModelName(): void {
|
|
207
|
+
try {
|
|
208
|
+
const infoPath = path.join(SESSION_DIR, `${this.sessionId}.info.json`);
|
|
209
|
+
if (fs.existsSync(infoPath)) {
|
|
210
|
+
const info = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
|
|
211
|
+
const fullModel = info.model || info.modelId || "";
|
|
212
|
+
this.modelName = getModelAlias(fullModel) || fullModel.split(":").pop()?.slice(0, 12) || "";
|
|
213
|
+
}
|
|
214
|
+
} catch {}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private loadSession(): void {
|
|
218
|
+
try {
|
|
219
|
+
if (fs.existsSync(this.sessionFile)) {
|
|
220
|
+
this.sm = SessionManager.open(this.sessionFile);
|
|
221
|
+
const stats = fs.statSync(this.sessionFile);
|
|
222
|
+
this.lastFileSize = stats.size;
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
this.sm = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// =========================================================================
|
|
230
|
+
// Chat rebuild — uses real pi components
|
|
231
|
+
// =========================================================================
|
|
232
|
+
|
|
233
|
+
private rebuildChat(): void {
|
|
234
|
+
this.cachedLines = null;
|
|
235
|
+
this.cachedWidth = null;
|
|
236
|
+
this.chatContainer.clear();
|
|
237
|
+
this.pendingTools.clear();
|
|
238
|
+
|
|
239
|
+
if (!this.sm) return;
|
|
240
|
+
|
|
241
|
+
const context = this.sm.buildSessionContext();
|
|
242
|
+
|
|
243
|
+
for (const message of context.messages) {
|
|
244
|
+
if (message.role === "user") {
|
|
245
|
+
const text = this.getUserText(message);
|
|
246
|
+
if (text) {
|
|
247
|
+
this.chatContainer.addChild(
|
|
248
|
+
new UserMessageComponent(text, getMarkdownTheme()),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
} else if (message.role === "assistant") {
|
|
252
|
+
this.chatContainer.addChild(
|
|
253
|
+
new AssistantMessageComponent(message, false, getMarkdownTheme()),
|
|
254
|
+
);
|
|
255
|
+
// Render tool call components (same pattern as interactive-mode)
|
|
256
|
+
for (const content of message.content) {
|
|
257
|
+
if (content.type === "toolCall") {
|
|
258
|
+
const component = new ToolExecutionComponent(
|
|
259
|
+
content.name,
|
|
260
|
+
content.arguments,
|
|
261
|
+
{},
|
|
262
|
+
undefined, // no custom tool definitions for spar peers
|
|
263
|
+
this.tui,
|
|
264
|
+
);
|
|
265
|
+
this.chatContainer.addChild(component);
|
|
266
|
+
|
|
267
|
+
// Handle aborted/error assistant messages — mark tools as failed
|
|
268
|
+
// instead of leaving them pending (same as interactive-mode)
|
|
269
|
+
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
|
270
|
+
const errorMessage = message.errorMessage || (
|
|
271
|
+
message.stopReason === "aborted" ? "Operation aborted" : "Error"
|
|
272
|
+
);
|
|
273
|
+
component.updateResult({
|
|
274
|
+
content: [{ type: "text", text: errorMessage }],
|
|
275
|
+
isError: true,
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
this.pendingTools.set(content.id, component);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} else if (message.role === "toolResult") {
|
|
283
|
+
const component = this.pendingTools.get(message.toolCallId);
|
|
284
|
+
if (component) {
|
|
285
|
+
component.updateResult(message);
|
|
286
|
+
this.pendingTools.delete(message.toolCallId);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
this.pendingTools.clear();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private getUserText(message: any): string {
|
|
295
|
+
const content = message.content;
|
|
296
|
+
if (typeof content === "string") return content;
|
|
297
|
+
if (Array.isArray(content)) {
|
|
298
|
+
return content
|
|
299
|
+
.filter((c: any) => c.type === "text" && c.text)
|
|
300
|
+
.map((c: any) => c.text)
|
|
301
|
+
.join("\n");
|
|
302
|
+
}
|
|
303
|
+
return "";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// =========================================================================
|
|
307
|
+
// Socket connection — live streaming from active spar
|
|
308
|
+
// =========================================================================
|
|
309
|
+
|
|
310
|
+
private connectSocket(): void {
|
|
311
|
+
const socketPath = getSocketPath(this.sessionId);
|
|
312
|
+
if (!fs.existsSync(socketPath)) return;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
this.socket = net.connect(socketPath);
|
|
316
|
+
this.socketBuffer = "";
|
|
317
|
+
this.status = "thinking";
|
|
318
|
+
|
|
319
|
+
this.socket.on("error", () => {
|
|
320
|
+
this.socket = null;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
this.socket.on("close", () => {
|
|
324
|
+
this.socket = null;
|
|
325
|
+
this.status = "done";
|
|
326
|
+
this.cleanupStreaming();
|
|
327
|
+
this.loadSession();
|
|
328
|
+
this.rebuildChat();
|
|
329
|
+
this.tui.requestRender();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
this.socket.on("data", (data) => {
|
|
333
|
+
this.socketBuffer += data.toString();
|
|
334
|
+
const lines = this.socketBuffer.split("\n");
|
|
335
|
+
// Last element is either empty (if data ended with \n) or an incomplete line
|
|
336
|
+
this.socketBuffer = lines.pop() ?? "";
|
|
337
|
+
for (const line of lines) {
|
|
338
|
+
if (!line.trim()) continue;
|
|
339
|
+
try {
|
|
340
|
+
const event = JSON.parse(line);
|
|
341
|
+
this.handleEvent(event);
|
|
342
|
+
} catch {}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
this.socket = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private handleEvent(event: any): void {
|
|
351
|
+
if (event.type === "sync") {
|
|
352
|
+
// Sync event on connect — rebuild from file, then apply streaming state
|
|
353
|
+
this.loadSession();
|
|
354
|
+
this.rebuildChat();
|
|
355
|
+
this.status = event.status || "thinking";
|
|
356
|
+
this.toolName = event.toolName;
|
|
357
|
+
|
|
358
|
+
// If there's a partial message, use it directly (faithful reconstruction)
|
|
359
|
+
if (event.partialMessage) {
|
|
360
|
+
this.streamingMessage = event.partialMessage;
|
|
361
|
+
this.streamingComponent = new AssistantMessageComponent(
|
|
362
|
+
undefined, false, getMarkdownTheme(),
|
|
363
|
+
);
|
|
364
|
+
this.chatContainer.addChild(this.streamingComponent);
|
|
365
|
+
this.streamingComponent.updateContent(this.streamingMessage);
|
|
366
|
+
// Restore any tool components from the partial message
|
|
367
|
+
this.syncToolComponentsFromMessage();
|
|
368
|
+
} else if (event.thinking || event.text) {
|
|
369
|
+
// Fallback for older core.ts that doesn't send partialMessage
|
|
370
|
+
this.ensureStreamingComponent();
|
|
371
|
+
if (this.streamingMessage) {
|
|
372
|
+
if (event.thinking) {
|
|
373
|
+
this.streamingMessage.content.push({
|
|
374
|
+
type: "thinking", thinking: event.thinking,
|
|
375
|
+
} as any);
|
|
376
|
+
}
|
|
377
|
+
if (event.text) {
|
|
378
|
+
this.streamingMessage.content.push({
|
|
379
|
+
type: "text", text: event.text,
|
|
380
|
+
} as any);
|
|
381
|
+
}
|
|
382
|
+
this.streamingComponent?.updateContent(this.streamingMessage);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} else if (event.type === "message_start") {
|
|
386
|
+
// New assistant message — create streaming component (same as interactive-mode)
|
|
387
|
+
if (event.message?.role === "assistant") {
|
|
388
|
+
this.cleanupStreaming();
|
|
389
|
+
this.streamingMessage = event.message;
|
|
390
|
+
this.streamingComponent = new AssistantMessageComponent(
|
|
391
|
+
undefined, false, getMarkdownTheme(),
|
|
392
|
+
);
|
|
393
|
+
this.chatContainer.addChild(this.streamingComponent);
|
|
394
|
+
this.streamingComponent.updateContent(this.streamingMessage);
|
|
395
|
+
this.status = "thinking";
|
|
396
|
+
}
|
|
397
|
+
} else if (event.type === "message_update") {
|
|
398
|
+
if (event.message?.role === "assistant") {
|
|
399
|
+
// Use the full partial message from the event — same as interactive-mode
|
|
400
|
+
this.ensureStreamingComponent();
|
|
401
|
+
this.streamingMessage = event.message;
|
|
402
|
+
this.streamingComponent!.updateContent(this.streamingMessage);
|
|
403
|
+
|
|
404
|
+
// Update status from the delta type
|
|
405
|
+
const delta = event.assistantMessageEvent;
|
|
406
|
+
if (delta?.type === "thinking_delta") {
|
|
407
|
+
this.status = "thinking";
|
|
408
|
+
} else if (delta?.type === "text_delta") {
|
|
409
|
+
this.status = "streaming";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Create/update tool components from the message content
|
|
413
|
+
// (same pattern as interactive-mode lines 2163-2179)
|
|
414
|
+
this.syncToolComponentsFromMessage();
|
|
415
|
+
}
|
|
416
|
+
} else if (event.type === "message_end") {
|
|
417
|
+
if (this.streamingComponent && this.streamingMessage) {
|
|
418
|
+
if (event.message?.role === "assistant") {
|
|
419
|
+
this.streamingMessage = event.message;
|
|
420
|
+
this.streamingComponent.updateContent(this.streamingMessage);
|
|
421
|
+
|
|
422
|
+
// Handle aborted/error — mark pending tools as failed
|
|
423
|
+
if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
|
|
424
|
+
const errorMessage = this.streamingMessage.errorMessage || "Error";
|
|
425
|
+
for (const [, component] of this.pendingTools) {
|
|
426
|
+
component.updateResult({
|
|
427
|
+
content: [{ type: "text", text: errorMessage }],
|
|
428
|
+
isError: true,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
this.pendingTools.clear();
|
|
432
|
+
} else {
|
|
433
|
+
// Args are complete — trigger diff computation for edit tools
|
|
434
|
+
for (const [, component] of this.pendingTools) {
|
|
435
|
+
component.setArgsComplete();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
this.streamingComponent = null;
|
|
440
|
+
this.streamingMessage = null;
|
|
441
|
+
}
|
|
442
|
+
} else if (event.type === "tool_execution_start") {
|
|
443
|
+
this.status = "tool";
|
|
444
|
+
this.toolName = event.toolName;
|
|
445
|
+
if (event.toolCallId && !this.pendingTools.has(event.toolCallId)) {
|
|
446
|
+
const component = new ToolExecutionComponent(
|
|
447
|
+
event.toolName, event.args, {}, undefined, this.tui,
|
|
448
|
+
);
|
|
449
|
+
this.chatContainer.addChild(component);
|
|
450
|
+
this.pendingTools.set(event.toolCallId, component);
|
|
451
|
+
}
|
|
452
|
+
} else if (event.type === "tool_execution_update") {
|
|
453
|
+
if (event.toolCallId) {
|
|
454
|
+
const component = this.pendingTools.get(event.toolCallId);
|
|
455
|
+
if (component && event.partialResult) {
|
|
456
|
+
component.updateResult({ ...event.partialResult, isError: false }, true);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} else if (event.type === "tool_execution_end") {
|
|
460
|
+
if (event.toolCallId) {
|
|
461
|
+
const component = this.pendingTools.get(event.toolCallId);
|
|
462
|
+
if (component) {
|
|
463
|
+
component.updateResult({ ...event.result, isError: event.isError ?? false });
|
|
464
|
+
this.pendingTools.delete(event.toolCallId);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} else if (event.type === "agent_end") {
|
|
468
|
+
this.cleanupStreaming();
|
|
469
|
+
this.loadSession();
|
|
470
|
+
this.rebuildChat();
|
|
471
|
+
this.status = "done";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
this.invalidateCache();
|
|
475
|
+
if (this.followMode) this.scrollOffset = 999999;
|
|
476
|
+
this.tui.requestRender();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Walk streamingMessage.content and create/update ToolExecutionComponents
|
|
481
|
+
* for any tool calls. Same pattern as interactive-mode lines 2163-2179.
|
|
482
|
+
*/
|
|
483
|
+
private syncToolComponentsFromMessage(): void {
|
|
484
|
+
if (!this.streamingMessage) return;
|
|
485
|
+
for (const content of this.streamingMessage.content) {
|
|
486
|
+
if (content.type === "toolCall") {
|
|
487
|
+
if (!this.pendingTools.has(content.id)) {
|
|
488
|
+
const component = new ToolExecutionComponent(
|
|
489
|
+
content.name, content.arguments, {}, undefined, this.tui,
|
|
490
|
+
);
|
|
491
|
+
this.chatContainer.addChild(component);
|
|
492
|
+
this.pendingTools.set(content.id, component);
|
|
493
|
+
} else {
|
|
494
|
+
const component = this.pendingTools.get(content.id)!;
|
|
495
|
+
component.updateArgs(content.arguments);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// =========================================================================
|
|
502
|
+
// Streaming helpers — mirrors interactive-mode's streaming approach
|
|
503
|
+
// =========================================================================
|
|
504
|
+
|
|
505
|
+
private ensureStreamingComponent(): void {
|
|
506
|
+
if (this.streamingComponent) return;
|
|
507
|
+
|
|
508
|
+
this.streamingMessage = {
|
|
509
|
+
role: "assistant",
|
|
510
|
+
content: [],
|
|
511
|
+
api: "" as any,
|
|
512
|
+
provider: "" as any,
|
|
513
|
+
model: "",
|
|
514
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
515
|
+
stopReason: "stop" as any,
|
|
516
|
+
timestamp: Date.now(),
|
|
517
|
+
};
|
|
518
|
+
this.streamingComponent = new AssistantMessageComponent(
|
|
519
|
+
undefined,
|
|
520
|
+
false,
|
|
521
|
+
getMarkdownTheme(),
|
|
522
|
+
);
|
|
523
|
+
this.chatContainer.addChild(this.streamingComponent);
|
|
524
|
+
this.streamingComponent.updateContent(this.streamingMessage);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private cleanupStreaming(): void {
|
|
528
|
+
if (this.streamingComponent) {
|
|
529
|
+
this.chatContainer.removeChild(this.streamingComponent);
|
|
530
|
+
this.streamingComponent = null;
|
|
531
|
+
this.streamingMessage = null;
|
|
532
|
+
}
|
|
533
|
+
this.pendingTools.clear();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// =========================================================================
|
|
537
|
+
// Polling
|
|
538
|
+
// =========================================================================
|
|
539
|
+
|
|
540
|
+
private poll(): void {
|
|
541
|
+
if (!this.socket && isSessionActive(this.sessionId)) {
|
|
542
|
+
this.connectSocket();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const stats = fs.statSync(this.sessionFile);
|
|
547
|
+
if (stats.size !== this.lastFileSize) {
|
|
548
|
+
this.loadSession();
|
|
549
|
+
// Only rebuild if we're not actively streaming
|
|
550
|
+
if (!this.streamingComponent) {
|
|
551
|
+
this.rebuildChat();
|
|
552
|
+
}
|
|
553
|
+
this.invalidateCache();
|
|
554
|
+
if (this.followMode) this.scrollOffset = 999999;
|
|
555
|
+
this.tui.requestRender();
|
|
556
|
+
}
|
|
557
|
+
} catch {}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// =========================================================================
|
|
561
|
+
// Input handling
|
|
562
|
+
// =========================================================================
|
|
563
|
+
|
|
564
|
+
handleInput(data: string): void {
|
|
565
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
566
|
+
this.dispose();
|
|
567
|
+
this.done();
|
|
568
|
+
} else if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
569
|
+
this.followMode = false;
|
|
570
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
571
|
+
this.tui.requestRender();
|
|
572
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
573
|
+
this.scrollOffset++;
|
|
574
|
+
this.tui.requestRender();
|
|
575
|
+
} else if (matchesKey(data, "pageup") || matchesKey(data, "ctrl+u")) {
|
|
576
|
+
this.followMode = false;
|
|
577
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 15);
|
|
578
|
+
this.tui.requestRender();
|
|
579
|
+
} else if (matchesKey(data, "pagedown") || matchesKey(data, "ctrl+d")) {
|
|
580
|
+
this.scrollOffset += 15;
|
|
581
|
+
this.tui.requestRender();
|
|
582
|
+
} else if (data === "g") {
|
|
583
|
+
this.followMode = false;
|
|
584
|
+
this.scrollOffset = 0;
|
|
585
|
+
this.tui.requestRender();
|
|
586
|
+
} else if (data === "G") {
|
|
587
|
+
this.followMode = true;
|
|
588
|
+
this.scrollOffset = 999999;
|
|
589
|
+
this.tui.requestRender();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// =========================================================================
|
|
594
|
+
// Rendering — bordered chrome around the real pi chat container
|
|
595
|
+
// =========================================================================
|
|
596
|
+
|
|
597
|
+
private invalidateCache(): void {
|
|
598
|
+
this.cachedLines = null;
|
|
599
|
+
this.cachedWidth = null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
invalidate(): void {
|
|
603
|
+
this.chatContainer.invalidate();
|
|
604
|
+
this.invalidateCache();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
render(width: number): string[] {
|
|
608
|
+
const th = this.theme;
|
|
609
|
+
const innerW = Math.max(20, width - 2);
|
|
610
|
+
|
|
611
|
+
// ── Header ──
|
|
612
|
+
const title = ` ${this.sessionId} `;
|
|
613
|
+
const modelTag = this.modelName ? `[${this.modelName}] ` : "";
|
|
614
|
+
const statusIcon = { thinking: "◐", streaming: "●", tool: "◑", done: "✓" }[this.status] || "○";
|
|
615
|
+
const statusColor = { thinking: "warning", streaming: "success", tool: "accent", done: "success" }[this.status] || "muted";
|
|
616
|
+
const statusText = ` ${statusIcon} ${this.status} `;
|
|
617
|
+
const headerContent = title + modelTag;
|
|
618
|
+
const headerPad = Math.max(0, innerW - visibleWidth(headerContent) - visibleWidth(statusText));
|
|
619
|
+
|
|
620
|
+
const lines: string[] = [];
|
|
621
|
+
lines.push(
|
|
622
|
+
th.fg("border", "╭") +
|
|
623
|
+
th.fg("accent", title) +
|
|
624
|
+
th.fg("dim", modelTag) +
|
|
625
|
+
th.fg("border", "─".repeat(headerPad)) +
|
|
626
|
+
th.fg(statusColor as any, statusText) +
|
|
627
|
+
th.fg("border", "╮"),
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// ── Content — rendered by the real pi components ──
|
|
631
|
+
let contentLines: string[];
|
|
632
|
+
if (this.cachedLines && this.cachedWidth === innerW) {
|
|
633
|
+
contentLines = this.cachedLines;
|
|
634
|
+
} else {
|
|
635
|
+
contentLines = this.chatContainer.render(innerW);
|
|
636
|
+
this.cachedLines = contentLines;
|
|
637
|
+
this.cachedWidth = innerW;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ── Scrolling ──
|
|
641
|
+
const termRows = this.tui.terminal.rows;
|
|
642
|
+
const maxHeight = Math.min(60, termRows - 4);
|
|
643
|
+
const chromeLines = 4; // header + separator + footer + bottom border
|
|
644
|
+
const maxVisible = Math.max(10, maxHeight - chromeLines);
|
|
645
|
+
const maxScroll = Math.max(0, contentLines.length - maxVisible);
|
|
646
|
+
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
|
647
|
+
|
|
648
|
+
const visible = contentLines.slice(this.scrollOffset, this.scrollOffset + maxVisible);
|
|
649
|
+
for (const line of visible) {
|
|
650
|
+
const padded = line + " ".repeat(Math.max(0, innerW - visibleWidth(line)));
|
|
651
|
+
lines.push(th.fg("border", "│") + truncateToWidth(padded, innerW) + th.fg("border", "│"));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── Footer ──
|
|
655
|
+
const scrollInfo = contentLines.length > maxVisible
|
|
656
|
+
? `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisible, contentLines.length)}/${contentLines.length}`
|
|
657
|
+
: `${contentLines.length}L`;
|
|
658
|
+
const followIcon = this.followMode ? th.fg("success", "●") : th.fg("dim", "○");
|
|
659
|
+
|
|
660
|
+
lines.push(th.fg("border", "├" + "─".repeat(innerW) + "┤"));
|
|
661
|
+
const footer = ` ${scrollInfo} ${followIcon} │ j/k scroll │ g/G top/end │ q close `;
|
|
662
|
+
const footerPad = " ".repeat(Math.max(0, innerW - visibleWidth(footer)));
|
|
663
|
+
lines.push(th.fg("border", "│") + th.fg("dim", footer) + footerPad + th.fg("border", "│"));
|
|
664
|
+
lines.push(th.fg("border", "╰" + "─".repeat(innerW) + "╯"));
|
|
665
|
+
|
|
666
|
+
return lines;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// =========================================================================
|
|
670
|
+
// Cleanup
|
|
671
|
+
// =========================================================================
|
|
672
|
+
|
|
673
|
+
dispose(): void {
|
|
674
|
+
if (this.pollInterval) {
|
|
675
|
+
clearInterval(this.pollInterval);
|
|
676
|
+
this.pollInterval = null;
|
|
677
|
+
}
|
|
678
|
+
if (this.socket) {
|
|
679
|
+
this.socket.end();
|
|
680
|
+
this.socket = null;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|