@oh-my-pi/pi-coding-agent 13.18.0 → 13.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -11
  3. package/src/autoresearch/git.ts +25 -30
  4. package/src/autoresearch/tools/log-experiment.ts +61 -74
  5. package/src/commit/agentic/agent.ts +0 -3
  6. package/src/commit/agentic/index.ts +19 -22
  7. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  8. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  9. package/src/commit/agentic/tools/git-overview.ts +6 -9
  10. package/src/commit/agentic/tools/index.ts +6 -8
  11. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  12. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  13. package/src/commit/agentic/tools/split-commit.ts +4 -4
  14. package/src/commit/changelog/index.ts +5 -9
  15. package/src/commit/pipeline.ts +10 -12
  16. package/src/config/keybindings.ts +7 -6
  17. package/src/config/settings-schema.ts +44 -0
  18. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
  19. package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
  20. package/src/extensibility/custom-tools/types.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -1
  22. package/src/extensibility/hooks/types.ts +1 -1
  23. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  24. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  25. package/src/index.ts +1 -0
  26. package/src/main.ts +24 -2
  27. package/src/modes/components/footer.ts +9 -29
  28. package/src/modes/components/hook-editor.ts +3 -3
  29. package/src/modes/components/hook-selector.ts +6 -1
  30. package/src/modes/components/session-observer-overlay.ts +472 -0
  31. package/src/modes/components/settings-defs.ts +19 -0
  32. package/src/modes/components/status-line.ts +15 -61
  33. package/src/modes/controllers/command-controller.ts +1 -0
  34. package/src/modes/controllers/event-controller.ts +59 -2
  35. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  36. package/src/modes/controllers/input-controller.ts +3 -0
  37. package/src/modes/controllers/selector-controller.ts +26 -0
  38. package/src/modes/interactive-mode.ts +195 -43
  39. package/src/modes/session-observer-registry.ts +146 -0
  40. package/src/modes/shared.ts +0 -42
  41. package/src/modes/types.ts +2 -0
  42. package/src/modes/utils/keybinding-matchers.ts +9 -0
  43. package/src/prompts/system/custom-system-prompt.md +5 -0
  44. package/src/prompts/system/system-prompt.md +6 -0
  45. package/src/sdk.ts +28 -13
  46. package/src/secrets/index.ts +1 -1
  47. package/src/secrets/obfuscator.ts +24 -16
  48. package/src/session/agent-session.ts +75 -30
  49. package/src/session/session-manager.ts +15 -5
  50. package/src/system-prompt.ts +4 -0
  51. package/src/task/executor.ts +28 -0
  52. package/src/task/index.ts +88 -78
  53. package/src/task/types.ts +25 -0
  54. package/src/task/worktree.ts +127 -145
  55. package/src/tools/exit-plan-mode.ts +1 -0
  56. package/src/tools/gh.ts +120 -297
  57. package/src/tools/read.ts +13 -79
  58. package/src/utils/external-editor.ts +11 -5
  59. package/src/utils/git.ts +1400 -0
  60. package/src/web/search/render.ts +6 -4
  61. package/src/commit/git/errors.ts +0 -9
  62. package/src/commit/git/index.ts +0 -210
  63. package/src/commit/git/operations.ts +0 -54
  64. package/src/tools/gh-cli.ts +0 -125
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Session observer overlay component.
3
+ *
4
+ * Picker mode: lists main + active subagent sessions with live status.
5
+ * Viewer mode: renders a read-only transcript of the selected subagent's session
6
+ * by reading its JSONL session file — shows thinking, text, tool calls, results.
7
+ *
8
+ * Lifecycle:
9
+ * - shortcut opens picker
10
+ * - Enter on a subagent -> viewer
11
+ * - shortcut while in viewer -> back to picker
12
+ * - Esc from viewer -> back to picker
13
+ * - Esc from picker -> close overlay
14
+ * - Enter on main session -> close overlay (jump back)
15
+ */
16
+ import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai";
17
+ import { Container, Markdown, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui";
18
+ import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
19
+ import type { KeyId } from "../../config/keybindings";
20
+ import type { SessionMessageEntry } from "../../session/session-manager";
21
+ import { parseSessionEntries } from "../../session/session-manager";
22
+ import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils";
23
+ import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
24
+ import { getMarkdownTheme, getSelectListTheme, theme } from "../theme/theme";
25
+ import { DynamicBorder } from "./dynamic-border";
26
+
27
+ type Mode = "picker" | "viewer";
28
+
29
+ /** Max thinking characters to show (long thinking can be huge) */
30
+ const MAX_THINKING_CHARS = 600;
31
+ /** Max tool args characters to display */
32
+ const MAX_TOOL_ARGS_CHARS = 200;
33
+ /** Max tool result text to display */
34
+ const MAX_TOOL_RESULT_CHARS = 300;
35
+
36
+ export class SessionObserverOverlayComponent extends Container {
37
+ #registry: SessionObserverRegistry;
38
+ #onDone: () => void;
39
+ #mode: Mode = "picker";
40
+ #selectList: SelectList;
41
+ #viewerContainer: Container;
42
+ #selectedSessionId?: string;
43
+ #observeKeys: KeyId[];
44
+ /** Cached parsed transcript per session file to avoid reparsing on every refresh */
45
+ #transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[] };
46
+
47
+ constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
48
+ super();
49
+ this.#registry = registry;
50
+ this.#onDone = onDone;
51
+ this.#observeKeys = observeKeys;
52
+ this.#selectList = new SelectList([], 0, getSelectListTheme());
53
+ this.#viewerContainer = new Container();
54
+
55
+ this.#setupPicker();
56
+ }
57
+
58
+ #setupPicker(): void {
59
+ this.#mode = "picker";
60
+ this.children = [];
61
+
62
+ this.addChild(new DynamicBorder());
63
+ this.addChild(new Text(theme.bold(theme.fg("accent", "Session Observer")), 1, 0));
64
+ this.addChild(new Spacer(1));
65
+
66
+ const items = this.#buildPickerItems();
67
+ this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
68
+
69
+ this.#selectList.onSelect = item => {
70
+ if (item.value === "main") {
71
+ this.#onDone();
72
+ return;
73
+ }
74
+ this.#selectedSessionId = item.value;
75
+ this.#setupViewer();
76
+ };
77
+
78
+ this.#selectList.onCancel = () => {
79
+ this.#onDone();
80
+ };
81
+
82
+ this.addChild(this.#selectList);
83
+ this.addChild(new DynamicBorder());
84
+ }
85
+
86
+ #setupViewer(): void {
87
+ this.#mode = "viewer";
88
+ this.children = [];
89
+ this.#viewerContainer = new Container();
90
+ this.#refreshViewer();
91
+
92
+ this.addChild(new DynamicBorder());
93
+ this.addChild(this.#viewerContainer);
94
+ this.addChild(new Spacer(1));
95
+ this.addChild(new Text(theme.fg("dim", "Esc: back to picker | Ctrl+S: back to picker"), 1, 0));
96
+ this.addChild(new DynamicBorder());
97
+ }
98
+
99
+ /** Rebuild content from live registry data */
100
+ refreshFromRegistry(): void {
101
+ if (this.#mode === "picker") {
102
+ this.#refreshPickerItems();
103
+ } else if (this.#mode === "viewer" && this.#selectedSessionId) {
104
+ this.#refreshViewer();
105
+ }
106
+ }
107
+
108
+ #refreshPickerItems(): void {
109
+ // Preserve selection across refresh by matching on value
110
+ const previousValue = this.#selectList.getSelectedItem()?.value;
111
+
112
+ const items = this.#buildPickerItems();
113
+ const newList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
114
+ newList.onSelect = this.#selectList.onSelect;
115
+ newList.onCancel = this.#selectList.onCancel;
116
+
117
+ if (previousValue) {
118
+ const newIndex = items.findIndex(i => i.value === previousValue);
119
+ if (newIndex >= 0) newList.setSelectedIndex(newIndex);
120
+ }
121
+
122
+ const idx = this.children.indexOf(this.#selectList);
123
+ if (idx >= 0) {
124
+ this.children[idx] = newList;
125
+ }
126
+ this.#selectList = newList;
127
+ }
128
+
129
+ #refreshViewer(): void {
130
+ this.#viewerContainer.clear();
131
+
132
+ const sessions = this.#registry.getSessions();
133
+ const session = sessions.find(s => s.id === this.#selectedSessionId);
134
+ if (!session) {
135
+ this.#viewerContainer.addChild(new Text(theme.fg("dim", "Session no longer available."), 1, 0));
136
+ return;
137
+ }
138
+
139
+ this.#renderSessionHeader(session);
140
+ this.#renderSessionTranscript(session);
141
+ }
142
+
143
+ #renderSessionHeader(session: ObservableSession): void {
144
+ const c = this.#viewerContainer;
145
+ const progress = session.progress;
146
+
147
+ // Header: label + status + [agent]
148
+ const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
149
+ const statusText = theme.fg(statusColor, session.status);
150
+ const agentTag = session.agent ? theme.fg("dim", ` [${session.agent}]`) : "";
151
+ c.addChild(new Text(`${theme.bold(theme.fg("accent", session.label))} ${statusText}${agentTag}`, 1, 0));
152
+
153
+ if (session.description) {
154
+ c.addChild(new Text(theme.fg("muted", session.description), 1, 0));
155
+ }
156
+
157
+ // Stats from progress
158
+ if (progress) {
159
+ const stats: string[] = [];
160
+ if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
161
+ if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`);
162
+ if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
163
+ if (stats.length > 0) {
164
+ c.addChild(new Text(theme.fg("dim", stats.join(theme.sep.dot)), 1, 0));
165
+ }
166
+ }
167
+
168
+ if (session.sessionFile) {
169
+ c.addChild(new Text(theme.fg("dim", `Session: ${shortenPath(session.sessionFile)}`), 1, 0));
170
+ }
171
+
172
+ c.addChild(new DynamicBorder());
173
+ }
174
+
175
+ /** Incrementally read and parse the session JSONL, caching already-parsed entries. */
176
+ #loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
177
+ // Invalidate cache if session file changed (e.g. switched to different subagent)
178
+ if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
179
+ this.#transcriptCache = undefined;
180
+ }
181
+
182
+ const fromByte = this.#transcriptCache?.bytesRead ?? 0;
183
+ const result = readFileIncremental(sessionFile, fromByte);
184
+ if (!result) {
185
+ logger.debug("Session observer: failed to read session file", { path: sessionFile });
186
+ return this.#transcriptCache?.entries ?? null;
187
+ }
188
+
189
+ // File shrank (compaction or pruning rewrote it) — invalidate and re-read from scratch
190
+ if (result.newSize < fromByte) {
191
+ this.#transcriptCache = undefined;
192
+ return this.#loadTranscript(sessionFile);
193
+ }
194
+
195
+ if (!this.#transcriptCache) {
196
+ this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] };
197
+ }
198
+
199
+ // Parse only new bytes, but only up to the last complete line.
200
+ // A partial trailing record (mid-write) must not be consumed —
201
+ // we leave those bytes for the next refresh.
202
+ if (result.text.length > 0) {
203
+ const lastNewline = result.text.lastIndexOf("\n");
204
+ if (lastNewline >= 0) {
205
+ const completeChunk = result.text.slice(0, lastNewline + 1);
206
+ const newEntries = parseSessionEntries(completeChunk);
207
+ for (const entry of newEntries) {
208
+ if (entry.type === "message") {
209
+ this.#transcriptCache.entries.push(entry as SessionMessageEntry);
210
+ }
211
+ }
212
+ this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
213
+ }
214
+ // If no newline found, the entire chunk is partial — leave bytesRead unchanged
215
+ }
216
+ return this.#transcriptCache.entries;
217
+ }
218
+
219
+ #renderSessionTranscript(session: ObservableSession): void {
220
+ const c = this.#viewerContainer;
221
+
222
+ if (!session.sessionFile) {
223
+ c.addChild(new Text(theme.fg("dim", "No session file available yet."), 1, 0));
224
+ return;
225
+ }
226
+
227
+ const messageEntries = this.#loadTranscript(session.sessionFile);
228
+ if (!messageEntries) {
229
+ c.addChild(new Text(theme.fg("dim", "Unable to read session file."), 1, 0));
230
+ return;
231
+ }
232
+ if (messageEntries.length === 0) {
233
+ c.addChild(new Text(theme.fg("dim", "No messages yet."), 1, 0));
234
+ return;
235
+ }
236
+
237
+ // Build a tool call ID -> tool result map for matching
238
+ const toolResults = new Map<string, ToolResultMessage>();
239
+ for (const entry of messageEntries) {
240
+ if (entry.message.role === "toolResult") {
241
+ toolResults.set(entry.message.toolCallId, entry.message);
242
+ }
243
+ }
244
+
245
+ for (const entry of messageEntries) {
246
+ const msg = entry.message;
247
+
248
+ if (msg.role === "assistant") {
249
+ this.#renderAssistantMessage(c, msg, toolResults);
250
+ } else if (msg.role === "user" || msg.role === "developer") {
251
+ // Show user/developer messages briefly
252
+ const text =
253
+ typeof msg.content === "string"
254
+ ? msg.content
255
+ : msg.content
256
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
257
+ .map(b => b.text)
258
+ .join("\n");
259
+ if (text.trim()) {
260
+ const label = msg.role === "developer" ? "System" : "User";
261
+ c.addChild(new Spacer(1));
262
+ c.addChild(
263
+ new Text(
264
+ `${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", truncateToWidth(text.trim(), 80))}`,
265
+ 1,
266
+ 0,
267
+ ),
268
+ );
269
+ }
270
+ }
271
+ // toolResult entries are rendered inline with their tool calls above
272
+ }
273
+ }
274
+
275
+ #renderAssistantMessage(c: Container, msg: AssistantMessage, toolResults: Map<string, ToolResultMessage>): void {
276
+ for (const content of msg.content) {
277
+ if (content.type === "thinking" && content.thinking.trim()) {
278
+ const thinking = content.thinking.trim();
279
+ c.addChild(new Spacer(1));
280
+ if (thinking.length > MAX_THINKING_CHARS) {
281
+ // Show truncated thinking as markdown for proper formatting
282
+ const truncated = `${thinking.slice(0, MAX_THINKING_CHARS)}...`;
283
+ c.addChild(
284
+ new Markdown(truncated, 1, 0, getMarkdownTheme(), {
285
+ color: (t: string) => theme.fg("thinkingText", t),
286
+ italic: true,
287
+ }),
288
+ );
289
+ } else {
290
+ c.addChild(
291
+ new Markdown(thinking, 1, 0, getMarkdownTheme(), {
292
+ color: (t: string) => theme.fg("thinkingText", t),
293
+ italic: true,
294
+ }),
295
+ );
296
+ }
297
+ } else if (content.type === "text" && content.text.trim()) {
298
+ c.addChild(new Spacer(1));
299
+ c.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
300
+ } else if (content.type === "toolCall") {
301
+ this.#renderToolCall(c, content, toolResults);
302
+ }
303
+ }
304
+ }
305
+
306
+ #renderToolCall(
307
+ c: Container,
308
+ call: { id: string; name: string; arguments: Record<string, unknown>; intent?: string },
309
+ toolResults: Map<string, ToolResultMessage>,
310
+ ): void {
311
+ c.addChild(new Spacer(1));
312
+
313
+ // Tool call header with intent
314
+ const intentStr = call.intent ? theme.fg("dim", ` ${truncateToWidth(call.intent, 50)}`) : "";
315
+ c.addChild(new Text(`${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`, 1, 0));
316
+
317
+ // Key arguments (skip very long ones, show summary)
318
+ const argEntries = Object.entries(call.arguments);
319
+ if (argEntries.length > 0) {
320
+ const argSummary = this.#formatToolArgs(call.name, call.arguments);
321
+ if (argSummary) {
322
+ c.addChild(new Text(` ${theme.fg("dim", argSummary)}`, 1, 0));
323
+ }
324
+ }
325
+
326
+ // Inline tool result
327
+ const result = toolResults.get(call.id);
328
+ if (result) {
329
+ this.#renderToolResult(c, result);
330
+ }
331
+ }
332
+
333
+ #formatToolArgs(toolName: string, args: Record<string, unknown>): string {
334
+ // Show the most relevant arg for common tools
335
+ switch (toolName) {
336
+ case "read":
337
+ return args.path ? `path: ${args.path}` : "";
338
+ case "write":
339
+ return args.path ? `path: ${args.path}` : "";
340
+ case "edit":
341
+ return args.path ? `path: ${args.path}` : "";
342
+ case "grep":
343
+ return [args.pattern ? `pattern: ${args.pattern}` : "", args.path ? `path: ${args.path}` : ""]
344
+ .filter(Boolean)
345
+ .join(", ");
346
+ case "find":
347
+ return args.pattern ? `pattern: ${args.pattern}` : "";
348
+ case "bash": {
349
+ const cmd = args.command;
350
+ if (typeof cmd === "string") {
351
+ return truncateToWidth(replaceTabs(cmd), 70);
352
+ }
353
+ return "";
354
+ }
355
+ case "lsp":
356
+ return [args.action, args.file, args.symbol].filter(Boolean).join(" ");
357
+ case "ast_grep":
358
+ case "ast_edit":
359
+ return args.path ? `path: ${args.path}` : "";
360
+ case "task": {
361
+ const tasks = args.tasks;
362
+ if (Array.isArray(tasks)) {
363
+ return `${tasks.length} task(s)`;
364
+ }
365
+ return "";
366
+ }
367
+ default: {
368
+ // Generic: show first few args truncated
369
+ const parts: string[] = [];
370
+ let total = 0;
371
+ for (const [key, value] of Object.entries(args)) {
372
+ if (key.startsWith("_")) continue;
373
+ const v = typeof value === "string" ? value : JSON.stringify(value);
374
+ const entry = `${key}: ${truncateToWidth(replaceTabs(v ?? ""), 40)}`;
375
+ if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
376
+ parts.push(entry);
377
+ total += entry.length;
378
+ }
379
+ return parts.join(", ");
380
+ }
381
+ }
382
+ }
383
+
384
+ #renderToolResult(c: Container, result: ToolResultMessage): void {
385
+ const textParts = result.content
386
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
387
+ .map(p => p.text);
388
+ const text = textParts.join("\n").trim();
389
+
390
+ if (result.isError) {
391
+ const preview = truncateToWidth(replaceTabs(text || "Error"), 70);
392
+ c.addChild(new Text(` ${theme.fg("error", `✗ ${preview}`)}`, 1, 0));
393
+ } else if (text) {
394
+ // Show brief result preview
395
+ const lines = text.split("\n");
396
+ if (lines.length === 1 && text.length < MAX_TOOL_RESULT_CHARS) {
397
+ c.addChild(new Text(` ${theme.fg("dim", `✓ ${truncateToWidth(replaceTabs(text), 70)}`)}`, 1, 0));
398
+ } else {
399
+ c.addChild(new Text(` ${theme.fg("dim", `✓ ${lines.length} lines`)}`, 1, 0));
400
+ }
401
+ } else {
402
+ c.addChild(new Text(` ${theme.fg("dim", "✓ done")}`, 1, 0));
403
+ }
404
+ }
405
+
406
+ #buildPickerItems(): SelectItem[] {
407
+ const sessions = this.#registry.getSessions();
408
+ return sessions.map(s => {
409
+ const statusIcon =
410
+ s.status === "active" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : "○";
411
+ const statusColor = s.status === "active" ? "success" : s.status === "failed" ? "error" : "dim";
412
+ const prefix = theme.fg(statusColor, statusIcon);
413
+ const agentSuffix = s.agent ? theme.fg("dim", ` [${s.agent}]`) : "";
414
+ const label = s.kind === "main" ? `${prefix} ${s.label} (return)` : `${prefix} ${s.label}${agentSuffix}`;
415
+
416
+ // Show current activity in the picker description for subagents
417
+ let description = s.description;
418
+ if (s.progress?.currentTool) {
419
+ const intent = s.progress.lastIntent;
420
+ description = intent ? `${s.progress.currentTool}: ${truncateToWidth(intent, 40)}` : s.progress.currentTool;
421
+ }
422
+
423
+ return { value: s.id, label, description };
424
+ });
425
+ }
426
+
427
+ handleInput(keyData: string): void {
428
+ for (const key of this.#observeKeys) {
429
+ if (matchesKey(keyData, key)) {
430
+ if (this.#mode === "viewer") {
431
+ this.#setupPicker();
432
+ return;
433
+ }
434
+ this.#onDone();
435
+ return;
436
+ }
437
+ }
438
+
439
+ if (this.#mode === "picker") {
440
+ this.#selectList.handleInput(keyData);
441
+ } else if (this.#mode === "viewer") {
442
+ if (matchesKey(keyData, "escape")) {
443
+ this.#setupPicker();
444
+ return;
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ // Sync helpers for render path — avoid async in component rendering
451
+ import * as fs from "node:fs";
452
+
453
+ /**
454
+ * Read new bytes from a file starting at the given byte offset.
455
+ * Returns the new text and updated file size, or null on error.
456
+ */
457
+ function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
458
+ try {
459
+ const stat = fs.statSync(filePath);
460
+ if (stat.size <= fromByte) return { text: "", newSize: stat.size };
461
+ const buf = Buffer.alloc(stat.size - fromByte);
462
+ const fd = fs.openSync(filePath, "r");
463
+ try {
464
+ fs.readSync(fd, buf, 0, buf.length, fromByte);
465
+ } finally {
466
+ fs.closeSync(fd);
467
+ }
468
+ return { text: buf.toString("utf-8"), newSize: stat.size };
469
+ } catch {
470
+ return null;
471
+ }
472
+ }
@@ -109,6 +109,25 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
109
109
  { value: "300000", label: "300K tokens", description: "Large context window" },
110
110
  { value: "500000", label: "500K tokens", description: "Very large context window" },
111
111
  ],
112
+ "compaction.idleThresholdTokens": [
113
+ { value: "100000", label: "100K tokens" },
114
+ { value: "200000", label: "200K tokens" },
115
+ { value: "300000", label: "300K tokens" },
116
+ { value: "400000", label: "400K tokens" },
117
+ { value: "500000", label: "500K tokens" },
118
+ { value: "600000", label: "600K tokens" },
119
+ { value: "700000", label: "700K tokens" },
120
+ { value: "800000", label: "800K tokens" },
121
+ { value: "900000", label: "900K tokens" },
122
+ ],
123
+ "compaction.idleTimeoutSeconds": [
124
+ { value: "60", label: "1 minute" },
125
+ { value: "120", label: "2 minutes" },
126
+ { value: "300", label: "5 minutes" },
127
+ { value: "600", label: "10 minutes" },
128
+ { value: "1800", label: "30 minutes" },
129
+ { value: "3600", label: "1 hour" },
130
+ ],
112
131
  // Retry max retries
113
132
  "retry.maxRetries": [
114
133
  { value: "1", label: "1 retry" },
@@ -1,20 +1,20 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
- import { formatCount } from "@oh-my-pi/pi-utils";
4
+ import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils";
5
5
  import { $ } from "bun";
6
6
  import { settings } from "../../config/settings";
7
7
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
8
8
  import { theme } from "../../modes/theme/theme";
9
9
  import type { AgentSession } from "../../session/agent-session";
10
10
  import { calculatePromptTokens } from "../../session/compaction/compaction";
11
- import { findGitHeadPathSync, sanitizeStatusText } from "../shared";
11
+ import * as git from "../../utils/git";
12
+ import { sanitizeStatusText } from "../shared";
12
13
  import {
13
14
  canReuseCachedPr,
14
15
  createPrCacheContext,
15
16
  isSamePrCacheContext,
16
17
  type PrCacheContext,
17
- parseDefaultBranch,
18
18
  } from "./status-line/git-utils";
19
19
  import { getPreset } from "./status-line/presets";
20
20
  import { renderSegment, type SegmentContext } from "./status-line/segments";
@@ -120,7 +120,7 @@ export class StatusLineComponent implements Component {
120
120
  this.#gitWatcher = null;
121
121
  }
122
122
 
123
- const gitHeadPath = findGitHeadPathSync();
123
+ const gitHeadPath = git.repo.resolveSync(getProjectDir())?.headPath ?? null;
124
124
  if (!gitHeadPath) return;
125
125
 
126
126
  try {
@@ -152,46 +152,33 @@ export class StatusLineComponent implements Component {
152
152
  this.#cachedPrContext = undefined;
153
153
  }
154
154
  #getCurrentBranch(): string | null {
155
- const gitHeadPath = findGitHeadPathSync();
155
+ const head = git.head.resolveSync(getProjectDir());
156
+ const gitHeadPath = head?.headPath ?? null;
156
157
  if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
157
158
  return this.#cachedBranch;
158
159
  }
159
160
 
160
161
  this.#cachedBranchRepoId = gitHeadPath;
161
- if (!gitHeadPath) {
162
+ if (!head) {
162
163
  this.#cachedBranch = null;
163
164
  return null;
164
165
  }
165
166
 
166
- try {
167
- const content = fs.readFileSync(gitHeadPath, "utf8").trim();
168
-
169
- if (content.startsWith("ref: refs/heads/")) {
170
- this.#cachedBranch = content.slice(16);
171
- } else {
172
- this.#cachedBranch = "detached";
173
- }
174
- } catch {
175
- this.#cachedBranch = null;
176
- }
167
+ this.#cachedBranch = head.kind === "ref" ? (head.branchName ?? head.ref) : "detached";
177
168
 
178
169
  return this.#cachedBranch ?? null;
179
170
  }
180
171
 
181
172
  #isDefaultBranch(branch: string): boolean {
182
173
  if (this.#defaultBranch === undefined) {
183
- // Kick off async resolution, use hardcoded fallback until it resolves
184
174
  this.#defaultBranch = "main";
185
175
  (async () => {
186
- // Try origin/HEAD first, fall back to upstream/HEAD
187
- const origin = await $`git rev-parse --abbrev-ref origin/HEAD`.quiet().nothrow();
188
- if (origin.exitCode === 0) {
189
- this.#defaultBranch = parseDefaultBranch(origin.stdout.toString().trim());
190
- return;
191
- }
192
- const upstream = await $`git rev-parse --abbrev-ref upstream/HEAD`.quiet().nothrow();
193
- if (upstream.exitCode === 0) {
194
- this.#defaultBranch = parseDefaultBranch(upstream.stdout.toString().trim());
176
+ const resolved = await git.branch.default(getProjectDir());
177
+ if (resolved) {
178
+ this.#defaultBranch = resolved;
179
+ if (this.#onBranchChange) {
180
+ this.#onBranchChange();
181
+ }
195
182
  }
196
183
  })();
197
184
  }
@@ -205,42 +192,9 @@ export class StatusLineComponent implements Component {
205
192
 
206
193
  this.#gitStatusInFlight = true;
207
194
 
208
- // Fire async fetch, return cached value
209
195
  (async () => {
210
196
  try {
211
- const result = await $`git --no-optional-locks status --porcelain`.quiet().nothrow();
212
-
213
- if (result.exitCode !== 0) {
214
- this.#cachedGitStatus = null;
215
- return;
216
- }
217
-
218
- const output = result.stdout.toString();
219
-
220
- let staged = 0;
221
- let unstaged = 0;
222
- let untracked = 0;
223
-
224
- for (const line of output.split("\n")) {
225
- if (!line) continue;
226
- const x = line[0];
227
- const y = line[1];
228
-
229
- if (x === "?" && y === "?") {
230
- untracked++;
231
- continue;
232
- }
233
-
234
- if (x && x !== " " && x !== "?") {
235
- staged++;
236
- }
237
-
238
- if (y && y !== " ") {
239
- unstaged++;
240
- }
241
- }
242
-
243
- this.#cachedGitStatus = { staged, unstaged, untracked };
197
+ this.#cachedGitStatus = await git.status.summary(getProjectDir());
244
198
  } catch {
245
199
  this.#cachedGitStatus = null;
246
200
  } finally {
@@ -586,6 +586,7 @@ export class CommandController {
586
586
  }
587
587
  }
588
588
  await this.ctx.session.newSession();
589
+ this.ctx.resetObserverRegistry();
589
590
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
590
591
 
591
592
  this.ctx.statusLine.invalidate();