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