@dungle-scrubs/tallow 0.9.4 → 0.9.7

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 (212) hide show
  1. package/dist/cli.js +8 -5
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +24 -12
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +229 -146
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/interactive-reset.d.ts +49 -0
  10. package/dist/interactive-reset.d.ts.map +1 -0
  11. package/dist/interactive-reset.js +40 -0
  12. package/dist/interactive-reset.js.map +1 -0
  13. package/dist/pi-tui-editor-patch.d.ts +10 -0
  14. package/dist/pi-tui-editor-patch.d.ts.map +1 -0
  15. package/dist/pi-tui-editor-patch.js +159 -0
  16. package/dist/pi-tui-editor-patch.js.map +1 -0
  17. package/dist/pi-tui-patch.d.ts +2 -0
  18. package/dist/pi-tui-patch.d.ts.map +1 -0
  19. package/dist/pi-tui-patch.js +563 -0
  20. package/dist/pi-tui-patch.js.map +1 -0
  21. package/dist/pi-tui-settings-list-patch.d.ts +11 -0
  22. package/dist/pi-tui-settings-list-patch.d.ts.map +1 -0
  23. package/dist/pi-tui-settings-list-patch.js +38 -0
  24. package/dist/pi-tui-settings-list-patch.js.map +1 -0
  25. package/dist/process-cleanup.js +1 -1
  26. package/dist/process-cleanup.js.map +1 -1
  27. package/dist/reset-diagnostics.d.ts +69 -0
  28. package/dist/reset-diagnostics.d.ts.map +1 -0
  29. package/dist/reset-diagnostics.js +41 -0
  30. package/dist/reset-diagnostics.js.map +1 -0
  31. package/dist/sdk.d.ts +7 -23
  32. package/dist/sdk.d.ts.map +1 -1
  33. package/dist/sdk.js +211 -174
  34. package/dist/sdk.js.map +1 -1
  35. package/dist/workspace-transition-interactive.d.ts +1 -0
  36. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  37. package/dist/workspace-transition-interactive.js +8 -18
  38. package/dist/workspace-transition-interactive.js.map +1 -1
  39. package/extensions/__integration__/audit-findings.test.ts +4 -5
  40. package/extensions/_icons/index.ts +2 -4
  41. package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
  42. package/extensions/_shared/__tests__/shell-policy.test.ts +19 -0
  43. package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
  44. package/extensions/_shared/image-metadata.ts +99 -0
  45. package/extensions/_shared/inline-preview.ts +1 -1
  46. package/extensions/_shared/shell-policy.ts +121 -1
  47. package/extensions/_shared/terminal-links.ts +22 -0
  48. package/extensions/ask-user-question-tool/index.ts +0 -3
  49. package/extensions/clear/__tests__/clear.test.ts +269 -2
  50. package/extensions/command-expansion/index.ts +9 -3
  51. package/extensions/context-files/index.ts +5 -1
  52. package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
  53. package/extensions/context-fork/extension.json +1 -1
  54. package/extensions/context-fork/frontmatter-index.ts +6 -1
  55. package/extensions/context-fork/index.ts +32 -0
  56. package/extensions/edit-tool-enhanced/index.ts +2 -1
  57. package/extensions/git-status/__tests__/git-status.test.ts +65 -2
  58. package/extensions/git-status/index.ts +268 -98
  59. package/extensions/hooks/index.ts +33 -11
  60. package/extensions/loop/index.ts +14 -1
  61. package/extensions/lsp/index.ts +64 -13
  62. package/extensions/lsp/package.json +2 -2
  63. package/extensions/minimal-skill-display/index.ts +7 -1
  64. package/extensions/random-spinner/index.ts +7 -642
  65. package/extensions/read-tool-enhanced/index.ts +13 -10
  66. package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
  67. package/extensions/render-stabilizer/index.ts +6 -6
  68. package/extensions/rewind/__tests__/session-files.test.ts +115 -0
  69. package/extensions/rewind/__tests__/snapshots.test.ts +23 -0
  70. package/extensions/rewind/index.ts +5 -0
  71. package/extensions/rewind/session-files.ts +138 -0
  72. package/extensions/rewind/snapshots.ts +104 -5
  73. package/extensions/skill-commands/index.ts +6 -1
  74. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
  75. package/extensions/slash-command-bridge/index.ts +14 -2
  76. package/extensions/subagent-tool/model-resolver.ts +274 -7
  77. package/extensions/subagent-tool/schema.ts +1 -2
  78. package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
  79. package/extensions/teams-tool/tools/register-extension.ts +1 -3
  80. package/extensions/teams-tool/tools/teammate-tools.ts +1 -2
  81. package/extensions/web-search-tool/index.ts +2 -1
  82. package/extensions/wezterm-pane-control/index.ts +1 -2
  83. package/extensions/write-tool-enhanced/index.ts +2 -1
  84. package/node_modules/@mariozechner/pi-tui/README.md +56 -34
  85. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
  86. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
  87. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
  88. package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
  89. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
  90. package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
  91. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
  92. package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
  93. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
  94. package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
  95. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
  96. package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
  97. package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
  98. package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
  101. package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
  102. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
  103. package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
  104. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
  105. package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
  106. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
  107. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
  108. package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
  109. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
  110. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
  111. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
  112. package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
  113. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +1 -25
  114. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
  115. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +13 -50
  116. package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
  117. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
  118. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  119. package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
  120. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  121. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
  122. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  123. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
  124. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  125. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
  126. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  127. package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
  128. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  129. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
  130. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
  131. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
  132. package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
  133. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
  134. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  135. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
  136. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  137. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
  138. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  139. package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
  140. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  141. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
  142. package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
  143. package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
  144. package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
  145. package/node_modules/@mariozechner/pi-tui/package.json +6 -6
  146. package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
  147. package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
  148. package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
  149. package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
  150. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
  151. package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
  152. package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
  153. package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
  154. package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
  155. package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
  156. package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
  157. package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
  158. package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
  159. package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
  160. package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +12 -51
  161. package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
  162. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
  163. package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
  164. package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
  165. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
  166. package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
  167. package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
  168. package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
  169. package/package.json +13 -13
  170. package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
  171. package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
  172. package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
  173. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
  174. package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
  175. package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
  176. package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
  177. package/packages/tallow-tui/node_modules/marked/README.md +5 -4
  178. package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
  179. package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
  180. package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
  181. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
  182. package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
  183. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
  184. package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
  185. package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
  186. package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
  187. package/packages/tallow-tui/node_modules/marked/package.json +26 -34
  188. package/skills/tallow-expert/SKILL.md +3 -5
  189. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
  190. package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
  191. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
  192. package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
  193. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
  194. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
  195. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
  196. package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
  197. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
  198. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
  199. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
  200. package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
  201. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
  202. package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
  203. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
  204. package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -81
  205. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
  206. package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
  207. package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
  208. package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
  209. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
  210. package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
  211. package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
  212. package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
@@ -23,6 +23,7 @@ import * as path from "node:path";
23
23
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
24
24
  import { stripFrontmatter } from "@mariozechner/pi-coding-agent";
25
25
  import { Text } from "@mariozechner/pi-tui";
26
+ import { recordResetDiagnostic } from "../../src/reset-diagnostics.js";
26
27
  import { createLazyInitializer } from "../_shared/lazy-init.js";
27
28
  import { isProjectTrusted } from "../_shared/project-trust.js";
28
29
  import { isShellInterpolationEnabled } from "../_shared/shell-policy.js";
@@ -326,6 +327,7 @@ export function registerContextForkExtension(
326
327
 
327
328
  let frontmatterIndex: FrontmatterIndex = new Map();
328
329
  let agents: Map<string, AgentConfig> = new Map();
330
+ let sessionGeneration = 0;
329
331
 
330
332
  const debug = (...args: unknown[]) => {
331
333
  if (process.env.DEBUG) {
@@ -375,9 +377,21 @@ export function registerContextForkExtension(
375
377
 
376
378
  // Reset lazy state on each session start so resources reflect on-disk changes.
377
379
  pi.on("session_start", async () => {
380
+ sessionGeneration += 1;
378
381
  resetResources();
379
382
  });
380
383
 
384
+ // Invalidate any in-flight fork completions before switching sessions.
385
+ pi.on("session_before_switch", async (_event, ctx) => {
386
+ sessionGeneration += 1;
387
+ ctx.ui?.setWorkingMessage?.();
388
+ recordResetDiagnostic({
389
+ kind: "deferred_cancelled",
390
+ reason: "session_before_switch",
391
+ source: "context-fork",
392
+ });
393
+ });
394
+
381
395
  // Register custom message renderer for fork results
382
396
  pi.registerMessageRenderer<ForkResultDetails>("fork-result", (message, _options, theme) => {
383
397
  const details = message.details;
@@ -505,6 +519,8 @@ export function registerContextForkExtension(
505
519
 
506
520
  // Mark as handled — prevent command-prompt/minimal-skill-display from processing
507
521
  // We continue the fork asynchronously via the promise below
522
+ const forkGeneration = sessionGeneration;
523
+ recordResetDiagnostic({ kind: "deferred_registered", source: "context-fork" });
508
524
  const forkPromise = dependencies.spawnForkSubprocess({
509
525
  content,
510
526
  cwd: ctx.cwd,
@@ -516,6 +532,14 @@ export function registerContextForkExtension(
516
532
 
517
533
  forkPromise
518
534
  .then((result) => {
535
+ if (forkGeneration !== sessionGeneration) {
536
+ recordResetDiagnostic({
537
+ kind: "deferred_dropped",
538
+ reason: "session_generation_mismatch",
539
+ source: "context-fork",
540
+ });
541
+ return;
542
+ }
519
543
  ctx.ui.setWorkingMessage();
520
544
 
521
545
  if (result.exitCode !== 0 && !result.output) {
@@ -550,6 +574,14 @@ export function registerContextForkExtension(
550
574
  );
551
575
  })
552
576
  .catch((err: unknown) => {
577
+ if (forkGeneration !== sessionGeneration) {
578
+ recordResetDiagnostic({
579
+ kind: "deferred_dropped",
580
+ reason: "session_generation_mismatch",
581
+ source: "context-fork",
582
+ });
583
+ return;
584
+ }
553
585
  ctx.ui.setWorkingMessage();
554
586
  const message = err instanceof Error ? err.message : String(err);
555
587
  ctx.ui.notify(`Fork /${commandName} error: ${message}`, "error");
@@ -14,9 +14,10 @@ import {
14
14
  renderDiff,
15
15
  type ThemeColor,
16
16
  } from "@mariozechner/pi-coding-agent";
17
- import { fileLink, hyperlink, Text } from "@mariozechner/pi-tui";
17
+ import { Text } from "@mariozechner/pi-tui";
18
18
  import { getIcon } from "../_icons/index.js";
19
19
  import { commandExistsOnPath, runGitCommandSync } from "../_shared/shell-policy.js";
20
+ import { fileLink, hyperlink } from "../_shared/terminal-links.js";
20
21
  import {
21
22
  appendSection,
22
23
  dimProcessOutputLine,
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
- import gitStatus from "../index.js";
3
+ import gitStatus, { formatStatus, parseGitStatus, parsePullRequestInfo } from "../index.js";
4
4
 
5
5
  describe("git-status extension", () => {
6
- test("registers session_start, tool_result, and session_shutdown handlers", () => {
6
+ test("registers session_start, agent_end, tool_result, and session_shutdown handlers", () => {
7
7
  const events: string[] = [];
8
8
  const pi = {
9
9
  on: (event: string) => {
@@ -13,6 +13,7 @@ describe("git-status extension", () => {
13
13
 
14
14
  gitStatus(pi);
15
15
  expect(events).toContain("session_start");
16
+ expect(events).toContain("agent_end");
16
17
  expect(events).toContain("tool_result");
17
18
  expect(events).toContain("session_shutdown");
18
19
  });
@@ -30,3 +31,65 @@ describe("git-status extension", () => {
30
31
  expect(commands).toHaveLength(0);
31
32
  });
32
33
  });
34
+
35
+ describe("parseGitStatus", () => {
36
+ test("extracts branch, ahead/behind, and dirty state", () => {
37
+ const parsed = parseGitStatus(
38
+ [
39
+ "# branch.oid abcdef",
40
+ "# branch.head main",
41
+ "# branch.upstream origin/main",
42
+ "# branch.ab +2 -3",
43
+ "1 .M N... 100644 100644 100644 abc def file.ts",
44
+ ].join("\n")
45
+ );
46
+
47
+ expect(parsed).toEqual({
48
+ branch: "main",
49
+ dirty: true,
50
+ ahead: 2,
51
+ behind: 3,
52
+ prState: null,
53
+ prNumber: null,
54
+ });
55
+ });
56
+
57
+ test("returns null when branch metadata is missing", () => {
58
+ expect(parseGitStatus("# branch.oid abcdef")).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe("parsePullRequestInfo", () => {
63
+ test("prefers draft state when isDraft is true", () => {
64
+ expect(parsePullRequestInfo('{"number":42,"isDraft":true,"state":"OPEN"}')).toEqual({
65
+ prState: "draft",
66
+ prNumber: 42,
67
+ });
68
+ });
69
+
70
+ test("normalizes regular PR state values", () => {
71
+ expect(parsePullRequestInfo('{"number":42,"state":"OPEN"}')).toEqual({
72
+ prState: "open",
73
+ prNumber: 42,
74
+ });
75
+ });
76
+ });
77
+
78
+ describe("formatStatus", () => {
79
+ test("includes dirty, ahead/behind, and PR metadata", () => {
80
+ const formatted = formatStatus({
81
+ branch: "main",
82
+ dirty: true,
83
+ ahead: 1,
84
+ behind: 2,
85
+ prState: "draft",
86
+ prNumber: 42,
87
+ });
88
+
89
+ expect(formatted).toContain("main");
90
+ expect(formatted).toContain("*");
91
+ expect(formatted).toContain("↑1");
92
+ expect(formatted).toContain("↓2");
93
+ expect(formatted).toContain("PR#42(draft)");
94
+ });
95
+ });
@@ -5,12 +5,12 @@
5
5
  * - Current branch name
6
6
  * - Dirty state (* if uncommitted changes)
7
7
  * - Ahead/behind remote
8
- * - PR status (if GitHub CLI available)
8
+ * - PR status (if GitHub CLI is available and responsive)
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
12
  import { getIcon } from "../_icons/index.js";
13
- import { runCommandSync, runGitCommandSync } from "../_shared/shell-policy.js";
13
+ import { runCommand, runGitCommand } from "../_shared/shell-policy.js";
14
14
 
15
15
  // Catppuccin Macchiato colors
16
16
  const C_TEAL = "\x1b[38;2;139;213;202m"; // teal #8bd5ca
@@ -21,47 +21,53 @@ const C_MAUVE = "\x1b[38;2;198;160;246m"; // mauve #c6a0f6
21
21
  const C_GRAY = "\x1b[38;2;128;135;162m"; // overlay1 #8087a2
22
22
  const C_RESET = "\x1b[0m";
23
23
 
24
- /** Represents the current state of a git repository */
25
- interface GitState {
24
+ const STATUS_REFRESH_INTERVAL_MS = 10_000;
25
+ const PR_REFRESH_INTERVAL_MS = 60_000;
26
+ const PR_TIMEOUT_MS = 1_500;
27
+ const PR_ERROR_COOLDOWN_MS = 5 * 60_000;
28
+
29
+ type PullRequestState = "open" | "merged" | "closed" | "draft" | null;
30
+
31
+ /** Represents the current state of a git repository. */
32
+ export interface GitState {
26
33
  branch: string | null;
27
34
  dirty: boolean;
28
35
  ahead: number;
29
36
  behind: number;
30
- prState: "open" | "merged" | "closed" | "draft" | null;
37
+ prState: PullRequestState;
38
+ prNumber: number | null;
39
+ }
40
+
41
+ interface PullRequestInfo {
42
+ prState: PullRequestState;
31
43
  prNumber: number | null;
32
44
  }
33
45
 
34
- // Store interval on globalThis to clear across reloads
35
- const G = globalThis;
46
+ interface GitStatusGlobals {
47
+ __piGitStatusInterval?: ReturnType<typeof setInterval> | null;
48
+ }
49
+
50
+ const G = globalThis as typeof globalThis & GitStatusGlobals;
36
51
  if (G.__piGitStatusInterval) {
37
52
  clearInterval(G.__piGitStatusInterval);
38
53
  G.__piGitStatusInterval = null;
39
54
  }
55
+
40
56
  let lastCwd = "";
41
57
  let cachedState: GitState | null = null;
58
+ let activeRefresh: Promise<void> | null = null;
59
+ let queuedRefresh: { ctx: ExtensionContext; revision: number } | null = null;
60
+ let sessionRevision = 0;
61
+ let lastPrRefreshAt = 0;
62
+ let prCooldownUntil = 0;
42
63
 
43
64
  /**
44
- * Executes a git command in the specified directory via arg-array spawn.
65
+ * Parse `git status --porcelain=v2 --branch` output into a base git state.
45
66
  *
46
- * @param args - Git subcommand and arguments as an array
47
- * @param cwd - The working directory to run the command in
48
- * @returns The trimmed stdout output, or null if the command failed
49
- */
50
- function runGit(args: string[], cwd: string): string | null {
51
- return runGitCommandSync(args, cwd, 5000);
52
- }
53
-
54
- /**
55
- * Retrieves the current git state for a directory.
56
- * Includes branch name, dirty status, ahead/behind counts, and PR info.
57
- * @param cwd - The working directory to check
58
- * @returns The git state object, or null if not a git repository
67
+ * @param raw - Raw porcelain-v2 status output
68
+ * @returns Parsed git state without PR metadata, or null when no branch is found
59
69
  */
60
- function getGitState(cwd: string): GitState | null {
61
- // Single command: branch, ahead/behind, and porcelain status
62
- const raw = runGit(["status", "--porcelain=v2", "--branch"], cwd);
63
- if (raw === null) return null;
64
-
70
+ export function parseGitStatus(raw: string): GitState | null {
65
71
  let branch: string | null = null;
66
72
  let ahead = 0;
67
73
  let behind = 0;
@@ -70,10 +76,6 @@ function getGitState(cwd: string): GitState | null {
70
76
  for (const line of raw.split("\n")) {
71
77
  if (line.startsWith("# branch.head ")) {
72
78
  branch = line.slice("# branch.head ".length);
73
- if (branch === "(detached)") {
74
- const sha = runGit(["rev-parse", "--short", "HEAD"], cwd);
75
- branch = sha ? `(${sha})` : branch;
76
- }
77
79
  } else if (line.startsWith("# branch.ab ")) {
78
80
  const match = line.match(/\+(\d+) -(\d+)/);
79
81
  if (match) {
@@ -86,58 +88,161 @@ function getGitState(cwd: string): GitState | null {
86
88
  }
87
89
 
88
90
  if (!branch) return null;
91
+ return { branch, dirty, ahead, behind, prState: null, prNumber: null };
92
+ }
89
93
 
90
- // Try to get PR status using GitHub CLI
91
- let prState: GitState["prState"] = null;
92
- let prNumber: number | null = null;
93
-
94
- try {
95
- const prResult = runCommandSync({
96
- command: "gh",
97
- args: ["pr", "view", "--json", "state,number,isDraft"],
98
- cwd,
99
- source: "git-helper",
100
- timeoutMs: 5000,
101
- });
102
-
103
- if (prResult.ok && prResult.stdout) {
104
- const pr = JSON.parse(prResult.stdout) as {
105
- number?: number;
106
- isDraft?: boolean;
107
- state?: string;
108
- };
109
- if (pr.number) {
110
- prNumber = pr.number;
111
- if (pr.isDraft) {
112
- prState = "draft";
113
- } else if (pr.state) {
114
- prState = pr.state.toLowerCase() as GitState["prState"];
115
- }
116
- }
94
+ /**
95
+ * Parse `gh pr view --json state,number,isDraft` output.
96
+ *
97
+ * @param raw - Raw gh JSON output
98
+ * @returns Parsed pull-request metadata, or null when unavailable
99
+ */
100
+ export function parsePullRequestInfo(raw: string): PullRequestInfo | null {
101
+ const parsed = JSON.parse(raw) as {
102
+ number?: number;
103
+ isDraft?: boolean;
104
+ state?: string;
105
+ };
106
+ if (!parsed.number) return null;
107
+ if (parsed.isDraft) {
108
+ return { prState: "draft", prNumber: parsed.number };
109
+ }
110
+ if (!parsed.state) return null;
111
+ return {
112
+ prState: parsed.state.toLowerCase() as Exclude<PullRequestState, null>,
113
+ prNumber: parsed.number,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Returns whether a gh stderr payload means “no PR” rather than a broken CLI.
119
+ *
120
+ * @param stderr - gh stderr text
121
+ * @returns True when the branch simply has no associated pull request
122
+ */
123
+ function isNoPullRequestError(stderr: string): boolean {
124
+ return /no pull requests? found/i.test(stderr);
125
+ }
126
+
127
+ /**
128
+ * Returns whether a gh failure should trigger a long retry cooldown.
129
+ *
130
+ * @param result - Process result from the gh invocation
131
+ * @returns True when failures are likely environmental or timeout-related
132
+ */
133
+ function shouldCooldownPullRequestChecks(result: {
134
+ reason?: string;
135
+ stderr: string;
136
+ exitCode: number | null;
137
+ }): boolean {
138
+ if (result.reason?.includes("timed out")) return true;
139
+ if (result.reason?.includes("ENOENT")) return true;
140
+ if (result.exitCode === null && result.reason) return true;
141
+ return /could not resolve to a repository|no git remotes found|not a git repository/i.test(
142
+ result.stderr
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Execute a git command asynchronously.
148
+ *
149
+ * @param args - Git subcommand and arguments
150
+ * @param cwd - Working directory
151
+ * @param timeoutMs - Optional timeout override
152
+ * @returns Trimmed stdout output, or null on failure
153
+ */
154
+ async function runGit(
155
+ args: readonly string[],
156
+ cwd: string,
157
+ timeoutMs = 3_000
158
+ ): Promise<string | null> {
159
+ return await runGitCommand(args, cwd, timeoutMs);
160
+ }
161
+
162
+ /**
163
+ * Read branch, ahead/behind, and dirty state without blocking the UI thread.
164
+ *
165
+ * @param cwd - Working directory to inspect
166
+ * @returns Base git state, or null when not inside a git repository
167
+ */
168
+ async function getBaseGitState(cwd: string): Promise<GitState | null> {
169
+ const raw = await runGit(["status", "--porcelain=v2", "--branch"], cwd);
170
+ if (raw === null) return null;
171
+
172
+ const state = parseGitStatus(raw);
173
+ if (!state) return null;
174
+ if (state.branch !== "(detached)") return state;
175
+
176
+ const sha = await runGit(["rev-parse", "--short", "HEAD"], cwd);
177
+ if (sha) {
178
+ state.branch = `(${sha})`;
179
+ }
180
+ return state;
181
+ }
182
+
183
+ /**
184
+ * Resolve pull-request metadata for the current branch.
185
+ *
186
+ * @param cwd - Working directory to inspect
187
+ * @returns PR metadata, null for no PR / unavailable data, and cooldown on repeated failures
188
+ */
189
+ async function getPullRequestInfoForBranch(cwd: string): Promise<PullRequestInfo | null> {
190
+ const result = await runCommand({
191
+ command: "gh",
192
+ args: ["pr", "view", "--json", "state,number,isDraft"],
193
+ cwd,
194
+ source: "git-helper",
195
+ timeoutMs: PR_TIMEOUT_MS,
196
+ });
197
+ if (result.ok && result.stdout) {
198
+ try {
199
+ return parsePullRequestInfo(result.stdout);
200
+ } catch {
201
+ prCooldownUntil = Date.now() + PR_ERROR_COOLDOWN_MS;
202
+ return null;
117
203
  }
118
- } catch {
119
- // gh CLI not available, parse failure, or not in a GitHub repo
120
204
  }
121
205
 
122
- return { branch, dirty, ahead, behind, prState, prNumber };
206
+ if (isNoPullRequestError(result.stderr)) {
207
+ return null;
208
+ }
209
+
210
+ if (shouldCooldownPullRequestChecks(result)) {
211
+ prCooldownUntil = Date.now() + PR_ERROR_COOLDOWN_MS;
212
+ }
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Returns whether PR metadata should be refreshed for the current branch.
218
+ *
219
+ * @param baseState - Freshly computed base git state
220
+ * @param previousState - Previously cached state before the current refresh
221
+ * @returns True when a PR refresh is worth attempting
222
+ */
223
+ function shouldRefreshPullRequest(baseState: GitState, previousState: GitState | null): boolean {
224
+ if (baseState.branch === null) return false;
225
+ if (Date.now() < prCooldownUntil) return false;
226
+ if (!previousState) return true;
227
+ if (previousState.branch !== baseState.branch) return true;
228
+ return Date.now() - lastPrRefreshAt >= PR_REFRESH_INTERVAL_MS;
123
229
  }
124
230
 
125
231
  /**
126
232
  * Formats the git state into a colored status string for display.
233
+ *
127
234
  * @param state - The git state to format
128
235
  * @returns A formatted string with ANSI color codes
129
236
  */
130
- function formatStatus(state: GitState): string {
237
+ export function formatStatus(state: GitState): string {
131
238
  const parts: string[] = [];
132
239
 
133
- // Branch name with dirty indicator
134
240
  let branchDisplay = `${C_TEAL}${state.branch}${C_RESET}`;
135
241
  if (state.dirty) {
136
242
  branchDisplay += `${C_YELLOW}*${C_RESET}`;
137
243
  }
138
244
  parts.push(branchDisplay);
139
245
 
140
- // Ahead/behind
141
246
  if (state.ahead > 0 || state.behind > 0) {
142
247
  const arrows: string[] = [];
143
248
  if (state.ahead > 0) arrows.push(`${C_GREEN}↑${state.ahead}${C_RESET}`);
@@ -145,7 +250,6 @@ function formatStatus(state: GitState): string {
145
250
  parts.push(arrows.join(""));
146
251
  }
147
252
 
148
- // PR status
149
253
  if (state.prState && state.prNumber) {
150
254
  let prDisplay: string;
151
255
  switch (state.prState) {
@@ -171,63 +275,129 @@ function formatStatus(state: GitState): string {
171
275
  }
172
276
 
173
277
  /**
174
- * Updates the git status in the UI status bar.
175
- * Caches the state to avoid redundant git calls.
176
- * @param ctx - The extension context providing UI access
278
+ * Push the current cached status into the UI.
279
+ *
280
+ * @param ctx - Extension context providing the UI surface
281
+ * @param revision - Session revision that must still be current
282
+ * @returns Nothing
283
+ */
284
+ function renderCachedStatus(ctx: ExtensionContext, revision: number): void {
285
+ if (revision !== sessionRevision) return;
286
+ if (ctx.cwd !== lastCwd || !cachedState) {
287
+ ctx.ui.setStatus("git", undefined);
288
+ return;
289
+ }
290
+ ctx.ui.setStatus("git", formatStatus(cachedState));
291
+ }
292
+
293
+ /**
294
+ * Refresh the git status cache without blocking terminal input.
295
+ *
296
+ * Concurrent refreshes are coalesced so timer ticks, agent-end hooks, and bash
297
+ * results cannot stack multiple in-flight `git`/`gh` subprocesses.
298
+ *
299
+ * @param ctx - Extension context providing cwd + ui access
300
+ * @param revision - Session revision that must remain current while refreshing
301
+ * @returns Promise resolving after the latest queued refresh finishes
177
302
  */
178
- async function updateStatus(ctx: ExtensionContext): Promise<void> {
303
+ async function refreshStatus(ctx: ExtensionContext, revision: number): Promise<void> {
304
+ if (activeRefresh) {
305
+ queuedRefresh = { ctx, revision };
306
+ return;
307
+ }
308
+
179
309
  const cwd = ctx.cwd;
310
+ activeRefresh = (async () => {
311
+ const baseState = await getBaseGitState(cwd);
312
+ if (revision !== sessionRevision || ctx.cwd !== cwd) return;
180
313
 
181
- // Only update if cwd changed or no cache
182
- if (cwd !== lastCwd || !cachedState) {
183
314
  lastCwd = cwd;
184
- cachedState = getGitState(cwd);
185
- } else {
186
- // Refresh state periodically
187
- cachedState = getGitState(cwd);
188
- }
315
+ if (!baseState) {
316
+ cachedState = null;
317
+ renderCachedStatus(ctx, revision);
318
+ return;
319
+ }
189
320
 
190
- if (cachedState) {
191
- ctx.ui.setStatus("git", formatStatus(cachedState));
192
- } else {
193
- ctx.ui.setStatus("git", undefined);
194
- }
321
+ const previousState = cachedState;
322
+ cachedState = {
323
+ ...baseState,
324
+ prState: previousState?.branch === baseState.branch ? previousState.prState : null,
325
+ prNumber: previousState?.branch === baseState.branch ? previousState.prNumber : null,
326
+ };
327
+ renderCachedStatus(ctx, revision);
328
+
329
+ if (!shouldRefreshPullRequest(baseState, previousState)) {
330
+ return;
331
+ }
332
+
333
+ const prInfo = await getPullRequestInfoForBranch(cwd);
334
+ if (revision !== sessionRevision || ctx.cwd !== cwd) return;
335
+ if (!cachedState || cachedState.branch !== baseState.branch) return;
336
+
337
+ cachedState = {
338
+ ...cachedState,
339
+ prState: prInfo?.prState ?? null,
340
+ prNumber: prInfo?.prNumber ?? null,
341
+ };
342
+ lastPrRefreshAt = Date.now();
343
+ renderCachedStatus(ctx, revision);
344
+ })().finally(() => {
345
+ activeRefresh = null;
346
+ const nextRefresh = queuedRefresh;
347
+ queuedRefresh = null;
348
+ if (nextRefresh) {
349
+ void refreshStatus(nextRefresh.ctx, nextRefresh.revision);
350
+ }
351
+ });
352
+
353
+ await activeRefresh;
354
+ }
355
+
356
+ /**
357
+ * Invalidate caches that should be recomputed on the next refresh.
358
+ *
359
+ * @returns Nothing
360
+ */
361
+ function invalidateStatusCache(): void {
362
+ cachedState = null;
363
+ lastPrRefreshAt = 0;
195
364
  }
196
365
 
197
366
  /**
198
367
  * Registers the git status extension with Pi.
199
- * Sets up event handlers for session lifecycle and git state updates.
368
+ *
200
369
  * @param pi - The Pi extension API
370
+ * @returns Nothing
201
371
  */
202
372
  export default function gitStatus(pi: ExtensionAPI): void {
203
- pi.on("session_start", async (_event, ctx) => {
204
- await updateStatus(ctx);
373
+ pi.on("session_start", (_event, ctx) => {
374
+ const revision = ++sessionRevision;
375
+ renderCachedStatus(ctx, revision);
376
+ void refreshStatus(ctx, revision);
205
377
 
206
- // Update every 10 seconds
207
378
  if (G.__piGitStatusInterval) clearInterval(G.__piGitStatusInterval);
208
- G.__piGitStatusInterval = setInterval(() => updateStatus(ctx), 10_000);
379
+ G.__piGitStatusInterval = setInterval(() => {
380
+ void refreshStatus(ctx, revision);
381
+ }, STATUS_REFRESH_INTERVAL_MS);
209
382
  });
210
383
 
211
- pi.on("session_shutdown", async () => {
384
+ pi.on("session_shutdown", () => {
385
+ sessionRevision += 1;
386
+ queuedRefresh = null;
212
387
  if (G.__piGitStatusInterval) {
213
388
  clearInterval(G.__piGitStatusInterval);
214
389
  G.__piGitStatusInterval = null;
215
390
  }
216
391
  });
217
392
 
218
- // Update after each agent turn (files may have changed)
219
- pi.on("agent_end", async (_event, ctx) => {
220
- // Clear cache to force refresh
221
- cachedState = null;
222
- await updateStatus(ctx);
393
+ pi.on("agent_end", (_event, ctx) => {
394
+ invalidateStatusCache();
395
+ void refreshStatus(ctx, sessionRevision);
223
396
  });
224
397
 
225
- // Update when directory changes
226
- pi.on("tool_result", async (event, ctx) => {
227
- if (event.toolName === "bash") {
228
- // Might have changed directory or git state
229
- cachedState = null;
230
- await updateStatus(ctx);
231
- }
398
+ pi.on("tool_result", (event, ctx) => {
399
+ if (event.toolName !== "bash") return;
400
+ invalidateStatusCache();
401
+ void refreshStatus(ctx, sessionRevision);
232
402
  });
233
403
  }