takomi 2.1.30 → 2.1.31
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/.pi/extensions/notify-sound/index.ts +262 -262
- package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +1 -1
- package/.pi/extensions/takomi-context-manager/index.ts +1 -1
- package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +1 -1
- package/.pi/extensions/takomi-context-manager/policy-tools.ts +1 -1
- package/.pi/extensions/takomi-context-manager/prerequisite-gates.ts +1 -1
- package/.pi/extensions/takomi-context-manager/skill-tools.ts +1 -1
- package/.pi/extensions/takomi-runtime/commands.ts +1 -1
- package/.pi/extensions/takomi-runtime/context-panel.ts +708 -708
- package/.pi/extensions/takomi-runtime/index.ts +2 -2
- package/.pi/extensions/takomi-runtime/shared.ts +1 -1
- package/.pi/extensions/takomi-runtime/subagent-controller.ts +1 -1
- package/.pi/extensions/takomi-runtime/subagent-render.ts +1 -1
- package/.pi/extensions/takomi-runtime/subagent-types.ts +11 -11
- package/.pi/extensions/takomi-runtime/ui.ts +2 -2
- package/.pi/extensions/takomi-subagents/agents.ts +1 -1
- package/.pi/extensions/takomi-subagents/index.ts +1 -1
- package/.pi/extensions/takomi-subagents/native-render.ts +3 -3
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +2 -2
- package/.pi/extensions/takomi-subagents/tool-runner.ts +1 -1
- package/package.json +5 -9
- package/src/doctor.js +1 -1
- package/src/pi-harness.js +0 -3
|
@@ -1,708 +1,708 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Takomi Context Panel - right-side overlay showing session context.
|
|
3
|
-
*
|
|
4
|
-
* Tracks changed files, tool usage, and active Takomi work.
|
|
5
|
-
* Toggled with Alt+C or /takomi-context.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { accessSync, readFileSync, statSync } from "node:fs";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import type { ExtensionAPI, ExtensionContext, Theme } from "@
|
|
11
|
-
import { ellipsizeMiddle, formatDuration, truncateToWidth, visibleWidth } from "./shared";
|
|
12
|
-
|
|
13
|
-
interface Component {
|
|
14
|
-
render(width: number): string[];
|
|
15
|
-
handleInput?(data: string): void;
|
|
16
|
-
invalidate(): void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface OverlayHandle {
|
|
20
|
-
hide(): void;
|
|
21
|
-
setHidden(hidden: boolean): void;
|
|
22
|
-
isHidden(): boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type FileChange = {
|
|
26
|
-
path: string;
|
|
27
|
-
action: "M" | "+";
|
|
28
|
-
timestamp: number;
|
|
29
|
-
added?: number;
|
|
30
|
-
removed?: number;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export type ToolUseCount = {
|
|
34
|
-
[tool: string]: number;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export type ContextRuntimeState = {
|
|
38
|
-
role?: string;
|
|
39
|
-
stage?: string;
|
|
40
|
-
workflow?: string;
|
|
41
|
-
activeSessionId?: string;
|
|
42
|
-
autoOrch?: boolean;
|
|
43
|
-
launchMode?: string;
|
|
44
|
-
planMode?: boolean;
|
|
45
|
-
activeSubagent?: string;
|
|
46
|
-
activeSubagentAgent?: string;
|
|
47
|
-
activeSubagentTask?: string;
|
|
48
|
-
activeSubagentStatus?: "running" | "completed" | "blocked" | string;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export type ContextPanelState = {
|
|
52
|
-
fileChanges: FileChange[];
|
|
53
|
-
toolUses: ToolUseCount;
|
|
54
|
-
sessionStart: number;
|
|
55
|
-
activeMs: number;
|
|
56
|
-
activityIntervals: ActivityInterval[];
|
|
57
|
-
pendingToolStarts: Record<string, number>;
|
|
58
|
-
lastActivityAt: number;
|
|
59
|
-
lastToolAt: number;
|
|
60
|
-
runtime: ContextRuntimeState;
|
|
61
|
-
scrollOffset: number;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
type ToolCallInput = Record<string, unknown>;
|
|
65
|
-
|
|
66
|
-
const ACTIVE_GAP_THRESHOLD_MS = 15 * 60 * 1000;
|
|
67
|
-
|
|
68
|
-
type BoardCounts = {
|
|
69
|
-
completed: number;
|
|
70
|
-
pending: number;
|
|
71
|
-
inProgress: number;
|
|
72
|
-
blocked: number;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
type BoardCountsCacheEntry = {
|
|
76
|
-
checkedAt: number;
|
|
77
|
-
mtimeMs: number;
|
|
78
|
-
counts?: BoardCounts;
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
type ActivityInterval = {
|
|
82
|
-
start: number;
|
|
83
|
-
end: number;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const BOARD_COUNTS_CACHE_MS = 1_000;
|
|
87
|
-
const boardCountsCache = new Map<string, BoardCountsCacheEntry>();
|
|
88
|
-
|
|
89
|
-
function createEmptyState(sessionStart = Date.now(), runtime: ContextRuntimeState = {}): ContextPanelState {
|
|
90
|
-
return {
|
|
91
|
-
fileChanges: [],
|
|
92
|
-
toolUses: {},
|
|
93
|
-
sessionStart,
|
|
94
|
-
activeMs: 0,
|
|
95
|
-
activityIntervals: [],
|
|
96
|
-
pendingToolStarts: {},
|
|
97
|
-
lastActivityAt: sessionStart,
|
|
98
|
-
lastToolAt: 0,
|
|
99
|
-
runtime,
|
|
100
|
-
scrollOffset: 0,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function asRecord(value: unknown): Record<string, unknown> {
|
|
105
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function firstString(...values: unknown[]): string | undefined {
|
|
109
|
-
for (const value of values) {
|
|
110
|
-
if (typeof value === "string" && value.trim()) return value;
|
|
111
|
-
}
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function normalizeToolPath(input: ToolCallInput): string | undefined {
|
|
116
|
-
const value = firstString(input.path, input.file_path, input.filePath);
|
|
117
|
-
return value?.replace(/^@/, "");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function countLines(text: string): number {
|
|
121
|
-
if (!text) return 0;
|
|
122
|
-
return text.replace(/\r?\n$/, "").split(/\r?\n/).length;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function lineDeltaForEdit(input: ToolCallInput): { added?: number; removed?: number } {
|
|
126
|
-
const edits = Array.isArray(input.edits)
|
|
127
|
-
? input.edits.map(asRecord)
|
|
128
|
-
: typeof input.oldText === "string" || typeof input.newText === "string"
|
|
129
|
-
? [input]
|
|
130
|
-
: [];
|
|
131
|
-
|
|
132
|
-
let added = 0;
|
|
133
|
-
let removed = 0;
|
|
134
|
-
for (const edit of edits) {
|
|
135
|
-
const oldText = typeof edit.oldText === "string" ? edit.oldText : "";
|
|
136
|
-
const newText = typeof edit.newText === "string" ? edit.newText : "";
|
|
137
|
-
const oldLines = countLines(oldText);
|
|
138
|
-
const newLines = countLines(newText);
|
|
139
|
-
if (newLines > oldLines) added += newLines - oldLines;
|
|
140
|
-
if (oldLines > newLines) removed += oldLines - newLines;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
...(added > 0 ? { added } : {}),
|
|
145
|
-
...(removed > 0 ? { removed } : {}),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function lineDeltaForWrite(input: ToolCallInput): { added?: number; removed?: number } {
|
|
150
|
-
const content = typeof input.content === "string" ? input.content : undefined;
|
|
151
|
-
if (content === undefined) return {};
|
|
152
|
-
const added = countLines(content);
|
|
153
|
-
return added > 0 ? { added } : {};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function changeFromTool(toolName: string, input: ToolCallInput, actionOverride?: "M" | "+"): FileChange | undefined {
|
|
157
|
-
if (toolName !== "edit" && toolName !== "write") return undefined;
|
|
158
|
-
const filePath = normalizeToolPath(input);
|
|
159
|
-
if (!filePath) return undefined;
|
|
160
|
-
const delta = toolName === "write" ? lineDeltaForWrite(input) : lineDeltaForEdit(input);
|
|
161
|
-
return {
|
|
162
|
-
path: filePath,
|
|
163
|
-
action: actionOverride ?? (toolName === "write" ? "+" : "M"),
|
|
164
|
-
timestamp: Date.now(),
|
|
165
|
-
...delta,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function formatDiff(change: FileChange, theme: Theme): string {
|
|
170
|
-
const parts = [];
|
|
171
|
-
if (change.added && change.added > 0) parts.push(theme.fg("success", `+${change.added}`));
|
|
172
|
-
if (change.removed && change.removed > 0) parts.push(theme.fg("error", `-${change.removed}`));
|
|
173
|
-
return parts.join(" ");
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function toTimestampMs(entry: { timestamp?: unknown; message?: { timestamp?: unknown } }): number | undefined {
|
|
177
|
-
if (typeof entry.message?.timestamp === "number") return entry.message.timestamp;
|
|
178
|
-
if (typeof entry.timestamp === "string") {
|
|
179
|
-
const parsed = Date.parse(entry.timestamp);
|
|
180
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
181
|
-
}
|
|
182
|
-
return undefined;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function mergeIntervals(intervals: ActivityInterval[]): ActivityInterval[] {
|
|
186
|
-
const sorted = intervals
|
|
187
|
-
.filter((interval) => Number.isFinite(interval.start) && Number.isFinite(interval.end) && interval.end > interval.start)
|
|
188
|
-
.map((interval) => ({ start: interval.start, end: interval.end }))
|
|
189
|
-
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
190
|
-
if (sorted.length === 0) return [];
|
|
191
|
-
|
|
192
|
-
const merged: ActivityInterval[] = [sorted[0]];
|
|
193
|
-
for (let i = 1; i < sorted.length; i += 1) {
|
|
194
|
-
const current = sorted[i];
|
|
195
|
-
const previous = merged[merged.length - 1];
|
|
196
|
-
if (current.start <= previous.end) {
|
|
197
|
-
previous.end = Math.max(previous.end, current.end);
|
|
198
|
-
} else {
|
|
199
|
-
merged.push({ ...current });
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
return merged;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function calculateActiveMs(intervals: ActivityInterval[]): number {
|
|
206
|
-
return mergeIntervals(intervals).reduce((total, interval) => total + (interval.end - interval.start), 0);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function formatDisplayPath(filePath: string, cwd?: string): string {
|
|
210
|
-
const normalized = filePath.replace(/^@/, "");
|
|
211
|
-
const absolute = path.isAbsolute(normalized) ? normalized : undefined;
|
|
212
|
-
if (absolute && cwd) {
|
|
213
|
-
const rel = path.relative(cwd, absolute);
|
|
214
|
-
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/");
|
|
215
|
-
}
|
|
216
|
-
return normalized.replace(/\\/g, "/");
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function loadBoardCounts(cwd: string, sessionId?: string): BoardCounts | undefined {
|
|
220
|
-
if (!sessionId) return undefined;
|
|
221
|
-
const stateFile = path.join(cwd, ".pi", "takomi", "orchestrator", `${sessionId}.json`);
|
|
222
|
-
const cached = boardCountsCache.get(stateFile);
|
|
223
|
-
const checkedAt = Date.now();
|
|
224
|
-
|
|
225
|
-
if (cached && checkedAt - cached.checkedAt < BOARD_COUNTS_CACHE_MS) return cached.counts;
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
const mtimeMs = statSync(stateFile).mtimeMs;
|
|
229
|
-
if (cached && cached.mtimeMs === mtimeMs) {
|
|
230
|
-
boardCountsCache.set(stateFile, { ...cached, checkedAt });
|
|
231
|
-
return cached.counts;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const parsed = JSON.parse(readFileSync(stateFile, "utf8")) as { tasks?: Array<{ status?: string }> };
|
|
235
|
-
const counts: BoardCounts = { completed: 0, pending: 0, inProgress: 0, blocked: 0 };
|
|
236
|
-
for (const task of parsed.tasks ?? []) {
|
|
237
|
-
if (task.status === "completed") counts.completed += 1;
|
|
238
|
-
else if (task.status === "in-progress") counts.inProgress += 1;
|
|
239
|
-
else if (task.status === "blocked") counts.blocked += 1;
|
|
240
|
-
else counts.pending += 1;
|
|
241
|
-
}
|
|
242
|
-
boardCountsCache.set(stateFile, { checkedAt, mtimeMs, counts });
|
|
243
|
-
return counts;
|
|
244
|
-
} catch {
|
|
245
|
-
boardCountsCache.set(stateFile, { checkedAt, mtimeMs: 0, counts: undefined });
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function formatBoardCounts(counts: BoardCounts): string {
|
|
251
|
-
const parts = [
|
|
252
|
-
counts.completed ? `${counts.completed} done` : "",
|
|
253
|
-
counts.inProgress ? `${counts.inProgress} active` : "",
|
|
254
|
-
counts.pending ? `${counts.pending} pending` : "",
|
|
255
|
-
counts.blocked ? `${counts.blocked} blocked` : "",
|
|
256
|
-
].filter(Boolean);
|
|
257
|
-
return parts.join(" · ") || "0 tasks";
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
class ContextPanelComponent implements Component {
|
|
261
|
-
constructor(
|
|
262
|
-
private readonly theme: Theme,
|
|
263
|
-
private readonly getState: () => ContextPanelState,
|
|
264
|
-
private readonly getCtx: () => ExtensionContext | undefined,
|
|
265
|
-
) { }
|
|
266
|
-
|
|
267
|
-
invalidate(): void { }
|
|
268
|
-
|
|
269
|
-
private maxRenderLines(): number {
|
|
270
|
-
const rows = typeof process.stdout.rows === "number" && process.stdout.rows > 0 ? process.stdout.rows : 30;
|
|
271
|
-
return Math.max(10, Math.floor(rows * 0.8));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
private applyViewport(lines: string[], panelWidth: number, theme: Theme): string[] {
|
|
275
|
-
const maxLines = this.maxRenderLines();
|
|
276
|
-
if (lines.length <= maxLines) return lines;
|
|
277
|
-
|
|
278
|
-
const header = lines.slice(0, 3);
|
|
279
|
-
const body = lines.slice(3, -2);
|
|
280
|
-
const hBar = lines[lines.length - 1] ?? theme.fg("dim", "─".repeat(panelWidth));
|
|
281
|
-
const maxBodyLines = Math.max(4, maxLines - header.length - 2);
|
|
282
|
-
const maxOffset = Math.max(0, body.length - maxBodyLines);
|
|
283
|
-
const state = this.getState();
|
|
284
|
-
const offset = Math.max(0, Math.min(state.scrollOffset, maxOffset));
|
|
285
|
-
state.scrollOffset = offset;
|
|
286
|
-
const hiddenAbove = offset;
|
|
287
|
-
const hiddenBelow = Math.max(0, body.length - offset - maxBodyLines);
|
|
288
|
-
const scrollHint = hiddenAbove || hiddenBelow
|
|
289
|
-
? `↑${hiddenAbove} ↓${hiddenBelow} Alt+K/J scroll`
|
|
290
|
-
: "Alt+C to close";
|
|
291
|
-
|
|
292
|
-
return [
|
|
293
|
-
...header,
|
|
294
|
-
...body.slice(offset, offset + maxBodyLines),
|
|
295
|
-
` ${theme.fg("dim", truncateToWidth(scrollHint, panelWidth - 4))}`,
|
|
296
|
-
hBar,
|
|
297
|
-
];
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
render(width: number): string[] {
|
|
301
|
-
const theme = this.theme;
|
|
302
|
-
const state = this.getState();
|
|
303
|
-
const ctx = this.getCtx();
|
|
304
|
-
const panelWidth = Math.min(36, Math.max(24, width));
|
|
305
|
-
const innerWidth = panelWidth - 4;
|
|
306
|
-
const pad = " ";
|
|
307
|
-
const lines: string[] = [];
|
|
308
|
-
const hBar = theme.fg("dim", "─".repeat(panelWidth));
|
|
309
|
-
|
|
310
|
-
lines.push(hBar);
|
|
311
|
-
lines.push(`${pad}${theme.fg("accent", "◎ Context")}`);
|
|
312
|
-
lines.push("");
|
|
313
|
-
|
|
314
|
-
if (ctx) {
|
|
315
|
-
const age = formatDuration(Date.now() - state.sessionStart);
|
|
316
|
-
const pendingStarts = Object.values(state.pendingToolStarts ?? {});
|
|
317
|
-
const pendingStart = pendingStarts.length > 0 ? Math.min(...pendingStarts) : undefined;
|
|
318
|
-
const activeMs = pendingStart !== undefined ? state.activeMs + Math.max(0, Date.now() - pendingStart) : state.activeMs;
|
|
319
|
-
const active = formatDuration(activeMs);
|
|
320
|
-
lines.push(`${pad}${theme.fg("dim", "Age:")} ${theme.fg("muted", age)}`);
|
|
321
|
-
lines.push(`${pad}${theme.fg("dim", "Active:")} ${theme.fg("muted", active)}`);
|
|
322
|
-
|
|
323
|
-
const contextUsage = ctx.getContextUsage();
|
|
324
|
-
if (contextUsage && contextUsage.percent !== null) {
|
|
325
|
-
const pct = Math.round(contextUsage.percent);
|
|
326
|
-
const tokStr = contextUsage.tokens !== null
|
|
327
|
-
? `${Math.round(contextUsage.tokens / 1000)}k/${Math.round(contextUsage.contextWindow / 1000)}k`
|
|
328
|
-
: "?";
|
|
329
|
-
const ctxTone = pct > 80 ? "error" : pct > 60 ? "warning" : "muted";
|
|
330
|
-
lines.push(`${pad}${theme.fg("dim", "Context:")} ${theme.fg(ctxTone as never, `${pct}%`)} ${theme.fg("dim", `(${tokStr})`)}`);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (ctx.model) {
|
|
334
|
-
lines.push(`${pad}${theme.fg("dim", "Model:")} ${theme.fg("muted", ellipsizeMiddle(ctx.model.id, innerWidth - 10))}`);
|
|
335
|
-
}
|
|
336
|
-
lines.push("");
|
|
337
|
-
|
|
338
|
-
const boardCounts = loadBoardCounts(ctx.cwd, state.runtime.activeSessionId);
|
|
339
|
-
const activeAgent = state.runtime.activeSubagentAgent;
|
|
340
|
-
const activeTask = state.runtime.activeSubagentTask;
|
|
341
|
-
const activeStatus = state.runtime.activeSubagentStatus ?? "running";
|
|
342
|
-
if (activeAgent || activeTask || boardCounts) {
|
|
343
|
-
lines.push(`${pad}${theme.fg("accent", "-- Active Work --")}`);
|
|
344
|
-
if (activeAgent) {
|
|
345
|
-
const statusTone = activeStatus === "blocked" ? "error" : activeStatus === "completed" ? "success" : "muted";
|
|
346
|
-
lines.push(`${pad}${theme.fg(statusTone as never, truncateToWidth(`${activeAgent} ${activeStatus}`, innerWidth))}`);
|
|
347
|
-
}
|
|
348
|
-
if (activeTask) {
|
|
349
|
-
lines.push(`${pad}${theme.fg("muted", truncateToWidth(activeTask, innerWidth))}`);
|
|
350
|
-
}
|
|
351
|
-
if (boardCounts) {
|
|
352
|
-
lines.push("");
|
|
353
|
-
lines.push(`${pad}${theme.fg("dim", "Board:")} ${theme.fg("muted", truncateToWidth(formatBoardCounts(boardCounts), innerWidth - 7))}`);
|
|
354
|
-
}
|
|
355
|
-
lines.push("");
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
lines.push(`${pad}${theme.fg("accent", "-- Files Changed --")}`);
|
|
360
|
-
if (state.fileChanges.length === 0) {
|
|
361
|
-
lines.push(`${pad}${theme.fg("dim", " (none yet)")}`);
|
|
362
|
-
} else {
|
|
363
|
-
const seen = new Map<string, FileChange>();
|
|
364
|
-
for (const change of state.fileChanges) seen.set(change.path, change);
|
|
365
|
-
const deduped = [...seen.values()].slice(-12);
|
|
366
|
-
|
|
367
|
-
for (const change of deduped) {
|
|
368
|
-
const icon = change.action === "+" ? theme.fg("success", "+") : theme.fg("warning", "M");
|
|
369
|
-
const diff = formatDiff(change, theme);
|
|
370
|
-
const diffWidth = visibleWidth(diff);
|
|
371
|
-
const pathWidth = Math.max(8, innerWidth - 5 - diffWidth);
|
|
372
|
-
const displayPath = ellipsizeMiddle(formatDisplayPath(change.path, ctx?.cwd), pathWidth);
|
|
373
|
-
const spacer = diff ? " ".repeat(Math.max(1, innerWidth - 4 - visibleWidth(displayPath) - diffWidth)) : "";
|
|
374
|
-
lines.push(`${pad} ${icon} ${truncateToWidth(displayPath, pathWidth)}${spacer}${diff}`);
|
|
375
|
-
}
|
|
376
|
-
if (seen.size > 12) {
|
|
377
|
-
lines.push(`${pad} ${theme.fg("dim", `... +${seen.size - 12} more`)}`);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
lines.push("");
|
|
381
|
-
|
|
382
|
-
lines.push(`${pad}${theme.fg("accent", "-- Tool Activity --")}`);
|
|
383
|
-
const toolEntries = Object.entries(state.toolUses).sort((a, b) => b[1] - a[1]);
|
|
384
|
-
if (toolEntries.length === 0) {
|
|
385
|
-
lines.push(`${pad}${theme.fg("dim", " (no tools used)")}`);
|
|
386
|
-
} else {
|
|
387
|
-
for (const [tool, count] of toolEntries.slice(0, 8)) {
|
|
388
|
-
lines.push(`${pad} ${theme.fg("success", "✓")} ${tool} ${theme.fg("dim", `(${count})`)}`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
lines.push("");
|
|
392
|
-
|
|
393
|
-
lines.push(`${pad}${theme.fg("dim", "(Alt+C to close)")}`);
|
|
394
|
-
lines.push(hBar);
|
|
395
|
-
return this.applyViewport(lines, panelWidth, theme);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
export class TakomiContextPanel {
|
|
400
|
-
private state = createEmptyState();
|
|
401
|
-
private visible = false;
|
|
402
|
-
private overlayHandle?: OverlayHandle;
|
|
403
|
-
private requestRender?: () => void;
|
|
404
|
-
private lastCtx?: ExtensionContext;
|
|
405
|
-
private readonly toolStartTimes = new Map<string, number>();
|
|
406
|
-
|
|
407
|
-
getState(): ContextPanelState {
|
|
408
|
-
return this.state;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
isVisible(): boolean {
|
|
412
|
-
return this.visible;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
setRuntimeState(runtime: ContextRuntimeState): void {
|
|
416
|
-
this.state.runtime = { ...this.state.runtime, ...runtime };
|
|
417
|
-
this.requestRender?.();
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
private recomputeActiveMs(): void {
|
|
421
|
-
this.state.activeMs = calculateActiveMs(this.state.activityIntervals);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
private addActivityInterval(start: number, end: number): void {
|
|
425
|
-
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
|
|
426
|
-
this.state.activityIntervals.push({ start, end });
|
|
427
|
-
this.recomputeActiveMs();
|
|
428
|
-
this.state.lastActivityAt = Math.max(this.state.lastActivityAt, end);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
private noteActivity(timestamp = Date.now(), allowLongGap = false): void {
|
|
432
|
-
const previous = this.state.lastActivityAt;
|
|
433
|
-
const delta = timestamp - previous;
|
|
434
|
-
if (delta > 0 && (allowLongGap || delta <= ACTIVE_GAP_THRESHOLD_MS)) {
|
|
435
|
-
this.addActivityInterval(previous, timestamp);
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
this.state.lastActivityAt = Math.max(previous, timestamp);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
private noteToolWindow(toolCallId: string | undefined, start: number, end: number): void {
|
|
442
|
-
this.noteActivity(start);
|
|
443
|
-
this.addActivityInterval(start, end);
|
|
444
|
-
if (toolCallId) delete this.state.pendingToolStarts[toolCallId];
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
trackFileChange(change: FileChange): void {
|
|
448
|
-
this.state.fileChanges.push({ ...change, timestamp: change.timestamp || Date.now() });
|
|
449
|
-
this.state.lastToolAt = Date.now();
|
|
450
|
-
this.requestRender?.();
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
trackActivity(timestamp = Date.now()): void {
|
|
454
|
-
this.noteActivity(timestamp);
|
|
455
|
-
this.requestRender?.();
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
trackToolStart(toolCallId: string | undefined, timestamp = Date.now()): void {
|
|
459
|
-
if (toolCallId) {
|
|
460
|
-
this.toolStartTimes.set(toolCallId, timestamp);
|
|
461
|
-
this.state.pendingToolStarts[toolCallId] = timestamp;
|
|
462
|
-
}
|
|
463
|
-
this.noteActivity(timestamp);
|
|
464
|
-
this.requestRender?.();
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
trackToolEnd(toolCallId: string | undefined, timestamp = Date.now()): void {
|
|
468
|
-
const start = toolCallId ? this.toolStartTimes.get(toolCallId) : undefined;
|
|
469
|
-
if (start !== undefined) {
|
|
470
|
-
this.noteToolWindow(toolCallId, start, timestamp);
|
|
471
|
-
if (toolCallId) this.toolStartTimes.delete(toolCallId);
|
|
472
|
-
} else {
|
|
473
|
-
if (toolCallId) delete this.state.pendingToolStarts[toolCallId];
|
|
474
|
-
this.noteActivity(timestamp);
|
|
475
|
-
}
|
|
476
|
-
this.requestRender?.();
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
trackToolUse(toolName: string): void {
|
|
480
|
-
this.state.toolUses[toolName] = (this.state.toolUses[toolName] ?? 0) + 1;
|
|
481
|
-
this.state.lastToolAt = Date.now();
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
resetSession(): void {
|
|
485
|
-
this.state = createEmptyState(Date.now(), this.state.runtime);
|
|
486
|
-
this.toolStartTimes.clear();
|
|
487
|
-
this.requestRender?.();
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
scroll(delta: number): void {
|
|
491
|
-
if (!this.visible) return;
|
|
492
|
-
this.state.scrollOffset = Math.max(0, this.state.scrollOffset + delta);
|
|
493
|
-
this.requestRender?.();
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
page(delta: number): void {
|
|
497
|
-
this.scroll(delta * 8);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
rebuildFromSession(ctx: ExtensionContext): void {
|
|
501
|
-
this.lastCtx = ctx;
|
|
502
|
-
const branch = ctx.sessionManager.getBranch() as Array<{
|
|
503
|
-
type?: string;
|
|
504
|
-
timestamp?: string;
|
|
505
|
-
message?: {
|
|
506
|
-
role?: string;
|
|
507
|
-
content?: unknown;
|
|
508
|
-
toolCallId?: string;
|
|
509
|
-
toolName?: string;
|
|
510
|
-
timestamp?: number;
|
|
511
|
-
};
|
|
512
|
-
}>;
|
|
513
|
-
const next = createEmptyState(Date.now(), this.state.runtime);
|
|
514
|
-
const intervals: ActivityInterval[] = [];
|
|
515
|
-
const toolCalls = new Map<string, { name: string; input: ToolCallInput; startedAt: number }>();
|
|
516
|
-
let lastActivityPoint: number | undefined;
|
|
517
|
-
let firstSessionTs: number | undefined;
|
|
518
|
-
this.toolStartTimes.clear();
|
|
519
|
-
|
|
520
|
-
for (const entry of branch) {
|
|
521
|
-
const ts = toTimestampMs(entry);
|
|
522
|
-
const message = entry.message;
|
|
523
|
-
if (ts !== undefined) firstSessionTs = firstSessionTs === undefined ? ts : Math.min(firstSessionTs, ts);
|
|
524
|
-
if (!message || ts === undefined) continue;
|
|
525
|
-
|
|
526
|
-
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
527
|
-
let sawToolCall = false;
|
|
528
|
-
for (const block of message.content) {
|
|
529
|
-
const record = asRecord(block);
|
|
530
|
-
if (record.type !== "toolCall") continue;
|
|
531
|
-
const id = typeof record.id === "string" ? record.id : undefined;
|
|
532
|
-
const name = typeof record.name === "string" ? record.name : undefined;
|
|
533
|
-
if (!id || !name) continue;
|
|
534
|
-
sawToolCall = true;
|
|
535
|
-
toolCalls.set(id, { name, input: asRecord(record.arguments), startedAt: ts });
|
|
536
|
-
this.toolStartTimes.set(id, ts);
|
|
537
|
-
}
|
|
538
|
-
if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
|
|
539
|
-
intervals.push({ start: lastActivityPoint, end: ts });
|
|
540
|
-
}
|
|
541
|
-
lastActivityPoint = ts;
|
|
542
|
-
if (!sawToolCall) continue;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
if (message.role === "toolResult" && message.toolName) {
|
|
546
|
-
next.toolUses[message.toolName] = (next.toolUses[message.toolName] ?? 0) + 1;
|
|
547
|
-
next.lastToolAt = ts;
|
|
548
|
-
const toolCall = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
|
|
549
|
-
if (toolCall) {
|
|
550
|
-
intervals.push({ start: toolCall.startedAt, end: ts });
|
|
551
|
-
const change = changeFromTool(message.toolName, toolCall.input);
|
|
552
|
-
if (change) next.fileChanges.push({ ...change, timestamp: ts });
|
|
553
|
-
if (message.toolCallId) toolCalls.delete(message.toolCallId);
|
|
554
|
-
} else if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
|
|
555
|
-
intervals.push({ start: lastActivityPoint, end: ts });
|
|
556
|
-
}
|
|
557
|
-
lastActivityPoint = ts;
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
|
|
562
|
-
intervals.push({ start: lastActivityPoint, end: ts });
|
|
563
|
-
}
|
|
564
|
-
lastActivityPoint = ts;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
next.sessionStart = firstSessionTs ?? next.sessionStart;
|
|
568
|
-
next.activityIntervals = intervals;
|
|
569
|
-
next.activeMs = calculateActiveMs(intervals);
|
|
570
|
-
next.lastActivityAt = lastActivityPoint ?? next.sessionStart;
|
|
571
|
-
|
|
572
|
-
this.state = next;
|
|
573
|
-
this.requestRender?.();
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
refresh(): void {
|
|
577
|
-
this.requestRender?.();
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
toggle(ctx: ExtensionContext): void {
|
|
581
|
-
this.lastCtx = ctx;
|
|
582
|
-
if (this.visible) this.hide();
|
|
583
|
-
else this.show(ctx);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
show(ctx: ExtensionContext): void {
|
|
587
|
-
this.lastCtx = ctx;
|
|
588
|
-
if (!ctx.hasUI) return;
|
|
589
|
-
if (this.visible) {
|
|
590
|
-
this.requestRender?.();
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
this.visible = true;
|
|
594
|
-
|
|
595
|
-
void ctx.ui.custom<void>(
|
|
596
|
-
(tui, theme, _keybindings, _done) => {
|
|
597
|
-
this.requestRender = () => tui.requestRender();
|
|
598
|
-
return new ContextPanelComponent(theme, () => this.state, () => this.lastCtx);
|
|
599
|
-
},
|
|
600
|
-
{
|
|
601
|
-
overlay: true,
|
|
602
|
-
overlayOptions: {
|
|
603
|
-
width: 36,
|
|
604
|
-
maxHeight: "100%",
|
|
605
|
-
anchor: "top-right",
|
|
606
|
-
margin: { right: 1, top: 1, bottom: 3 },
|
|
607
|
-
nonCapturing: true,
|
|
608
|
-
visible: (termWidth) => termWidth >= 100,
|
|
609
|
-
},
|
|
610
|
-
onHandle: (handle) => {
|
|
611
|
-
this.overlayHandle = handle;
|
|
612
|
-
},
|
|
613
|
-
},
|
|
614
|
-
).then(() => {
|
|
615
|
-
this.visible = false;
|
|
616
|
-
this.overlayHandle = undefined;
|
|
617
|
-
this.requestRender = undefined;
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
hide(): void {
|
|
622
|
-
this.visible = false;
|
|
623
|
-
this.overlayHandle?.hide();
|
|
624
|
-
this.overlayHandle = undefined;
|
|
625
|
-
this.requestRender = undefined;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
export function wireContextPanel(pi: ExtensionAPI, panel: TakomiContextPanel): void {
|
|
630
|
-
const pendingWriteActions = new Map<string, "M" | "+">();
|
|
631
|
-
|
|
632
|
-
pi.on("tool_call", (event, ctx) => {
|
|
633
|
-
panel.trackToolStart(event.toolCallId, Date.now());
|
|
634
|
-
if (event.toolName !== "write") return;
|
|
635
|
-
const input = asRecord(event.input);
|
|
636
|
-
const filePath = normalizeToolPath(input);
|
|
637
|
-
if (!filePath) return;
|
|
638
|
-
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.cwd, filePath);
|
|
639
|
-
try {
|
|
640
|
-
accessSync(absolute);
|
|
641
|
-
statSync(absolute);
|
|
642
|
-
pendingWriteActions.set(event.toolCallId, "M");
|
|
643
|
-
} catch {
|
|
644
|
-
pendingWriteActions.set(event.toolCallId, "+");
|
|
645
|
-
}
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
pi.on("tool_result", (event) => {
|
|
649
|
-
const toolName = event.toolName;
|
|
650
|
-
panel.trackToolUse(toolName);
|
|
651
|
-
panel.trackToolEnd(event.toolCallId, Date.now());
|
|
652
|
-
|
|
653
|
-
const input = asRecord(event.input);
|
|
654
|
-
const actionOverride = toolName === "write" ? pendingWriteActions.get(event.toolCallId) : undefined;
|
|
655
|
-
pendingWriteActions.delete(event.toolCallId);
|
|
656
|
-
const change = changeFromTool(toolName, input, actionOverride);
|
|
657
|
-
if (change) panel.trackFileChange(change);
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
pi.on("message_end", (event) => {
|
|
661
|
-
const message = (event as { message?: { role?: string } }).message;
|
|
662
|
-
if (message?.role === "toolResult") return;
|
|
663
|
-
panel.trackActivity(Date.now());
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
pi.on("turn_end", () => {
|
|
667
|
-
panel.refresh();
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
pi.on("session_start", (_event, ctx) => {
|
|
671
|
-
panel.rebuildFromSession(ctx);
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
pi.registerShortcut("alt+c", {
|
|
675
|
-
description: "Toggle Takomi context panel",
|
|
676
|
-
handler: async (ctx) => {
|
|
677
|
-
panel.toggle(ctx);
|
|
678
|
-
},
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
pi.registerShortcut("alt+k", {
|
|
682
|
-
description: "Scroll Takomi context panel up",
|
|
683
|
-
handler: async () => {
|
|
684
|
-
panel.scroll(-1);
|
|
685
|
-
},
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
pi.registerShortcut("alt+j", {
|
|
689
|
-
description: "Scroll Takomi context panel down",
|
|
690
|
-
handler: async () => {
|
|
691
|
-
panel.scroll(1);
|
|
692
|
-
},
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
pi.registerShortcut("alt+shift+k", {
|
|
696
|
-
description: "Page Takomi context panel up",
|
|
697
|
-
handler: async () => {
|
|
698
|
-
panel.page(-1);
|
|
699
|
-
},
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
pi.registerShortcut("alt+shift+j", {
|
|
703
|
-
description: "Page Takomi context panel down",
|
|
704
|
-
handler: async () => {
|
|
705
|
-
panel.page(1);
|
|
706
|
-
},
|
|
707
|
-
});
|
|
708
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Takomi Context Panel - right-side overlay showing session context.
|
|
3
|
+
*
|
|
4
|
+
* Tracks changed files, tool usage, and active Takomi work.
|
|
5
|
+
* Toggled with Alt+C or /takomi-context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { accessSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { ellipsizeMiddle, formatDuration, truncateToWidth, visibleWidth } from "./shared";
|
|
12
|
+
|
|
13
|
+
interface Component {
|
|
14
|
+
render(width: number): string[];
|
|
15
|
+
handleInput?(data: string): void;
|
|
16
|
+
invalidate(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface OverlayHandle {
|
|
20
|
+
hide(): void;
|
|
21
|
+
setHidden(hidden: boolean): void;
|
|
22
|
+
isHidden(): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FileChange = {
|
|
26
|
+
path: string;
|
|
27
|
+
action: "M" | "+";
|
|
28
|
+
timestamp: number;
|
|
29
|
+
added?: number;
|
|
30
|
+
removed?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ToolUseCount = {
|
|
34
|
+
[tool: string]: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ContextRuntimeState = {
|
|
38
|
+
role?: string;
|
|
39
|
+
stage?: string;
|
|
40
|
+
workflow?: string;
|
|
41
|
+
activeSessionId?: string;
|
|
42
|
+
autoOrch?: boolean;
|
|
43
|
+
launchMode?: string;
|
|
44
|
+
planMode?: boolean;
|
|
45
|
+
activeSubagent?: string;
|
|
46
|
+
activeSubagentAgent?: string;
|
|
47
|
+
activeSubagentTask?: string;
|
|
48
|
+
activeSubagentStatus?: "running" | "completed" | "blocked" | string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ContextPanelState = {
|
|
52
|
+
fileChanges: FileChange[];
|
|
53
|
+
toolUses: ToolUseCount;
|
|
54
|
+
sessionStart: number;
|
|
55
|
+
activeMs: number;
|
|
56
|
+
activityIntervals: ActivityInterval[];
|
|
57
|
+
pendingToolStarts: Record<string, number>;
|
|
58
|
+
lastActivityAt: number;
|
|
59
|
+
lastToolAt: number;
|
|
60
|
+
runtime: ContextRuntimeState;
|
|
61
|
+
scrollOffset: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type ToolCallInput = Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
const ACTIVE_GAP_THRESHOLD_MS = 15 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
type BoardCounts = {
|
|
69
|
+
completed: number;
|
|
70
|
+
pending: number;
|
|
71
|
+
inProgress: number;
|
|
72
|
+
blocked: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type BoardCountsCacheEntry = {
|
|
76
|
+
checkedAt: number;
|
|
77
|
+
mtimeMs: number;
|
|
78
|
+
counts?: BoardCounts;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type ActivityInterval = {
|
|
82
|
+
start: number;
|
|
83
|
+
end: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const BOARD_COUNTS_CACHE_MS = 1_000;
|
|
87
|
+
const boardCountsCache = new Map<string, BoardCountsCacheEntry>();
|
|
88
|
+
|
|
89
|
+
function createEmptyState(sessionStart = Date.now(), runtime: ContextRuntimeState = {}): ContextPanelState {
|
|
90
|
+
return {
|
|
91
|
+
fileChanges: [],
|
|
92
|
+
toolUses: {},
|
|
93
|
+
sessionStart,
|
|
94
|
+
activeMs: 0,
|
|
95
|
+
activityIntervals: [],
|
|
96
|
+
pendingToolStarts: {},
|
|
97
|
+
lastActivityAt: sessionStart,
|
|
98
|
+
lastToolAt: 0,
|
|
99
|
+
runtime,
|
|
100
|
+
scrollOffset: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
105
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function firstString(...values: unknown[]): string | undefined {
|
|
109
|
+
for (const value of values) {
|
|
110
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeToolPath(input: ToolCallInput): string | undefined {
|
|
116
|
+
const value = firstString(input.path, input.file_path, input.filePath);
|
|
117
|
+
return value?.replace(/^@/, "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function countLines(text: string): number {
|
|
121
|
+
if (!text) return 0;
|
|
122
|
+
return text.replace(/\r?\n$/, "").split(/\r?\n/).length;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lineDeltaForEdit(input: ToolCallInput): { added?: number; removed?: number } {
|
|
126
|
+
const edits = Array.isArray(input.edits)
|
|
127
|
+
? input.edits.map(asRecord)
|
|
128
|
+
: typeof input.oldText === "string" || typeof input.newText === "string"
|
|
129
|
+
? [input]
|
|
130
|
+
: [];
|
|
131
|
+
|
|
132
|
+
let added = 0;
|
|
133
|
+
let removed = 0;
|
|
134
|
+
for (const edit of edits) {
|
|
135
|
+
const oldText = typeof edit.oldText === "string" ? edit.oldText : "";
|
|
136
|
+
const newText = typeof edit.newText === "string" ? edit.newText : "";
|
|
137
|
+
const oldLines = countLines(oldText);
|
|
138
|
+
const newLines = countLines(newText);
|
|
139
|
+
if (newLines > oldLines) added += newLines - oldLines;
|
|
140
|
+
if (oldLines > newLines) removed += oldLines - newLines;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...(added > 0 ? { added } : {}),
|
|
145
|
+
...(removed > 0 ? { removed } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function lineDeltaForWrite(input: ToolCallInput): { added?: number; removed?: number } {
|
|
150
|
+
const content = typeof input.content === "string" ? input.content : undefined;
|
|
151
|
+
if (content === undefined) return {};
|
|
152
|
+
const added = countLines(content);
|
|
153
|
+
return added > 0 ? { added } : {};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function changeFromTool(toolName: string, input: ToolCallInput, actionOverride?: "M" | "+"): FileChange | undefined {
|
|
157
|
+
if (toolName !== "edit" && toolName !== "write") return undefined;
|
|
158
|
+
const filePath = normalizeToolPath(input);
|
|
159
|
+
if (!filePath) return undefined;
|
|
160
|
+
const delta = toolName === "write" ? lineDeltaForWrite(input) : lineDeltaForEdit(input);
|
|
161
|
+
return {
|
|
162
|
+
path: filePath,
|
|
163
|
+
action: actionOverride ?? (toolName === "write" ? "+" : "M"),
|
|
164
|
+
timestamp: Date.now(),
|
|
165
|
+
...delta,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatDiff(change: FileChange, theme: Theme): string {
|
|
170
|
+
const parts = [];
|
|
171
|
+
if (change.added && change.added > 0) parts.push(theme.fg("success", `+${change.added}`));
|
|
172
|
+
if (change.removed && change.removed > 0) parts.push(theme.fg("error", `-${change.removed}`));
|
|
173
|
+
return parts.join(" ");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toTimestampMs(entry: { timestamp?: unknown; message?: { timestamp?: unknown } }): number | undefined {
|
|
177
|
+
if (typeof entry.message?.timestamp === "number") return entry.message.timestamp;
|
|
178
|
+
if (typeof entry.timestamp === "string") {
|
|
179
|
+
const parsed = Date.parse(entry.timestamp);
|
|
180
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function mergeIntervals(intervals: ActivityInterval[]): ActivityInterval[] {
|
|
186
|
+
const sorted = intervals
|
|
187
|
+
.filter((interval) => Number.isFinite(interval.start) && Number.isFinite(interval.end) && interval.end > interval.start)
|
|
188
|
+
.map((interval) => ({ start: interval.start, end: interval.end }))
|
|
189
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
190
|
+
if (sorted.length === 0) return [];
|
|
191
|
+
|
|
192
|
+
const merged: ActivityInterval[] = [sorted[0]];
|
|
193
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
194
|
+
const current = sorted[i];
|
|
195
|
+
const previous = merged[merged.length - 1];
|
|
196
|
+
if (current.start <= previous.end) {
|
|
197
|
+
previous.end = Math.max(previous.end, current.end);
|
|
198
|
+
} else {
|
|
199
|
+
merged.push({ ...current });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return merged;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function calculateActiveMs(intervals: ActivityInterval[]): number {
|
|
206
|
+
return mergeIntervals(intervals).reduce((total, interval) => total + (interval.end - interval.start), 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatDisplayPath(filePath: string, cwd?: string): string {
|
|
210
|
+
const normalized = filePath.replace(/^@/, "");
|
|
211
|
+
const absolute = path.isAbsolute(normalized) ? normalized : undefined;
|
|
212
|
+
if (absolute && cwd) {
|
|
213
|
+
const rel = path.relative(cwd, absolute);
|
|
214
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/");
|
|
215
|
+
}
|
|
216
|
+
return normalized.replace(/\\/g, "/");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function loadBoardCounts(cwd: string, sessionId?: string): BoardCounts | undefined {
|
|
220
|
+
if (!sessionId) return undefined;
|
|
221
|
+
const stateFile = path.join(cwd, ".pi", "takomi", "orchestrator", `${sessionId}.json`);
|
|
222
|
+
const cached = boardCountsCache.get(stateFile);
|
|
223
|
+
const checkedAt = Date.now();
|
|
224
|
+
|
|
225
|
+
if (cached && checkedAt - cached.checkedAt < BOARD_COUNTS_CACHE_MS) return cached.counts;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const mtimeMs = statSync(stateFile).mtimeMs;
|
|
229
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
230
|
+
boardCountsCache.set(stateFile, { ...cached, checkedAt });
|
|
231
|
+
return cached.counts;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const parsed = JSON.parse(readFileSync(stateFile, "utf8")) as { tasks?: Array<{ status?: string }> };
|
|
235
|
+
const counts: BoardCounts = { completed: 0, pending: 0, inProgress: 0, blocked: 0 };
|
|
236
|
+
for (const task of parsed.tasks ?? []) {
|
|
237
|
+
if (task.status === "completed") counts.completed += 1;
|
|
238
|
+
else if (task.status === "in-progress") counts.inProgress += 1;
|
|
239
|
+
else if (task.status === "blocked") counts.blocked += 1;
|
|
240
|
+
else counts.pending += 1;
|
|
241
|
+
}
|
|
242
|
+
boardCountsCache.set(stateFile, { checkedAt, mtimeMs, counts });
|
|
243
|
+
return counts;
|
|
244
|
+
} catch {
|
|
245
|
+
boardCountsCache.set(stateFile, { checkedAt, mtimeMs: 0, counts: undefined });
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatBoardCounts(counts: BoardCounts): string {
|
|
251
|
+
const parts = [
|
|
252
|
+
counts.completed ? `${counts.completed} done` : "",
|
|
253
|
+
counts.inProgress ? `${counts.inProgress} active` : "",
|
|
254
|
+
counts.pending ? `${counts.pending} pending` : "",
|
|
255
|
+
counts.blocked ? `${counts.blocked} blocked` : "",
|
|
256
|
+
].filter(Boolean);
|
|
257
|
+
return parts.join(" · ") || "0 tasks";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
class ContextPanelComponent implements Component {
|
|
261
|
+
constructor(
|
|
262
|
+
private readonly theme: Theme,
|
|
263
|
+
private readonly getState: () => ContextPanelState,
|
|
264
|
+
private readonly getCtx: () => ExtensionContext | undefined,
|
|
265
|
+
) { }
|
|
266
|
+
|
|
267
|
+
invalidate(): void { }
|
|
268
|
+
|
|
269
|
+
private maxRenderLines(): number {
|
|
270
|
+
const rows = typeof process.stdout.rows === "number" && process.stdout.rows > 0 ? process.stdout.rows : 30;
|
|
271
|
+
return Math.max(10, Math.floor(rows * 0.8));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private applyViewport(lines: string[], panelWidth: number, theme: Theme): string[] {
|
|
275
|
+
const maxLines = this.maxRenderLines();
|
|
276
|
+
if (lines.length <= maxLines) return lines;
|
|
277
|
+
|
|
278
|
+
const header = lines.slice(0, 3);
|
|
279
|
+
const body = lines.slice(3, -2);
|
|
280
|
+
const hBar = lines[lines.length - 1] ?? theme.fg("dim", "─".repeat(panelWidth));
|
|
281
|
+
const maxBodyLines = Math.max(4, maxLines - header.length - 2);
|
|
282
|
+
const maxOffset = Math.max(0, body.length - maxBodyLines);
|
|
283
|
+
const state = this.getState();
|
|
284
|
+
const offset = Math.max(0, Math.min(state.scrollOffset, maxOffset));
|
|
285
|
+
state.scrollOffset = offset;
|
|
286
|
+
const hiddenAbove = offset;
|
|
287
|
+
const hiddenBelow = Math.max(0, body.length - offset - maxBodyLines);
|
|
288
|
+
const scrollHint = hiddenAbove || hiddenBelow
|
|
289
|
+
? `↑${hiddenAbove} ↓${hiddenBelow} Alt+K/J scroll`
|
|
290
|
+
: "Alt+C to close";
|
|
291
|
+
|
|
292
|
+
return [
|
|
293
|
+
...header,
|
|
294
|
+
...body.slice(offset, offset + maxBodyLines),
|
|
295
|
+
` ${theme.fg("dim", truncateToWidth(scrollHint, panelWidth - 4))}`,
|
|
296
|
+
hBar,
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
render(width: number): string[] {
|
|
301
|
+
const theme = this.theme;
|
|
302
|
+
const state = this.getState();
|
|
303
|
+
const ctx = this.getCtx();
|
|
304
|
+
const panelWidth = Math.min(36, Math.max(24, width));
|
|
305
|
+
const innerWidth = panelWidth - 4;
|
|
306
|
+
const pad = " ";
|
|
307
|
+
const lines: string[] = [];
|
|
308
|
+
const hBar = theme.fg("dim", "─".repeat(panelWidth));
|
|
309
|
+
|
|
310
|
+
lines.push(hBar);
|
|
311
|
+
lines.push(`${pad}${theme.fg("accent", "◎ Context")}`);
|
|
312
|
+
lines.push("");
|
|
313
|
+
|
|
314
|
+
if (ctx) {
|
|
315
|
+
const age = formatDuration(Date.now() - state.sessionStart);
|
|
316
|
+
const pendingStarts = Object.values(state.pendingToolStarts ?? {});
|
|
317
|
+
const pendingStart = pendingStarts.length > 0 ? Math.min(...pendingStarts) : undefined;
|
|
318
|
+
const activeMs = pendingStart !== undefined ? state.activeMs + Math.max(0, Date.now() - pendingStart) : state.activeMs;
|
|
319
|
+
const active = formatDuration(activeMs);
|
|
320
|
+
lines.push(`${pad}${theme.fg("dim", "Age:")} ${theme.fg("muted", age)}`);
|
|
321
|
+
lines.push(`${pad}${theme.fg("dim", "Active:")} ${theme.fg("muted", active)}`);
|
|
322
|
+
|
|
323
|
+
const contextUsage = ctx.getContextUsage();
|
|
324
|
+
if (contextUsage && contextUsage.percent !== null) {
|
|
325
|
+
const pct = Math.round(contextUsage.percent);
|
|
326
|
+
const tokStr = contextUsage.tokens !== null
|
|
327
|
+
? `${Math.round(contextUsage.tokens / 1000)}k/${Math.round(contextUsage.contextWindow / 1000)}k`
|
|
328
|
+
: "?";
|
|
329
|
+
const ctxTone = pct > 80 ? "error" : pct > 60 ? "warning" : "muted";
|
|
330
|
+
lines.push(`${pad}${theme.fg("dim", "Context:")} ${theme.fg(ctxTone as never, `${pct}%`)} ${theme.fg("dim", `(${tokStr})`)}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (ctx.model) {
|
|
334
|
+
lines.push(`${pad}${theme.fg("dim", "Model:")} ${theme.fg("muted", ellipsizeMiddle(ctx.model.id, innerWidth - 10))}`);
|
|
335
|
+
}
|
|
336
|
+
lines.push("");
|
|
337
|
+
|
|
338
|
+
const boardCounts = loadBoardCounts(ctx.cwd, state.runtime.activeSessionId);
|
|
339
|
+
const activeAgent = state.runtime.activeSubagentAgent;
|
|
340
|
+
const activeTask = state.runtime.activeSubagentTask;
|
|
341
|
+
const activeStatus = state.runtime.activeSubagentStatus ?? "running";
|
|
342
|
+
if (activeAgent || activeTask || boardCounts) {
|
|
343
|
+
lines.push(`${pad}${theme.fg("accent", "-- Active Work --")}`);
|
|
344
|
+
if (activeAgent) {
|
|
345
|
+
const statusTone = activeStatus === "blocked" ? "error" : activeStatus === "completed" ? "success" : "muted";
|
|
346
|
+
lines.push(`${pad}${theme.fg(statusTone as never, truncateToWidth(`${activeAgent} ${activeStatus}`, innerWidth))}`);
|
|
347
|
+
}
|
|
348
|
+
if (activeTask) {
|
|
349
|
+
lines.push(`${pad}${theme.fg("muted", truncateToWidth(activeTask, innerWidth))}`);
|
|
350
|
+
}
|
|
351
|
+
if (boardCounts) {
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(`${pad}${theme.fg("dim", "Board:")} ${theme.fg("muted", truncateToWidth(formatBoardCounts(boardCounts), innerWidth - 7))}`);
|
|
354
|
+
}
|
|
355
|
+
lines.push("");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
lines.push(`${pad}${theme.fg("accent", "-- Files Changed --")}`);
|
|
360
|
+
if (state.fileChanges.length === 0) {
|
|
361
|
+
lines.push(`${pad}${theme.fg("dim", " (none yet)")}`);
|
|
362
|
+
} else {
|
|
363
|
+
const seen = new Map<string, FileChange>();
|
|
364
|
+
for (const change of state.fileChanges) seen.set(change.path, change);
|
|
365
|
+
const deduped = [...seen.values()].slice(-12);
|
|
366
|
+
|
|
367
|
+
for (const change of deduped) {
|
|
368
|
+
const icon = change.action === "+" ? theme.fg("success", "+") : theme.fg("warning", "M");
|
|
369
|
+
const diff = formatDiff(change, theme);
|
|
370
|
+
const diffWidth = visibleWidth(diff);
|
|
371
|
+
const pathWidth = Math.max(8, innerWidth - 5 - diffWidth);
|
|
372
|
+
const displayPath = ellipsizeMiddle(formatDisplayPath(change.path, ctx?.cwd), pathWidth);
|
|
373
|
+
const spacer = diff ? " ".repeat(Math.max(1, innerWidth - 4 - visibleWidth(displayPath) - diffWidth)) : "";
|
|
374
|
+
lines.push(`${pad} ${icon} ${truncateToWidth(displayPath, pathWidth)}${spacer}${diff}`);
|
|
375
|
+
}
|
|
376
|
+
if (seen.size > 12) {
|
|
377
|
+
lines.push(`${pad} ${theme.fg("dim", `... +${seen.size - 12} more`)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
lines.push("");
|
|
381
|
+
|
|
382
|
+
lines.push(`${pad}${theme.fg("accent", "-- Tool Activity --")}`);
|
|
383
|
+
const toolEntries = Object.entries(state.toolUses).sort((a, b) => b[1] - a[1]);
|
|
384
|
+
if (toolEntries.length === 0) {
|
|
385
|
+
lines.push(`${pad}${theme.fg("dim", " (no tools used)")}`);
|
|
386
|
+
} else {
|
|
387
|
+
for (const [tool, count] of toolEntries.slice(0, 8)) {
|
|
388
|
+
lines.push(`${pad} ${theme.fg("success", "✓")} ${tool} ${theme.fg("dim", `(${count})`)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
lines.push("");
|
|
392
|
+
|
|
393
|
+
lines.push(`${pad}${theme.fg("dim", "(Alt+C to close)")}`);
|
|
394
|
+
lines.push(hBar);
|
|
395
|
+
return this.applyViewport(lines, panelWidth, theme);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export class TakomiContextPanel {
|
|
400
|
+
private state = createEmptyState();
|
|
401
|
+
private visible = false;
|
|
402
|
+
private overlayHandle?: OverlayHandle;
|
|
403
|
+
private requestRender?: () => void;
|
|
404
|
+
private lastCtx?: ExtensionContext;
|
|
405
|
+
private readonly toolStartTimes = new Map<string, number>();
|
|
406
|
+
|
|
407
|
+
getState(): ContextPanelState {
|
|
408
|
+
return this.state;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
isVisible(): boolean {
|
|
412
|
+
return this.visible;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
setRuntimeState(runtime: ContextRuntimeState): void {
|
|
416
|
+
this.state.runtime = { ...this.state.runtime, ...runtime };
|
|
417
|
+
this.requestRender?.();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private recomputeActiveMs(): void {
|
|
421
|
+
this.state.activeMs = calculateActiveMs(this.state.activityIntervals);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private addActivityInterval(start: number, end: number): void {
|
|
425
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
|
|
426
|
+
this.state.activityIntervals.push({ start, end });
|
|
427
|
+
this.recomputeActiveMs();
|
|
428
|
+
this.state.lastActivityAt = Math.max(this.state.lastActivityAt, end);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private noteActivity(timestamp = Date.now(), allowLongGap = false): void {
|
|
432
|
+
const previous = this.state.lastActivityAt;
|
|
433
|
+
const delta = timestamp - previous;
|
|
434
|
+
if (delta > 0 && (allowLongGap || delta <= ACTIVE_GAP_THRESHOLD_MS)) {
|
|
435
|
+
this.addActivityInterval(previous, timestamp);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.state.lastActivityAt = Math.max(previous, timestamp);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private noteToolWindow(toolCallId: string | undefined, start: number, end: number): void {
|
|
442
|
+
this.noteActivity(start);
|
|
443
|
+
this.addActivityInterval(start, end);
|
|
444
|
+
if (toolCallId) delete this.state.pendingToolStarts[toolCallId];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
trackFileChange(change: FileChange): void {
|
|
448
|
+
this.state.fileChanges.push({ ...change, timestamp: change.timestamp || Date.now() });
|
|
449
|
+
this.state.lastToolAt = Date.now();
|
|
450
|
+
this.requestRender?.();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
trackActivity(timestamp = Date.now()): void {
|
|
454
|
+
this.noteActivity(timestamp);
|
|
455
|
+
this.requestRender?.();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
trackToolStart(toolCallId: string | undefined, timestamp = Date.now()): void {
|
|
459
|
+
if (toolCallId) {
|
|
460
|
+
this.toolStartTimes.set(toolCallId, timestamp);
|
|
461
|
+
this.state.pendingToolStarts[toolCallId] = timestamp;
|
|
462
|
+
}
|
|
463
|
+
this.noteActivity(timestamp);
|
|
464
|
+
this.requestRender?.();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
trackToolEnd(toolCallId: string | undefined, timestamp = Date.now()): void {
|
|
468
|
+
const start = toolCallId ? this.toolStartTimes.get(toolCallId) : undefined;
|
|
469
|
+
if (start !== undefined) {
|
|
470
|
+
this.noteToolWindow(toolCallId, start, timestamp);
|
|
471
|
+
if (toolCallId) this.toolStartTimes.delete(toolCallId);
|
|
472
|
+
} else {
|
|
473
|
+
if (toolCallId) delete this.state.pendingToolStarts[toolCallId];
|
|
474
|
+
this.noteActivity(timestamp);
|
|
475
|
+
}
|
|
476
|
+
this.requestRender?.();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
trackToolUse(toolName: string): void {
|
|
480
|
+
this.state.toolUses[toolName] = (this.state.toolUses[toolName] ?? 0) + 1;
|
|
481
|
+
this.state.lastToolAt = Date.now();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
resetSession(): void {
|
|
485
|
+
this.state = createEmptyState(Date.now(), this.state.runtime);
|
|
486
|
+
this.toolStartTimes.clear();
|
|
487
|
+
this.requestRender?.();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
scroll(delta: number): void {
|
|
491
|
+
if (!this.visible) return;
|
|
492
|
+
this.state.scrollOffset = Math.max(0, this.state.scrollOffset + delta);
|
|
493
|
+
this.requestRender?.();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
page(delta: number): void {
|
|
497
|
+
this.scroll(delta * 8);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
rebuildFromSession(ctx: ExtensionContext): void {
|
|
501
|
+
this.lastCtx = ctx;
|
|
502
|
+
const branch = ctx.sessionManager.getBranch() as Array<{
|
|
503
|
+
type?: string;
|
|
504
|
+
timestamp?: string;
|
|
505
|
+
message?: {
|
|
506
|
+
role?: string;
|
|
507
|
+
content?: unknown;
|
|
508
|
+
toolCallId?: string;
|
|
509
|
+
toolName?: string;
|
|
510
|
+
timestamp?: number;
|
|
511
|
+
};
|
|
512
|
+
}>;
|
|
513
|
+
const next = createEmptyState(Date.now(), this.state.runtime);
|
|
514
|
+
const intervals: ActivityInterval[] = [];
|
|
515
|
+
const toolCalls = new Map<string, { name: string; input: ToolCallInput; startedAt: number }>();
|
|
516
|
+
let lastActivityPoint: number | undefined;
|
|
517
|
+
let firstSessionTs: number | undefined;
|
|
518
|
+
this.toolStartTimes.clear();
|
|
519
|
+
|
|
520
|
+
for (const entry of branch) {
|
|
521
|
+
const ts = toTimestampMs(entry);
|
|
522
|
+
const message = entry.message;
|
|
523
|
+
if (ts !== undefined) firstSessionTs = firstSessionTs === undefined ? ts : Math.min(firstSessionTs, ts);
|
|
524
|
+
if (!message || ts === undefined) continue;
|
|
525
|
+
|
|
526
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
527
|
+
let sawToolCall = false;
|
|
528
|
+
for (const block of message.content) {
|
|
529
|
+
const record = asRecord(block);
|
|
530
|
+
if (record.type !== "toolCall") continue;
|
|
531
|
+
const id = typeof record.id === "string" ? record.id : undefined;
|
|
532
|
+
const name = typeof record.name === "string" ? record.name : undefined;
|
|
533
|
+
if (!id || !name) continue;
|
|
534
|
+
sawToolCall = true;
|
|
535
|
+
toolCalls.set(id, { name, input: asRecord(record.arguments), startedAt: ts });
|
|
536
|
+
this.toolStartTimes.set(id, ts);
|
|
537
|
+
}
|
|
538
|
+
if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
|
|
539
|
+
intervals.push({ start: lastActivityPoint, end: ts });
|
|
540
|
+
}
|
|
541
|
+
lastActivityPoint = ts;
|
|
542
|
+
if (!sawToolCall) continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (message.role === "toolResult" && message.toolName) {
|
|
546
|
+
next.toolUses[message.toolName] = (next.toolUses[message.toolName] ?? 0) + 1;
|
|
547
|
+
next.lastToolAt = ts;
|
|
548
|
+
const toolCall = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
|
|
549
|
+
if (toolCall) {
|
|
550
|
+
intervals.push({ start: toolCall.startedAt, end: ts });
|
|
551
|
+
const change = changeFromTool(message.toolName, toolCall.input);
|
|
552
|
+
if (change) next.fileChanges.push({ ...change, timestamp: ts });
|
|
553
|
+
if (message.toolCallId) toolCalls.delete(message.toolCallId);
|
|
554
|
+
} else if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
|
|
555
|
+
intervals.push({ start: lastActivityPoint, end: ts });
|
|
556
|
+
}
|
|
557
|
+
lastActivityPoint = ts;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (lastActivityPoint !== undefined && ts > lastActivityPoint && ts - lastActivityPoint <= ACTIVE_GAP_THRESHOLD_MS) {
|
|
562
|
+
intervals.push({ start: lastActivityPoint, end: ts });
|
|
563
|
+
}
|
|
564
|
+
lastActivityPoint = ts;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
next.sessionStart = firstSessionTs ?? next.sessionStart;
|
|
568
|
+
next.activityIntervals = intervals;
|
|
569
|
+
next.activeMs = calculateActiveMs(intervals);
|
|
570
|
+
next.lastActivityAt = lastActivityPoint ?? next.sessionStart;
|
|
571
|
+
|
|
572
|
+
this.state = next;
|
|
573
|
+
this.requestRender?.();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
refresh(): void {
|
|
577
|
+
this.requestRender?.();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
toggle(ctx: ExtensionContext): void {
|
|
581
|
+
this.lastCtx = ctx;
|
|
582
|
+
if (this.visible) this.hide();
|
|
583
|
+
else this.show(ctx);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
show(ctx: ExtensionContext): void {
|
|
587
|
+
this.lastCtx = ctx;
|
|
588
|
+
if (!ctx.hasUI) return;
|
|
589
|
+
if (this.visible) {
|
|
590
|
+
this.requestRender?.();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
this.visible = true;
|
|
594
|
+
|
|
595
|
+
void ctx.ui.custom<void>(
|
|
596
|
+
(tui, theme, _keybindings, _done) => {
|
|
597
|
+
this.requestRender = () => tui.requestRender();
|
|
598
|
+
return new ContextPanelComponent(theme, () => this.state, () => this.lastCtx);
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
overlay: true,
|
|
602
|
+
overlayOptions: {
|
|
603
|
+
width: 36,
|
|
604
|
+
maxHeight: "100%",
|
|
605
|
+
anchor: "top-right",
|
|
606
|
+
margin: { right: 1, top: 1, bottom: 3 },
|
|
607
|
+
nonCapturing: true,
|
|
608
|
+
visible: (termWidth) => termWidth >= 100,
|
|
609
|
+
},
|
|
610
|
+
onHandle: (handle) => {
|
|
611
|
+
this.overlayHandle = handle;
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
).then(() => {
|
|
615
|
+
this.visible = false;
|
|
616
|
+
this.overlayHandle = undefined;
|
|
617
|
+
this.requestRender = undefined;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
hide(): void {
|
|
622
|
+
this.visible = false;
|
|
623
|
+
this.overlayHandle?.hide();
|
|
624
|
+
this.overlayHandle = undefined;
|
|
625
|
+
this.requestRender = undefined;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function wireContextPanel(pi: ExtensionAPI, panel: TakomiContextPanel): void {
|
|
630
|
+
const pendingWriteActions = new Map<string, "M" | "+">();
|
|
631
|
+
|
|
632
|
+
pi.on("tool_call", (event, ctx) => {
|
|
633
|
+
panel.trackToolStart(event.toolCallId, Date.now());
|
|
634
|
+
if (event.toolName !== "write") return;
|
|
635
|
+
const input = asRecord(event.input);
|
|
636
|
+
const filePath = normalizeToolPath(input);
|
|
637
|
+
if (!filePath) return;
|
|
638
|
+
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.cwd, filePath);
|
|
639
|
+
try {
|
|
640
|
+
accessSync(absolute);
|
|
641
|
+
statSync(absolute);
|
|
642
|
+
pendingWriteActions.set(event.toolCallId, "M");
|
|
643
|
+
} catch {
|
|
644
|
+
pendingWriteActions.set(event.toolCallId, "+");
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
pi.on("tool_result", (event) => {
|
|
649
|
+
const toolName = event.toolName;
|
|
650
|
+
panel.trackToolUse(toolName);
|
|
651
|
+
panel.trackToolEnd(event.toolCallId, Date.now());
|
|
652
|
+
|
|
653
|
+
const input = asRecord(event.input);
|
|
654
|
+
const actionOverride = toolName === "write" ? pendingWriteActions.get(event.toolCallId) : undefined;
|
|
655
|
+
pendingWriteActions.delete(event.toolCallId);
|
|
656
|
+
const change = changeFromTool(toolName, input, actionOverride);
|
|
657
|
+
if (change) panel.trackFileChange(change);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
pi.on("message_end", (event) => {
|
|
661
|
+
const message = (event as { message?: { role?: string } }).message;
|
|
662
|
+
if (message?.role === "toolResult") return;
|
|
663
|
+
panel.trackActivity(Date.now());
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
pi.on("turn_end", () => {
|
|
667
|
+
panel.refresh();
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.on("session_start", (_event, ctx) => {
|
|
671
|
+
panel.rebuildFromSession(ctx);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
pi.registerShortcut("alt+c", {
|
|
675
|
+
description: "Toggle Takomi context panel",
|
|
676
|
+
handler: async (ctx) => {
|
|
677
|
+
panel.toggle(ctx);
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
pi.registerShortcut("alt+k", {
|
|
682
|
+
description: "Scroll Takomi context panel up",
|
|
683
|
+
handler: async () => {
|
|
684
|
+
panel.scroll(-1);
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
pi.registerShortcut("alt+j", {
|
|
689
|
+
description: "Scroll Takomi context panel down",
|
|
690
|
+
handler: async () => {
|
|
691
|
+
panel.scroll(1);
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
pi.registerShortcut("alt+shift+k", {
|
|
696
|
+
description: "Page Takomi context panel up",
|
|
697
|
+
handler: async () => {
|
|
698
|
+
panel.page(-1);
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
pi.registerShortcut("alt+shift+j", {
|
|
703
|
+
description: "Page Takomi context panel down",
|
|
704
|
+
handler: async () => {
|
|
705
|
+
panel.page(1);
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
}
|