@oh-my-pi/pi-coding-agent 13.17.6 → 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 (80) hide show
  1. package/CHANGELOG.md +72 -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/cli/args.ts +0 -1
  6. package/src/commit/agentic/agent.ts +0 -3
  7. package/src/commit/agentic/index.ts +19 -22
  8. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  9. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  10. package/src/commit/agentic/tools/git-overview.ts +6 -9
  11. package/src/commit/agentic/tools/index.ts +6 -8
  12. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  13. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  14. package/src/commit/agentic/tools/split-commit.ts +4 -4
  15. package/src/commit/changelog/index.ts +5 -9
  16. package/src/commit/pipeline.ts +10 -12
  17. package/src/config/keybindings.ts +7 -6
  18. package/src/config/settings-schema.ts +45 -1
  19. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
  20. package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
  21. package/src/extensibility/custom-tools/types.ts +1 -1
  22. package/src/extensibility/extensions/types.ts +3 -1
  23. package/src/extensibility/hooks/types.ts +1 -1
  24. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  25. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  26. package/src/index.ts +1 -0
  27. package/src/internal-urls/types.ts +1 -1
  28. package/src/main.ts +24 -2
  29. package/src/modes/acp/acp-event-mapper.ts +0 -1
  30. package/src/modes/components/footer.ts +9 -29
  31. package/src/modes/components/hook-editor.ts +3 -3
  32. package/src/modes/components/hook-selector.ts +6 -1
  33. package/src/modes/components/session-observer-overlay.ts +472 -0
  34. package/src/modes/components/settings-defs.ts +19 -0
  35. package/src/modes/components/status-line.ts +15 -61
  36. package/src/modes/controllers/command-controller.ts +1 -0
  37. package/src/modes/controllers/event-controller.ts +59 -2
  38. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  39. package/src/modes/controllers/input-controller.ts +3 -0
  40. package/src/modes/controllers/selector-controller.ts +26 -0
  41. package/src/modes/interactive-mode.ts +195 -43
  42. package/src/modes/session-observer-registry.ts +146 -0
  43. package/src/modes/shared.ts +0 -42
  44. package/src/modes/types.ts +2 -0
  45. package/src/modes/utils/keybinding-matchers.ts +9 -0
  46. package/src/prompts/agents/designer.md +1 -1
  47. package/src/prompts/agents/explore.md +1 -1
  48. package/src/prompts/agents/librarian.md +1 -1
  49. package/src/prompts/agents/oracle.md +1 -1
  50. package/src/prompts/agents/plan.md +1 -1
  51. package/src/prompts/agents/reviewer.md +1 -1
  52. package/src/prompts/system/custom-system-prompt.md +5 -0
  53. package/src/prompts/system/system-prompt.md +6 -0
  54. package/src/prompts/tools/read.md +27 -18
  55. package/src/sdk.ts +28 -13
  56. package/src/secrets/index.ts +1 -1
  57. package/src/secrets/obfuscator.ts +24 -16
  58. package/src/session/agent-session.ts +75 -30
  59. package/src/session/artifacts.ts +2 -2
  60. package/src/session/session-manager.ts +15 -5
  61. package/src/system-prompt.ts +4 -0
  62. package/src/task/executor.ts +28 -0
  63. package/src/task/index.ts +89 -79
  64. package/src/task/types.ts +25 -0
  65. package/src/task/worktree.ts +127 -145
  66. package/src/tools/exit-plan-mode.ts +1 -0
  67. package/src/tools/fetch.ts +173 -98
  68. package/src/tools/gh.ts +120 -297
  69. package/src/tools/index.ts +0 -4
  70. package/src/tools/path-utils.ts +12 -1
  71. package/src/tools/read.ts +74 -85
  72. package/src/tools/renderers.ts +0 -2
  73. package/src/utils/external-editor.ts +11 -5
  74. package/src/utils/git.ts +1400 -0
  75. package/src/web/search/render.ts +6 -4
  76. package/src/commit/git/errors.ts +0 -9
  77. package/src/commit/git/index.ts +0 -210
  78. package/src/commit/git/operations.ts +0 -54
  79. package/src/prompts/tools/fetch.md +0 -11
  80. package/src/tools/gh-cli.ts +0 -125
@@ -15,6 +15,7 @@ import { renderPromptTemplate } from "../../../../config/prompt-templates";
15
15
  import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types";
16
16
  import type { HookCommandContext } from "../../../../extensibility/hooks/types";
17
17
  import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
18
+ import * as git from "../../../../utils/git";
18
19
 
19
20
  // ─────────────────────────────────────────────────────────────────────────────
20
21
  // Types
@@ -258,20 +259,20 @@ export class ReviewCommand implements CustomCommand {
258
259
  if (!baseBranch) return undefined;
259
260
 
260
261
  const currentBranch = await getCurrentBranch(this.api);
261
- const diffResult = await this.api.exec("git", ["diff", `${baseBranch}...${currentBranch}`], {
262
- timeout: 30000,
263
- });
264
- if (diffResult.code !== 0) {
265
- ctx.ui.notify(`Failed to get diff: ${diffResult.stderr}`, "error");
262
+ let diffText: string;
263
+ try {
264
+ diffText = await git.diff(this.api.cwd, { base: `${baseBranch}...${currentBranch}` });
265
+ } catch (err) {
266
+ ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error");
266
267
  return undefined;
267
268
  }
268
269
 
269
- if (!diffResult.stdout.trim()) {
270
+ if (!diffText.trim()) {
270
271
  ctx.ui.notify(`No changes between ${baseBranch} and ${currentBranch}`, "warning");
271
272
  return undefined;
272
273
  }
273
274
 
274
- const stats = parseDiff(diffResult.stdout);
275
+ const stats = parseDiff(diffText);
275
276
  if (stats.files.length === 0) {
276
277
  ctx.ui.notify("No reviewable files (all changes filtered out)", "warning");
277
278
  return undefined;
@@ -280,7 +281,7 @@ export class ReviewCommand implements CustomCommand {
280
281
  return buildReviewPrompt(
281
282
  `Reviewing changes between \`${baseBranch}\` and \`${currentBranch}\` (PR-style)`,
282
283
  stats,
283
- diffResult.stdout,
284
+ diffText,
284
285
  );
285
286
  }
286
287
 
@@ -292,12 +293,19 @@ export class ReviewCommand implements CustomCommand {
292
293
  return undefined;
293
294
  }
294
295
 
295
- const [unstagedResult, stagedResult] = await Promise.all([
296
- this.api.exec("git", ["diff"], { timeout: 30000 }),
297
- this.api.exec("git", ["diff", "--cached"], { timeout: 30000 }),
298
- ]);
296
+ let unstagedDiff: string;
297
+ let stagedDiff: string;
298
+ try {
299
+ [unstagedDiff, stagedDiff] = await Promise.all([
300
+ git.diff(this.api.cwd),
301
+ git.diff(this.api.cwd, { cached: true }),
302
+ ]);
303
+ } catch (err) {
304
+ ctx.ui.notify(`Failed to get diff: ${err instanceof Error ? err.message : String(err)}`, "error");
305
+ return undefined;
306
+ }
299
307
 
300
- const combinedDiff = [unstagedResult.stdout, stagedResult.stdout].filter(Boolean).join("\n");
308
+ const combinedDiff = [unstagedDiff, stagedDiff].filter(Boolean).join("\n");
301
309
 
302
310
  if (!combinedDiff.trim()) {
303
311
  ctx.ui.notify("No diff content found", "warning");
@@ -327,25 +335,26 @@ export class ReviewCommand implements CustomCommand {
327
335
  // Extract commit hash from selection (format: "abc1234 message")
328
336
  const hash = selected.split(" ")[0];
329
337
 
330
- // Get the commit diff (with timeout)
331
- const showResult = await this.api.exec("git", ["show", "--format=", hash], { timeout: 30000 });
332
- if (showResult.code !== 0) {
333
- ctx.ui.notify(`Failed to get commit: ${showResult.stderr}`, "error");
338
+ let diffText: string;
339
+ try {
340
+ diffText = await git.show(this.api.cwd, hash, { format: "" });
341
+ } catch (err) {
342
+ ctx.ui.notify(`Failed to get commit: ${err instanceof Error ? err.message : String(err)}`, "error");
334
343
  return undefined;
335
344
  }
336
345
 
337
- if (!showResult.stdout.trim()) {
346
+ if (!diffText.trim()) {
338
347
  ctx.ui.notify("Commit has no diff content", "warning");
339
348
  return undefined;
340
349
  }
341
350
 
342
- const stats = parseDiff(showResult.stdout);
351
+ const stats = parseDiff(diffText);
343
352
  if (stats.files.length === 0) {
344
353
  ctx.ui.notify("No reviewable files in commit (all changes filtered out)", "warning");
345
354
  return undefined;
346
355
  }
347
356
 
348
- return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, showResult.stdout);
357
+ return buildReviewPrompt(`Reviewing commit \`${hash}\``, stats, diffText);
349
358
  }
350
359
 
351
360
  case 4: {
@@ -354,16 +363,21 @@ export class ReviewCommand implements CustomCommand {
354
363
  if (!instructions?.trim()) return undefined;
355
364
 
356
365
  // For custom, we still try to get current diff for context
357
- const diffResult = await this.api.exec("git", ["diff", "HEAD"], { timeout: 30000 });
358
- const hasDiff = diffResult.code === 0 && diffResult.stdout.trim();
366
+ let diffText: string | undefined;
367
+ try {
368
+ diffText = await git.diff(this.api.cwd, { base: "HEAD" });
369
+ } catch {
370
+ diffText = undefined;
371
+ }
372
+ const reviewDiff = diffText?.trim();
359
373
 
360
- if (hasDiff) {
361
- const stats = parseDiff(diffResult.stdout);
374
+ if (reviewDiff) {
375
+ const stats = parseDiff(reviewDiff);
362
376
  // Even if all files filtered, include the custom instructions
363
377
  return `${buildReviewPrompt(
364
378
  `Custom review: ${instructions.split("\n")[0].slice(0, 60)}…`,
365
379
  stats,
366
- diffResult.stdout,
380
+ reviewDiff,
367
381
  )}\n\n### Additional Instructions\n\n${instructions}`;
368
382
  }
369
383
 
@@ -388,12 +402,7 @@ Use the Task tool with \`agent: "reviewer"\` to execute this review.`;
388
402
 
389
403
  async function getGitBranches(api: CustomCommandAPI): Promise<string[]> {
390
404
  try {
391
- const result = await api.exec("git", ["branch", "-a", "--format=%(refname:short)"]);
392
- if (result.code !== 0) return [];
393
- return result.stdout
394
- .split("\n")
395
- .map(b => b.trim())
396
- .filter(Boolean);
405
+ return await git.branch.list(api.cwd, { all: true });
397
406
  } catch {
398
407
  return [];
399
408
  }
@@ -401,8 +410,7 @@ async function getGitBranches(api: CustomCommandAPI): Promise<string[]> {
401
410
 
402
411
  async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
403
412
  try {
404
- const result = await api.exec("git", ["branch", "--show-current"]);
405
- return result.stdout.trim() || "HEAD";
413
+ return (await git.branch.current(api.cwd)) ?? "HEAD";
406
414
  } catch {
407
415
  return "HEAD";
408
416
  }
@@ -410,8 +418,7 @@ async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
410
418
 
411
419
  async function getGitStatus(api: CustomCommandAPI): Promise<string> {
412
420
  try {
413
- const result = await api.exec("git", ["status", "--porcelain"]);
414
- return result.stdout;
421
+ return await git.status(api.cwd);
415
422
  } catch {
416
423
  return "";
417
424
  }
@@ -419,12 +426,7 @@ async function getGitStatus(api: CustomCommandAPI): Promise<string> {
419
426
 
420
427
  async function getRecentCommits(api: CustomCommandAPI, count: number): Promise<string[]> {
421
428
  try {
422
- const result = await api.exec("git", ["log", `-${count}`, "--oneline", "--no-decorate"]);
423
- if (result.code !== 0) return [];
424
- return result.stdout
425
- .split("\n")
426
- .map(c => c.trim())
427
- .filter(Boolean);
429
+ return await git.log.onelines(api.cwd, count);
428
430
  } catch {
429
431
  return [];
430
432
  }
@@ -94,7 +94,7 @@ export type CustomToolSessionEvent =
94
94
  }
95
95
  | {
96
96
  reason: "auto_compaction_start";
97
- trigger: "threshold" | "overflow";
97
+ trigger: "threshold" | "overflow" | "idle";
98
98
  action: "context-full" | "handoff";
99
99
  }
100
100
  | {
@@ -81,6 +81,8 @@ export interface ExtensionUIDialogOptions {
81
81
  onLeft?: () => void;
82
82
  /** Invoked when user presses right arrow in select dialogs */
83
83
  onRight?: () => void;
84
+ /** Invoked when user presses the external editor shortcut in select dialogs */
85
+ onExternalEditor?: () => void;
84
86
  /** Optional footer hint text rendered by interactive selector */
85
87
  helpText?: string;
86
88
  }
@@ -566,7 +568,7 @@ export interface ToolExecutionEndEvent {
566
568
  /** Fired when auto-compaction starts */
567
569
  export interface AutoCompactionStartEvent {
568
570
  type: "auto_compaction_start";
569
- reason: "threshold" | "overflow";
571
+ reason: "threshold" | "overflow" | "idle";
570
572
  action: "context-full" | "handoff";
571
573
  }
572
574
 
@@ -394,7 +394,7 @@ export interface TurnEndEvent {
394
394
  /** Event data for auto_compaction_start event. */
395
395
  export interface AutoCompactionStartEvent {
396
396
  type: "auto_compaction_start";
397
- reason: "threshold" | "overflow";
397
+ reason: "threshold" | "overflow" | "idle";
398
398
  action: "context-full" | "handoff";
399
399
  }
400
400
 
@@ -8,7 +8,7 @@ import * as fs from "node:fs/promises";
8
8
  import * as os from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { isEnoent, logger } from "@oh-my-pi/pi-utils";
11
- import { $ } from "bun";
11
+ import * as git from "../../../utils/git";
12
12
 
13
13
  import type { MarketplaceCatalog, MarketplaceSourceType } from "./types";
14
14
  import { isValidNameSegment } from "./types";
@@ -274,21 +274,11 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
274
274
  * `promoteCloneToCache` after any duplicate/drift checks pass.
275
275
  */
276
276
  async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<FetchResult> {
277
- if (!Bun.which("git")) {
278
- throw new Error("git is not installed. Install git to use git-based marketplace sources.");
279
- }
280
-
281
277
  const tmpDir = path.join(cacheDir, `.tmp-clone-${Date.now()}`);
282
278
  await fs.mkdir(cacheDir, { recursive: true });
283
279
 
284
280
  logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`);
285
-
286
- const result = await $`git clone --depth 1 --single-branch ${url} ${tmpDir}`.quiet().nothrow();
287
- if (result.exitCode !== 0) {
288
- await fs.rm(tmpDir, { recursive: true, force: true });
289
- const stderr = result.stderr.toString().trim();
290
- throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`);
291
- }
281
+ await git.clone(url, tmpDir);
292
282
 
293
283
  const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH);
294
284
  let content: string;
@@ -325,48 +315,3 @@ export async function promoteCloneToCache(tmpDir: string, cacheDir: string, name
325
315
  await fs.rename(tmpDir, finalDir);
326
316
  return finalDir;
327
317
  }
328
-
329
- /**
330
- * Clone a git repository to a target directory. Shared by fetcher (marketplace clones)
331
- * and source-resolver (plugin source clones).
332
- *
333
- * @param url - Git clone URL (HTTPS, SSH, or GitHub shorthand expanded to HTTPS)
334
- * @param targetDir - Directory to clone into (must not exist)
335
- * @param options.ref - Optional branch/tag to clone
336
- * @param options.sha - Optional commit SHA to checkout after clone
337
- */
338
- export async function cloneGitRepo(
339
- url: string,
340
- targetDir: string,
341
- options?: { ref?: string; sha?: string },
342
- ): Promise<void> {
343
- if (!Bun.which("git")) {
344
- throw new Error("git is not installed. Install git to use git-based plugin sources.");
345
- }
346
-
347
- const cloneArgs = ["git", "clone", "--depth", "1"];
348
- if (options?.ref) {
349
- cloneArgs.push("--branch", options.ref, "--single-branch");
350
- } else {
351
- cloneArgs.push("--single-branch");
352
- }
353
- cloneArgs.push(url, targetDir);
354
-
355
- logger.debug("[marketplace] cloning plugin source", { url, targetDir });
356
-
357
- const result = await $`${cloneArgs}`.quiet().nothrow();
358
- if (result.exitCode !== 0) {
359
- await fs.rm(targetDir, { recursive: true, force: true });
360
- const stderr = result.stderr.toString().trim();
361
- throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`);
362
- }
363
-
364
- // If a specific SHA is requested, checkout that commit
365
- if (options?.sha) {
366
- const checkout = await $`git -C ${targetDir} checkout ${options.sha}`.quiet().nothrow();
367
- if (checkout.exitCode !== 0) {
368
- await fs.rm(targetDir, { recursive: true, force: true });
369
- throw new Error(`Failed to checkout SHA ${options.sha} — shallow clone may not contain this commit`);
370
- }
371
- }
372
- }
@@ -14,8 +14,8 @@ import * as fs from "node:fs/promises";
14
14
  import * as path from "node:path";
15
15
 
16
16
  import { isEnoent, pathIsWithin } from "@oh-my-pi/pi-utils";
17
+ import * as git from "../../../utils/git";
17
18
 
18
- import { cloneGitRepo } from "./fetcher";
19
19
  import type { MarketplaceCatalogMetadata, MarketplacePluginEntry, PluginSource } from "./types";
20
20
 
21
21
  export interface ResolveContext {
@@ -87,7 +87,7 @@ async function resolveObjectSource(
87
87
  // { source: "url", url: "https://github.com/owner/repo.git" }
88
88
  // Despite the name, this is typically a git clone URL
89
89
  const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
90
- await cloneGitRepo(source.url, targetDir, { ref: source.ref, sha: source.sha });
90
+ await git.clone(source.url, targetDir, { ref: source.ref, sha: source.sha });
91
91
  return { dir: targetDir, tempCloneRoot: targetDir };
92
92
  }
93
93
 
@@ -95,7 +95,7 @@ async function resolveObjectSource(
95
95
  // { source: "github", repo: "owner/repo" }
96
96
  const url = `https://github.com/${source.repo}.git`;
97
97
  const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
98
- await cloneGitRepo(url, targetDir, { ref: source.ref, sha: source.sha });
98
+ await git.clone(url, targetDir, { ref: source.ref, sha: source.sha });
99
99
  return { dir: targetDir, tempCloneRoot: targetDir };
100
100
  }
101
101
 
@@ -106,7 +106,7 @@ async function resolveObjectSource(
106
106
  ? source.url
107
107
  : `https://github.com/${source.url}.git`;
108
108
  const cloneDir = path.join(context.tmpDir, `plugin-repo-${crypto.randomUUID()}`);
109
- await cloneGitRepo(url, cloneDir, { ref: source.ref, sha: source.sha });
109
+ await git.clone(url, cloneDir, { ref: source.ref, sha: source.sha });
110
110
 
111
111
  const subdirPath = path.resolve(cloneDir, source.path);
112
112
  if (!pathIsWithin(cloneDir, subdirPath)) {
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export * from "./task/executor";
51
51
  export type * from "./task/types";
52
52
  // Tools (detail types and utilities)
53
53
  export * from "./tools";
54
+ export * from "./utils/git";
54
55
  // UI components for extensions
55
56
  export {
56
57
  HookEditorComponent as ExtensionEditorComponent,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Types for the internal URL routing system.
3
3
  *
4
- * Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://) are resolved by tools like fetch and read,
4
+ * Internal URLs (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://) are resolved by tools like read,
5
5
  * providing access to agent outputs and server resources without exposing filesystem paths.
6
6
  */
7
7
 
package/src/main.ts CHANGED
@@ -48,6 +48,7 @@ import type { AgentSession } from "./session/agent-session";
48
48
  import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
49
49
  import { resolvePromptInput } from "./system-prompt";
50
50
  import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
51
+ import type { EventBus } from "./utils/event-bus";
51
52
 
52
53
  async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
53
54
  if (!settings.get("startup.checkUpdate")) {
@@ -119,10 +120,19 @@ async function runInteractiveMode(
119
120
  setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
120
121
  lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }> | undefined,
121
122
  mcpManager: MCPManager | undefined,
123
+ eventBus?: EventBus,
122
124
  initialMessage?: string,
123
125
  initialImages?: ImageContent[],
124
126
  ): Promise<void> {
125
- const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers, mcpManager);
127
+ const mode = new InteractiveMode(
128
+ session,
129
+ version,
130
+ changelogMarkdown,
131
+ setExtensionUIContext,
132
+ lspServers,
133
+ mcpManager,
134
+ eventBus,
135
+ );
126
136
 
127
137
  await mode.init();
128
138
 
@@ -273,6 +283,17 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
273
283
  if (parsed.sessionDir) {
274
284
  return SessionManager.create(cwd, parsed.sessionDir);
275
285
  }
286
+ // Auto-resume: behave like --continue if the setting is enabled and a prior
287
+ // session exists. When a prior session is resumed, mark parsed.continue so
288
+ // buildSessionOptions restores the session's model/thinking instead of
289
+ // overriding them with CLI defaults.
290
+ if (settings.get("autoResume")) {
291
+ const manager = await SessionManager.continueRecent(cwd, parsed.sessionDir);
292
+ if (manager.getEntries().length > 0) {
293
+ parsed.continue = true;
294
+ }
295
+ return manager;
296
+ }
276
297
  // Default case (new session) returns undefined, SDK will create one
277
298
  return undefined;
278
299
  }
@@ -718,7 +739,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
718
739
  }
719
740
  }
720
741
 
721
- const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager } = await logger.timeAsync(
742
+ const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager, eventBus } = await logger.timeAsync(
722
743
  "createAgentSession",
723
744
  () => createAgentSession(sessionOptions),
724
745
  );
@@ -806,6 +827,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
806
827
  setToolUIContext,
807
828
  lspServers,
808
829
  mcpManager,
830
+ eventBus,
809
831
  initialMessage,
810
832
  initialImages,
811
833
  );
@@ -106,7 +106,6 @@ export function mapToolKind(toolName: string): ToolKind {
106
106
  case "find":
107
107
  case "ast_grep":
108
108
  return "search";
109
- case "fetch":
110
109
  case "web_search":
111
110
  return "fetch";
112
111
  case "todo_write":
@@ -5,7 +5,8 @@ import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
5
5
  import { theme } from "../../modes/theme/theme";
6
6
  import type { AgentSession } from "../../session/agent-session";
7
7
  import { shortenPath } from "../../tools/render-utils";
8
- import { findGitHeadPathAsync, sanitizeStatusText } from "../shared";
8
+ import * as git from "../../utils/git";
9
+ import { sanitizeStatusText } from "../shared";
9
10
  import { getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds";
10
11
 
11
12
  /**
@@ -55,13 +56,13 @@ export class FooterComponent implements Component {
55
56
  this.#gitWatcher = null;
56
57
  }
57
58
 
58
- findGitHeadPathAsync().then(result => {
59
- if (!result) {
59
+ git.head.resolve(getProjectDir()).then(head => {
60
+ if (!head) {
60
61
  return;
61
62
  }
62
63
 
63
64
  try {
64
- this.#gitWatcher = fs.watch(result.path, () => {
65
+ this.#gitWatcher = fs.watch(head.headPath, () => {
65
66
  this.#cachedBranch = undefined; // Invalidate cache
66
67
  if (this.#onBranchChange) {
67
68
  this.#onBranchChange();
@@ -93,35 +94,14 @@ export class FooterComponent implements Component {
93
94
  * Returns null if not in a git repo, branch name otherwise.
94
95
  */
95
96
  #getCurrentBranch(): string | null {
96
- // Return cached value if available
97
97
  if (this.#cachedBranch !== undefined) {
98
98
  return this.#cachedBranch;
99
99
  }
100
100
 
101
- // Note: fire-and-forget async call - will return undefined on first call
102
- // This is acceptable since it's a cached value that will update on next render
103
- findGitHeadPathAsync().then(result => {
104
- if (!result) {
105
- this.#cachedBranch = null;
106
- if (this.#onBranchChange) {
107
- this.#onBranchChange();
108
- }
109
- return;
110
- }
111
- const content = result.content.trim();
112
-
113
- if (content.startsWith("ref: refs/heads/")) {
114
- this.#cachedBranch = content.slice(16);
115
- } else {
116
- this.#cachedBranch = "detached";
117
- }
118
- if (this.#onBranchChange) {
119
- this.#onBranchChange();
120
- }
121
- });
122
-
123
- // Return undefined while loading (will show on next render once loaded)
124
- return null;
101
+ const headState = git.head.resolveSync(getProjectDir());
102
+ this.#cachedBranch =
103
+ headState === null ? null : headState.kind === "ref" ? (headState.branchName ?? headState.ref) : "detached";
104
+ return this.#cachedBranch;
125
105
  }
126
106
 
127
107
  render(width: number): string[] {
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
10
  import { getEditorTheme, theme } from "../../modes/theme/theme";
11
- import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
11
+ import { matchesAppExternalEditor, matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
12
12
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
13
13
  import { DynamicBorder } from "./dynamic-border";
14
14
 
@@ -87,7 +87,7 @@ export class HookEditorComponent extends Container {
87
87
  }
88
88
 
89
89
  // Ctrl+G for external editor
90
- if (matchesKey(keyData, "ctrl+g")) {
90
+ if (matchesAppExternalEditor(keyData)) {
91
91
  void this.#openExternalEditor();
92
92
  return;
93
93
  }
@@ -123,7 +123,7 @@ export class HookEditorComponent extends Container {
123
123
  }
124
124
 
125
125
  // Ctrl+G for external editor
126
- if (matchesKey(keyData, "ctrl+g")) {
126
+ if (matchesAppExternalEditor(keyData)) {
127
127
  void this.#openExternalEditor();
128
128
  return;
129
129
  }
@@ -16,7 +16,7 @@ import {
16
16
  visibleWidth,
17
17
  } from "@oh-my-pi/pi-tui";
18
18
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
19
- import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
19
+ import { matchesAppExternalEditor, matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
20
20
  import { CountdownTimer } from "./countdown-timer";
21
21
  import { DynamicBorder } from "./dynamic-border";
22
22
 
@@ -29,6 +29,7 @@ export interface HookSelectorOptions {
29
29
  maxVisible?: number;
30
30
  onLeft?: () => void;
31
31
  onRight?: () => void;
32
+ onExternalEditor?: () => void;
32
33
  helpText?: string;
33
34
  }
34
35
 
@@ -67,6 +68,7 @@ export class HookSelectorComponent extends Container {
67
68
  #countdown: CountdownTimer | undefined;
68
69
  #onLeftCallback: (() => void) | undefined;
69
70
  #onRightCallback: (() => void) | undefined;
71
+ #onExternalEditorCallback: (() => void) | undefined;
70
72
  constructor(
71
73
  title: string,
72
74
  options: string[],
@@ -84,6 +86,7 @@ export class HookSelectorComponent extends Container {
84
86
  this.#baseTitle = title;
85
87
  this.#onLeftCallback = opts?.onLeft;
86
88
  this.#onRightCallback = opts?.onRight;
89
+ this.#onExternalEditorCallback = opts?.onExternalEditor;
87
90
 
88
91
  this.addChild(new DynamicBorder());
89
92
  this.addChild(new Spacer(1));
@@ -174,6 +177,8 @@ export class HookSelectorComponent extends Container {
174
177
  this.#onLeftCallback?.();
175
178
  } else if (matchesKey(keyData, "right")) {
176
179
  this.#onRightCallback?.();
180
+ } else if (this.#onExternalEditorCallback && matchesAppExternalEditor(keyData)) {
181
+ this.#onExternalEditorCallback();
177
182
  } else if (matchesSelectCancel(keyData)) {
178
183
  this.#onCancelCallback();
179
184
  }