pi-sidebar-tui 1.2.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/README.md +121 -0
- package/colors.ts +88 -0
- package/compositor.ts +131 -0
- package/index.ts +590 -0
- package/mcp.ts +82 -0
- package/package.json +39 -0
- package/panels/mcp.ts +31 -0
- package/panels/session.ts +125 -0
- package/panels/subagents.ts +75 -0
- package/panels/todos.ts +55 -0
- package/panels/workspace.ts +69 -0
- package/sidebar.ts +31 -0
- package/types.ts +67 -0
- package/workspace.ts +84 -0
package/index.ts
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TodoItem, SubagentEntry, SidebarContext, McpServerInfo } from "./types.ts";
|
|
3
|
+
import { renderSidebar } from "./sidebar.ts";
|
|
4
|
+
import { getWorkspaceData, invalidateWorkspaceCache } from "./workspace.ts";
|
|
5
|
+
import { SidebarCompositor } from "./compositor.ts";
|
|
6
|
+
import { getMcpServers } from "./mcp.ts";
|
|
7
|
+
import { setPiTheme } from "./colors.ts";
|
|
8
|
+
|
|
9
|
+
const TOOL_LOG_MAX = 10;
|
|
10
|
+
const SUBAGENT_TOOL_PATTERN = /^(task|dispatch|agent)/i;
|
|
11
|
+
const TODO_TOOL_PATTERN = /todo/i;
|
|
12
|
+
const WRITE_TOOLS = new Set(["write", "edit", "bash", "computer"]);
|
|
13
|
+
|
|
14
|
+
let sidebarEnabled = true;
|
|
15
|
+
let sidebarWidth = 40;
|
|
16
|
+
let sessionManager: any = null;
|
|
17
|
+
let sessionTitle: string | null = null;
|
|
18
|
+
let todos: TodoItem[] = [];
|
|
19
|
+
const subagentsMap = new Map<string, SubagentEntry>();
|
|
20
|
+
let activeSubagentId: string | null = null;
|
|
21
|
+
let currentModel: string | null = null;
|
|
22
|
+
let thinkingLevel: string | null = null;
|
|
23
|
+
let contextTokens: number | null = null;
|
|
24
|
+
let contextPercent: number | null = null;
|
|
25
|
+
let contextWindow: number | null = null;
|
|
26
|
+
let tokensIn = 0;
|
|
27
|
+
let tokensOut = 0;
|
|
28
|
+
let cacheRead = 0;
|
|
29
|
+
let cacheWrite = 0;
|
|
30
|
+
let sessionCost = 0;
|
|
31
|
+
let turnCount = 0;
|
|
32
|
+
let activeTool: { name: string; startedAt: number } | null = null;
|
|
33
|
+
let autoCompactEnabled: boolean | null = null;
|
|
34
|
+
let sessionStartMs = Date.now();
|
|
35
|
+
let mcpServers: McpServerInfo[] = [];
|
|
36
|
+
let modelProvider: string | null = null;
|
|
37
|
+
let agentStartMs: number | null = null;
|
|
38
|
+
let msgStartMs: number | null = null;
|
|
39
|
+
let liveTps: number | null = null;
|
|
40
|
+
let lastTps: number | null = null;
|
|
41
|
+
let lastTurnMs: number | null = null;
|
|
42
|
+
let tpsSamples: { t: number; tokens: number }[] = [];
|
|
43
|
+
const TPS_WINDOW_MS = 2000;
|
|
44
|
+
let sessionTimerHandle: ReturnType<typeof setInterval> | null = null;
|
|
45
|
+
|
|
46
|
+
function inferThinkingLevel(sm: any): string | null {
|
|
47
|
+
try {
|
|
48
|
+
const entries: any[] = sm.getBranch?.() ?? [];
|
|
49
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
50
|
+
const e = entries[i];
|
|
51
|
+
if (e?.type === "thinking_level_change" && typeof e.thinkingLevel === "string") {
|
|
52
|
+
return e.thinkingLevel;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FILLER_PREFIX = /^(can you |could you |please |i want you to |i'd like you to |i need you to |help me |i need to |let's |let us )+/i;
|
|
62
|
+
const METHOD_WRAPPER = /^(use|create|run|make|build|write|add|generate|implement|spawn|start)\s+(?:(?:a|an|the|some|my|one)\s+)?(?:\w+\s+){0,3}(?:to|that|which|for)\s+/i;
|
|
63
|
+
|
|
64
|
+
function summarizePrompt(raw: string): string {
|
|
65
|
+
const firstLine = raw.split("\n").map(l => l.trim()).find(l => l.length > 0) ?? raw.trim();
|
|
66
|
+
const noFiller = firstLine.replace(FILLER_PREFIX, "").trim();
|
|
67
|
+
const noWrapper = noFiller.replace(METHOD_WRAPPER, "").trim();
|
|
68
|
+
const title = noWrapper.charAt(0).toUpperCase() + noWrapper.slice(1);
|
|
69
|
+
return title.slice(0, 60);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function generateTitleWithModel(prompt: string, ctx: any, onTitle: (t: string) => void): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
const registry = ctx.modelRegistry;
|
|
75
|
+
if (!registry?.streamSimple) return;
|
|
76
|
+
const modelDesc = ctx.model;
|
|
77
|
+
if (!modelDesc) return;
|
|
78
|
+
const fullModel = (registry.getAll() as any[]).find((m: any) => m.id === modelDesc.id);
|
|
79
|
+
if (!fullModel) return;
|
|
80
|
+
const context = {
|
|
81
|
+
messages: [{
|
|
82
|
+
role: "user" as const,
|
|
83
|
+
content: `Write a 5-7 word task title for this request. Start with an action verb. No punctuation. Output only the title.\n\n${prompt.slice(0, 500)}`,
|
|
84
|
+
}],
|
|
85
|
+
};
|
|
86
|
+
const stream = registry.streamSimple(fullModel, context, { maxTokens: 20, reasoning: "off" });
|
|
87
|
+
const message = await (stream as any).result();
|
|
88
|
+
const text = (message.content as any[]).find((c: any) => c.type === "text")?.text?.trim() ?? "";
|
|
89
|
+
if (text.length > 0 && text.length <= 80) onTitle(text.slice(0, 60));
|
|
90
|
+
} catch {
|
|
91
|
+
// fallback already set
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function inferSessionTitle(sm: any): string | null {
|
|
96
|
+
try {
|
|
97
|
+
const entries: any[] = sm.getBranch?.() ?? [];
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
if (e?.type !== "message") continue;
|
|
100
|
+
const msg = e.message;
|
|
101
|
+
if (msg?.role !== "user") continue;
|
|
102
|
+
const content = msg.content;
|
|
103
|
+
const text = typeof content === "string"
|
|
104
|
+
? content
|
|
105
|
+
: Array.isArray(content)
|
|
106
|
+
? content.find((c: any) => c?.type === "text")?.text ?? ""
|
|
107
|
+
: "";
|
|
108
|
+
if (text.trim()) return summarizePrompt(text);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// ignore
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildSidebarContext(cwd: string | undefined): SidebarContext {
|
|
117
|
+
const ws = getWorkspaceData(cwd);
|
|
118
|
+
return {
|
|
119
|
+
sessionTitle,
|
|
120
|
+
sessionId: sessionManager?.getSessionId?.() ?? null,
|
|
121
|
+
todos,
|
|
122
|
+
subagents: Array.from(subagentsMap.values()),
|
|
123
|
+
branch: ws.branch,
|
|
124
|
+
aheadCount: ws.aheadCount,
|
|
125
|
+
untrackedCount: ws.untrackedCount,
|
|
126
|
+
workspaceFiles: ws.files,
|
|
127
|
+
cwd,
|
|
128
|
+
model: currentModel,
|
|
129
|
+
thinkingLevel,
|
|
130
|
+
contextTokens,
|
|
131
|
+
contextPercent,
|
|
132
|
+
contextWindow,
|
|
133
|
+
tokensIn,
|
|
134
|
+
tokensOut,
|
|
135
|
+
cacheRead,
|
|
136
|
+
cacheWrite,
|
|
137
|
+
sessionCost,
|
|
138
|
+
turnCount,
|
|
139
|
+
activeTool,
|
|
140
|
+
autoCompactEnabled,
|
|
141
|
+
sessionStartMs,
|
|
142
|
+
mcpServers,
|
|
143
|
+
modelProvider,
|
|
144
|
+
liveTps,
|
|
145
|
+
lastTps,
|
|
146
|
+
lastTurnMs,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function updateContextUsage(ctx: any): void {
|
|
151
|
+
try {
|
|
152
|
+
const usage = ctx.getContextUsage?.();
|
|
153
|
+
if (usage) {
|
|
154
|
+
contextTokens = typeof usage.tokens === "number" ? usage.tokens : null;
|
|
155
|
+
contextPercent = typeof usage.percent === "number" ? usage.percent : null;
|
|
156
|
+
contextWindow = typeof usage.contextWindow === "number" ? usage.contextWindow : null;
|
|
157
|
+
}
|
|
158
|
+
const model = ctx.model;
|
|
159
|
+
if (model?.name) currentModel = model.name;
|
|
160
|
+
else if (model?.id) currentModel = model.id;
|
|
161
|
+
modelProvider = (model as any)?.provider ?? (model as any)?.backend ?? null;
|
|
162
|
+
} catch {
|
|
163
|
+
// ignore
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseTodos(input: unknown): TodoItem[] | null {
|
|
168
|
+
if (!input || typeof input !== "object") return null;
|
|
169
|
+
|
|
170
|
+
const obj = input as Record<string, unknown>;
|
|
171
|
+
const raw = obj["todos"] ?? obj["items"] ?? obj["list"] ?? input;
|
|
172
|
+
if (!Array.isArray(raw)) return null;
|
|
173
|
+
|
|
174
|
+
const result: TodoItem[] = [];
|
|
175
|
+
for (const item of raw) {
|
|
176
|
+
if (!item || typeof item !== "object") continue;
|
|
177
|
+
const i = item as Record<string, unknown>;
|
|
178
|
+
const content = typeof i["content"] === "string" ? i["content"] :
|
|
179
|
+
typeof i["text"] === "string" ? i["text"] : null;
|
|
180
|
+
const status = typeof i["status"] === "string" ? i["status"] : "pending";
|
|
181
|
+
const id = typeof i["id"] === "string" ? i["id"] : String(result.length);
|
|
182
|
+
const subAction = typeof i["subAction"] === "string" ? i["subAction"] : undefined;
|
|
183
|
+
if (!content) continue;
|
|
184
|
+
|
|
185
|
+
const normalizedStatus =
|
|
186
|
+
status === "in_progress" || status === "active" ? "in_progress" :
|
|
187
|
+
status === "completed" || status === "done" ? "completed" : "pending";
|
|
188
|
+
|
|
189
|
+
result.push({ id, content, status: normalizedStatus, subAction });
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractSubagentName(input: unknown): string {
|
|
195
|
+
if (!input || typeof input !== "object") return "subagent";
|
|
196
|
+
const obj = input as Record<string, unknown>;
|
|
197
|
+
const name = obj["name"] ?? obj["title"] ?? obj["description"] ?? obj["task"];
|
|
198
|
+
if (typeof name !== "string") return "subagent";
|
|
199
|
+
return name.split("\n")[0].slice(0, 60);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export default function piSidebar(pi: ExtensionAPI) {
|
|
203
|
+
let currentCwd: string | undefined = process.cwd();
|
|
204
|
+
let requestRender: (() => void) | null = null;
|
|
205
|
+
let tuiRef: any = null;
|
|
206
|
+
let compositorRef: SidebarCompositor | null = null;
|
|
207
|
+
|
|
208
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
209
|
+
sessionManager = ctx.sessionManager;
|
|
210
|
+
sessionTitle = ctx.sessionManager.getSessionName() ?? inferSessionTitle(ctx.sessionManager) ?? null;
|
|
211
|
+
todos = [];
|
|
212
|
+
subagentsMap.clear();
|
|
213
|
+
activeSubagentId = null;
|
|
214
|
+
currentModel = null;
|
|
215
|
+
thinkingLevel = null;
|
|
216
|
+
contextTokens = null;
|
|
217
|
+
contextPercent = null;
|
|
218
|
+
contextWindow = null;
|
|
219
|
+
mcpServers = getMcpServers();
|
|
220
|
+
sessionStartMs = Date.now();
|
|
221
|
+
if (sessionTimerHandle) { clearInterval(sessionTimerHandle); sessionTimerHandle = null; }
|
|
222
|
+
sessionTimerHandle = setInterval(() => requestRender?.(), 30_000);
|
|
223
|
+
activeTool = null;
|
|
224
|
+
turnCount = 0;
|
|
225
|
+
autoCompactEnabled = (ctx as any).settingsManager?.getCompactionSettings?.()?.enabled ?? null;
|
|
226
|
+
// Seed usage totals from existing session entries (handles resume)
|
|
227
|
+
{ let inSum = 0, outSum = 0, cacheSum = 0, costSum = 0, turns = 0;
|
|
228
|
+
for (const e of (ctx.sessionManager.getBranch?.() ?? [])) {
|
|
229
|
+
const m = (e as any).message;
|
|
230
|
+
if ((e as any).type !== "message") continue;
|
|
231
|
+
if (m?.role === "user") { turns++; continue; }
|
|
232
|
+
if (m?.role !== "assistant") continue;
|
|
233
|
+
if (m?.stopReason === "error" || m?.stopReason === "aborted") continue;
|
|
234
|
+
inSum += m.usage?.input ?? 0;
|
|
235
|
+
outSum += m.usage?.output ?? 0;
|
|
236
|
+
cacheSum += m.usage?.cacheRead ?? 0;
|
|
237
|
+
cacheWrite += m.usage?.cacheWrite ?? 0;
|
|
238
|
+
costSum += m.usage?.cost?.total ?? 0;
|
|
239
|
+
}
|
|
240
|
+
tokensIn = inSum; tokensOut = outSum; cacheRead = cacheSum; sessionCost = costSum;
|
|
241
|
+
turnCount = turns;
|
|
242
|
+
}
|
|
243
|
+
invalidateWorkspaceCache();
|
|
244
|
+
currentCwd = (ctx as any).cwd;
|
|
245
|
+
updateContextUsage(ctx);
|
|
246
|
+
// Prefer live API; fall back to last thinking_level_change entry in history
|
|
247
|
+
// Session history is authoritative for resumed sessions; fall back to live API
|
|
248
|
+
thinkingLevel = inferThinkingLevel(ctx.sessionManager) ?? pi.getThinkingLevel?.() ?? null;
|
|
249
|
+
|
|
250
|
+
const hasUI = (ctx as any).hasUI;
|
|
251
|
+
if (!hasUI) return;
|
|
252
|
+
|
|
253
|
+
const ui = (ctx as any).ui;
|
|
254
|
+
let renderTick = 0;
|
|
255
|
+
|
|
256
|
+
const scheduleRender = () => {
|
|
257
|
+
const tick = ++renderTick;
|
|
258
|
+
setTimeout(() => {
|
|
259
|
+
if (tick === renderTick) {
|
|
260
|
+
if (compositorRef) {
|
|
261
|
+
compositorRef.paint();
|
|
262
|
+
} else {
|
|
263
|
+
tuiRef?.requestRender();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}, 16);
|
|
267
|
+
};
|
|
268
|
+
const myRender = scheduleRender;
|
|
269
|
+
requestRender = myRender;
|
|
270
|
+
|
|
271
|
+
ui.setWidget("pi-sidebar", (tui: any, _theme: any) => {
|
|
272
|
+
setPiTheme(_theme);
|
|
273
|
+
tuiRef = tui;
|
|
274
|
+
|
|
275
|
+
if (sidebarEnabled) {
|
|
276
|
+
const comp = new SidebarCompositor(
|
|
277
|
+
tui,
|
|
278
|
+
() => buildSidebarContext(currentCwd),
|
|
279
|
+
sidebarWidth,
|
|
280
|
+
);
|
|
281
|
+
comp.install();
|
|
282
|
+
compositorRef = comp;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
dispose() {
|
|
287
|
+
if (requestRender === myRender) { requestRender = null; }
|
|
288
|
+
compositorRef?.dispose();
|
|
289
|
+
compositorRef = null;
|
|
290
|
+
tuiRef = null;
|
|
291
|
+
},
|
|
292
|
+
invalidate() {},
|
|
293
|
+
render(_width: number): string[] { return []; },
|
|
294
|
+
};
|
|
295
|
+
}, { placement: "belowEditor" });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
pi.on("session_info_changed", async (event, _ctx) => {
|
|
299
|
+
const name = (event as any).name;
|
|
300
|
+
sessionTitle = typeof name === "string" ? name : null;
|
|
301
|
+
requestRender?.();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
pi.on("session_shutdown", async () => {
|
|
305
|
+
if (sessionTimerHandle) { clearInterval(sessionTimerHandle); sessionTimerHandle = null; }
|
|
306
|
+
tokensIn = 0;
|
|
307
|
+
tokensOut = 0;
|
|
308
|
+
cacheRead = 0;
|
|
309
|
+
cacheWrite = 0;
|
|
310
|
+
sessionCost = 0;
|
|
311
|
+
turnCount = 0;
|
|
312
|
+
activeTool = null;
|
|
313
|
+
agentStartMs = null;
|
|
314
|
+
msgStartMs = null;
|
|
315
|
+
liveTps = null;
|
|
316
|
+
lastTps = null;
|
|
317
|
+
lastTurnMs = null;
|
|
318
|
+
tpsSamples = [];
|
|
319
|
+
sessionTitle = null;
|
|
320
|
+
todos = [];
|
|
321
|
+
subagentsMap.clear();
|
|
322
|
+
activeSubagentId = null;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
326
|
+
currentCwd = (ctx as any).cwd;
|
|
327
|
+
if (sessionTitle === null && typeof (event as any).prompt === "string") {
|
|
328
|
+
const raw = (event as any).prompt as string;
|
|
329
|
+
sessionTitle = summarizePrompt(raw);
|
|
330
|
+
generateTitleWithModel(raw, ctx, (title) => {
|
|
331
|
+
sessionTitle = title;
|
|
332
|
+
requestRender?.();
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
updateContextUsage(ctx);
|
|
336
|
+
requestRender?.();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
340
|
+
currentCwd = (ctx as any).cwd;
|
|
341
|
+
const toolName = (event as any).toolName ?? "";
|
|
342
|
+
const input = (event as any).input;
|
|
343
|
+
const toolCallId = (event as any).toolCallId ?? toolName;
|
|
344
|
+
|
|
345
|
+
if (TODO_TOOL_PATTERN.test(toolName)) {
|
|
346
|
+
const parsed = parseTodos(input);
|
|
347
|
+
if (parsed !== null) {
|
|
348
|
+
todos = parsed;
|
|
349
|
+
requestRender?.();
|
|
350
|
+
}
|
|
351
|
+
} else if (SUBAGENT_TOOL_PATTERN.test(toolName)) {
|
|
352
|
+
const entry: SubagentEntry = {
|
|
353
|
+
id: toolCallId,
|
|
354
|
+
name: extractSubagentName(input),
|
|
355
|
+
status: "running",
|
|
356
|
+
startedAt: Date.now(),
|
|
357
|
+
turns: 0,
|
|
358
|
+
toolCount: 0,
|
|
359
|
+
tokens: 0,
|
|
360
|
+
toolLog: [],
|
|
361
|
+
};
|
|
362
|
+
subagentsMap.set(toolCallId, entry);
|
|
363
|
+
activeSubagentId = toolCallId;
|
|
364
|
+
requestRender?.();
|
|
365
|
+
} else if (activeSubagentId) {
|
|
366
|
+
const active = subagentsMap.get(activeSubagentId);
|
|
367
|
+
if (active) {
|
|
368
|
+
const inputPreview = typeof input === "string"
|
|
369
|
+
? input.slice(0, 40)
|
|
370
|
+
: typeof input === "object" && input !== null
|
|
371
|
+
? JSON.stringify(input).slice(0, 40)
|
|
372
|
+
: "";
|
|
373
|
+
active.toolLog.push(`${toolName}: ${inputPreview}`);
|
|
374
|
+
if (active.toolLog.length > TOOL_LOG_MAX) {
|
|
375
|
+
active.toolLog.shift();
|
|
376
|
+
}
|
|
377
|
+
active.toolCount++;
|
|
378
|
+
requestRender?.();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
pi.on("tool_result", async (event) => {
|
|
384
|
+
const toolName = (event as any).toolName ?? "";
|
|
385
|
+
const toolCallId = (event as any).toolCallId ?? toolName;
|
|
386
|
+
|
|
387
|
+
if (WRITE_TOOLS.has(toolName.toLowerCase())) {
|
|
388
|
+
invalidateWorkspaceCache();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (subagentsMap.has(toolCallId)) {
|
|
392
|
+
const entry = subagentsMap.get(toolCallId)!;
|
|
393
|
+
entry.status = (event as any).isError ? "failed" : "completed";
|
|
394
|
+
entry.completedAt = Date.now();
|
|
395
|
+
if (activeSubagentId === toolCallId) {
|
|
396
|
+
activeSubagentId = null;
|
|
397
|
+
}
|
|
398
|
+
requestRender?.();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
pi.on("message_start", async (event) => {
|
|
403
|
+
if ((event as any).message?.role === "assistant") {
|
|
404
|
+
msgStartMs = Date.now();
|
|
405
|
+
liveTps = null;
|
|
406
|
+
tpsSamples = [];
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
pi.on("message_update", async (event) => {
|
|
411
|
+
if ((event as any).message?.role !== "assistant") return;
|
|
412
|
+
if (msgStartMs === null) msgStartMs = Date.now();
|
|
413
|
+
const now = Date.now();
|
|
414
|
+
// prefer usage.output; fall back to char count / 4
|
|
415
|
+
const usageOut = (event as any).message?.usage?.output ?? 0;
|
|
416
|
+
const textLen = ((event as any).message?.content as any[] | undefined)
|
|
417
|
+
?.filter((c: any) => c.type === "text")
|
|
418
|
+
.reduce((s: number, c: any) => s + (c.text?.length ?? 0), 0) ?? 0;
|
|
419
|
+
const tokens = usageOut > 0 ? usageOut : Math.round(textLen / 4);
|
|
420
|
+
if (tokens > 0) {
|
|
421
|
+
tpsSamples.push({ t: now, tokens });
|
|
422
|
+
// drop samples outside the window
|
|
423
|
+
while (tpsSamples.length > 1 && now - tpsSamples[0].t > TPS_WINDOW_MS) {
|
|
424
|
+
tpsSamples.shift();
|
|
425
|
+
}
|
|
426
|
+
if (tpsSamples.length >= 2) {
|
|
427
|
+
const oldest = tpsSamples[0];
|
|
428
|
+
const dt = now - oldest.t;
|
|
429
|
+
const dk = tokens - oldest.tokens;
|
|
430
|
+
if (dt > 0 && dk > 0) {
|
|
431
|
+
liveTps = Math.round(dk / (dt / 1000));
|
|
432
|
+
requestRender?.();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
pi.on("message_end", async (event, ctx) => {
|
|
439
|
+
currentCwd = (ctx as any).cwd;
|
|
440
|
+
const usage = (event as any).message?.usage;
|
|
441
|
+
if (usage && (event as any).message?.role === "assistant") {
|
|
442
|
+
const stopReason = (event as any).message?.stopReason;
|
|
443
|
+
if (stopReason !== "error" && stopReason !== "aborted") {
|
|
444
|
+
tokensIn += usage.input ?? 0;
|
|
445
|
+
tokensOut += usage.output ?? 0;
|
|
446
|
+
cacheRead += usage.cacheRead ?? 0;
|
|
447
|
+
cacheWrite += usage.cacheWrite ?? 0;
|
|
448
|
+
sessionCost += usage.cost?.total ?? 0;
|
|
449
|
+
const out = usage.output ?? 0;
|
|
450
|
+
const elapsed = msgStartMs !== null ? Date.now() - msgStartMs : null;
|
|
451
|
+
if (elapsed !== null && elapsed > 50 && out > 0) {
|
|
452
|
+
lastTps = Math.round(out / (elapsed / 1000));
|
|
453
|
+
}
|
|
454
|
+
liveTps = null;
|
|
455
|
+
msgStartMs = null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (activeSubagentId) {
|
|
459
|
+
const active = subagentsMap.get(activeSubagentId);
|
|
460
|
+
if (active) {
|
|
461
|
+
active.turns++;
|
|
462
|
+
if (usage && typeof usage.output === "number") {
|
|
463
|
+
active.tokens += (usage.input ?? 0) + (usage.output ?? 0);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
requestRender?.();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
471
|
+
currentCwd = (ctx as any).cwd;
|
|
472
|
+
updateContextUsage(ctx);
|
|
473
|
+
invalidateWorkspaceCache();
|
|
474
|
+
turnCount++;
|
|
475
|
+
activeTool = null;
|
|
476
|
+
if (agentStartMs !== null) {
|
|
477
|
+
lastTurnMs = Date.now() - agentStartMs;
|
|
478
|
+
agentStartMs = null;
|
|
479
|
+
}
|
|
480
|
+
requestRender?.();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
484
|
+
currentCwd = (ctx as any).cwd;
|
|
485
|
+
updateContextUsage(ctx);
|
|
486
|
+
invalidateWorkspaceCache();
|
|
487
|
+
requestRender?.();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
pi.on("model_select", async (event, ctx) => {
|
|
491
|
+
const m = (event as any).model;
|
|
492
|
+
if (m?.name) currentModel = m.name;
|
|
493
|
+
else if (m?.id) currentModel = m.id;
|
|
494
|
+
msgStartMs = null;
|
|
495
|
+
liveTps = null;
|
|
496
|
+
lastTps = null;
|
|
497
|
+
lastTurnMs = null;
|
|
498
|
+
tpsSamples = [];
|
|
499
|
+
updateContextUsage(ctx);
|
|
500
|
+
requestRender?.();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
504
|
+
currentCwd = (ctx as any).cwd;
|
|
505
|
+
agentStartMs = Date.now();
|
|
506
|
+
msgStartMs = null;
|
|
507
|
+
updateContextUsage(ctx);
|
|
508
|
+
requestRender?.();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
pi.on("tool_execution_start", async (event) => {
|
|
512
|
+
const name = (event as any).toolName ?? "";
|
|
513
|
+
activeTool = { name, startedAt: Date.now() };
|
|
514
|
+
requestRender?.();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
pi.on("tool_execution_end", async () => {
|
|
518
|
+
activeTool = null;
|
|
519
|
+
requestRender?.();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
pi.on("thinking_level_select", async (event) => {
|
|
523
|
+
thinkingLevel = (event as any).level ?? null;
|
|
524
|
+
requestRender?.();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
pi.registerCommand("sidebar-tui", {
|
|
528
|
+
description: "Control sidebar: /sidebar-tui on | off | width <N>",
|
|
529
|
+
handler: async (args, ctx) => {
|
|
530
|
+
currentCwd = (ctx as any).cwd;
|
|
531
|
+
const parts = (args?.trim() ?? "").split(/\s+/);
|
|
532
|
+
const cmd = parts[0];
|
|
533
|
+
|
|
534
|
+
if (cmd === "width") {
|
|
535
|
+
const n = parseInt(parts[1] ?? "", 10);
|
|
536
|
+
if (isNaN(n) || n < 10 || n > 120) {
|
|
537
|
+
(ctx as any).ui?.notify?.("Usage: /sidebar-tui width <10-120>", "warning");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
sidebarWidth = n;
|
|
541
|
+
if (compositorRef && tuiRef) {
|
|
542
|
+
compositorRef.dispose();
|
|
543
|
+
compositorRef = null;
|
|
544
|
+
const comp = new SidebarCompositor(tuiRef, () => buildSidebarContext(currentCwd), sidebarWidth);
|
|
545
|
+
comp.install();
|
|
546
|
+
compositorRef = comp;
|
|
547
|
+
requestRender?.();
|
|
548
|
+
}
|
|
549
|
+
(ctx as any).ui?.notify?.(`Sidebar width set to ${n}`, "info");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (cmd !== "on" && cmd !== "off") {
|
|
554
|
+
(ctx as any).ui?.notify?.("Usage: /sidebar-tui on | off | width <N>", "warning");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
sidebarEnabled = cmd === "on";
|
|
559
|
+
|
|
560
|
+
if (!sidebarEnabled) {
|
|
561
|
+
compositorRef?.dispose();
|
|
562
|
+
compositorRef = null;
|
|
563
|
+
} else if (tuiRef && !compositorRef) {
|
|
564
|
+
const comp = new SidebarCompositor(tuiRef, () => buildSidebarContext(currentCwd), sidebarWidth);
|
|
565
|
+
comp.install();
|
|
566
|
+
compositorRef = comp;
|
|
567
|
+
requestRender?.();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
(ctx as any).ui?.notify?.(
|
|
571
|
+
`Sidebar ${sidebarEnabled ? "enabled" : "disabled"}`,
|
|
572
|
+
"info"
|
|
573
|
+
);
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
pi.registerCommand("session-title", {
|
|
578
|
+
description: "Set session title shown in sidebar",
|
|
579
|
+
handler: async (args, ctx) => {
|
|
580
|
+
const title = args?.trim() ?? "";
|
|
581
|
+
if (!title) {
|
|
582
|
+
(ctx as any).ui?.notify?.("Usage: /session-title <text>", "warning");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
sessionTitle = title;
|
|
586
|
+
requestRender?.();
|
|
587
|
+
(ctx as any).ui?.notify?.(`Session title set: ${title}`, "info");
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
}
|
package/mcp.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface McpServerInfo {
|
|
6
|
+
name: string;
|
|
7
|
+
directCount: number;
|
|
8
|
+
totalCount: number;
|
|
9
|
+
tokenEstimate: number;
|
|
10
|
+
connected: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getAgentDir(): string {
|
|
14
|
+
return process.env["PI_AGENT_DIR"] ?? join(homedir(), ".pi", "agent");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJson(path: string): unknown {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function estimateTokens(tool: { name: string; description?: string; inputSchema?: unknown }): number {
|
|
26
|
+
const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
|
|
27
|
+
const descLen = tool.description?.length ?? 0;
|
|
28
|
+
return Math.ceil((tool.name.length + descLen + schemaLen) / 4) + 10;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getMcpServers(): McpServerInfo[] {
|
|
32
|
+
const agentDir = getAgentDir();
|
|
33
|
+
const config = readJson(join(agentDir, "mcp.json")) as any;
|
|
34
|
+
const cache = readJson(join(agentDir, "mcp-cache.json")) as any;
|
|
35
|
+
|
|
36
|
+
const configuredServers: Record<string, any> = config?.mcpServers ?? {};
|
|
37
|
+
const cachedServers: Record<string, any> = cache?.servers ?? {};
|
|
38
|
+
const globalDirect = config?.settings?.directTools;
|
|
39
|
+
|
|
40
|
+
const names = Object.keys(configuredServers).length > 0
|
|
41
|
+
? Object.keys(configuredServers)
|
|
42
|
+
: Object.keys(cachedServers);
|
|
43
|
+
|
|
44
|
+
if (names.length === 0) return [];
|
|
45
|
+
|
|
46
|
+
return names.map((name) => {
|
|
47
|
+
const definition = configuredServers[name] ?? {};
|
|
48
|
+
const srv = cachedServers[name];
|
|
49
|
+
const tools: any[] = srv?.tools ?? [];
|
|
50
|
+
const connected = tools.length > 0;
|
|
51
|
+
|
|
52
|
+
// Determine directTools filter same way mcp-panel does
|
|
53
|
+
let toolFilter: true | string[] | false = false;
|
|
54
|
+
if (definition.directTools !== undefined) {
|
|
55
|
+
toolFilter = definition.directTools;
|
|
56
|
+
} else if (globalDirect) {
|
|
57
|
+
toolFilter = globalDirect;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const excludeTools: string[] = definition.excludeTools ?? [];
|
|
61
|
+
const prefix = config?.settings?.toolPrefix ?? "server";
|
|
62
|
+
|
|
63
|
+
// Filter and count tools
|
|
64
|
+
let directCount = 0;
|
|
65
|
+
let tokenEstimate = 0;
|
|
66
|
+
let totalCount = 0;
|
|
67
|
+
|
|
68
|
+
for (const tool of tools) {
|
|
69
|
+
// Simple exclude check
|
|
70
|
+
if (excludeTools.includes(tool.name)) continue;
|
|
71
|
+
totalCount++;
|
|
72
|
+
|
|
73
|
+
const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(tool.name));
|
|
74
|
+
if (isDirect) {
|
|
75
|
+
directCount++;
|
|
76
|
+
tokenEstimate += estimateTokens(tool);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { name, directCount, totalCount, tokenEstimate, connected };
|
|
81
|
+
});
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-sidebar-tui",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "OpenCode-style sidebar TUI extension for pi coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"*.ts",
|
|
8
|
+
"panels/**/*.ts"
|
|
9
|
+
],
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi",
|
|
13
|
+
"coding-agent",
|
|
14
|
+
"sidebar",
|
|
15
|
+
"tui"
|
|
16
|
+
],
|
|
17
|
+
"author": "bi0h4z4rd88",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/bi0h4z4rd88/pi-sidebar-tui.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --experimental-strip-types --test 'tests/**/*.test.ts'"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
28
|
+
"@earendil-works/pi-tui": ">=0.74.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
32
|
+
"@earendil-works/pi-tui": ">=0.74.0"
|
|
33
|
+
},
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./index.ts"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|