@oh-my-pi/pi-coding-agent 16.1.23 → 16.2.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 (254) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/cli.js +3817 -3735
  3. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
  4. package/dist/types/advisor/emission-guard.d.ts +73 -0
  5. package/dist/types/advisor/index.d.ts +1 -0
  6. package/dist/types/advisor/runtime.d.ts +7 -0
  7. package/dist/types/advisor/watchdog.d.ts +2 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/collab/display-name.d.ts +3 -0
  11. package/dist/types/collab/host.d.ts +0 -2
  12. package/dist/types/commands/worktree.d.ts +0 -3
  13. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  14. package/dist/types/config/model-discovery.d.ts +6 -1
  15. package/dist/types/config/model-registry.d.ts +6 -2
  16. package/dist/types/config/model-resolver.d.ts +12 -0
  17. package/dist/types/config/models-config-schema.d.ts +29 -2
  18. package/dist/types/config/models-config.d.ts +22 -1
  19. package/dist/types/config/settings-schema.d.ts +91 -21
  20. package/dist/types/config/settings.d.ts +2 -0
  21. package/dist/types/dap/config.d.ts +1 -1
  22. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  23. package/dist/types/edit/renderer.d.ts +4 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  25. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  26. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  27. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  28. package/dist/types/internal-urls/index.d.ts +1 -0
  29. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  30. package/dist/types/internal-urls/router.d.ts +1 -1
  31. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  32. package/dist/types/internal-urls/types.d.ts +19 -2
  33. package/dist/types/irc/bus.d.ts +6 -0
  34. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  35. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  36. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  37. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  38. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  39. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  40. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  41. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  42. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  43. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  44. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  45. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  46. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  48. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  49. package/dist/types/modes/interactive-mode.d.ts +10 -1
  50. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  51. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  52. package/dist/types/modes/theme/theme.d.ts +1 -1
  53. package/dist/types/modes/types.d.ts +9 -1
  54. package/dist/types/sdk.d.ts +2 -2
  55. package/dist/types/session/agent-session.d.ts +2 -1
  56. package/dist/types/session/session-manager.d.ts +13 -0
  57. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  58. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  59. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  60. package/dist/types/ssh/connection-manager.d.ts +2 -0
  61. package/dist/types/ssh/file-transfer.d.ts +79 -0
  62. package/dist/types/ssh/utils.d.ts +6 -0
  63. package/dist/types/system-prompt.d.ts +3 -0
  64. package/dist/types/task/executor.d.ts +16 -0
  65. package/dist/types/tiny/text.d.ts +1 -1
  66. package/dist/types/tools/builtin-names.d.ts +5 -1
  67. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  68. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  69. package/dist/types/tools/index.d.ts +3 -3
  70. package/dist/types/tools/path-utils.d.ts +29 -0
  71. package/dist/types/tools/read.d.ts +2 -2
  72. package/dist/types/tools/render-utils.d.ts +8 -0
  73. package/dist/types/tools/renderers.d.ts +11 -0
  74. package/dist/types/tools/ssh.d.ts +2 -0
  75. package/dist/types/tools/todo.d.ts +0 -16
  76. package/dist/types/tools/write.d.ts +2 -2
  77. package/dist/types/utils/active-repo-context.d.ts +8 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/utils/markit-cache.d.ts +23 -0
  80. package/dist/types/utils/markit.d.ts +5 -1
  81. package/dist/types/utils/prompt-path.d.ts +1 -0
  82. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  83. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  84. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  85. package/dist/types/web/search/providers/xai.d.ts +13 -0
  86. package/dist/types/web/search/types.d.ts +18 -2
  87. package/package.json +30 -15
  88. package/scripts/build-binary.ts +9 -9
  89. package/scripts/bundle-dist.ts +2 -2
  90. package/src/advisor/__tests__/advisor.test.ts +40 -4
  91. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  92. package/src/advisor/advise-tool.ts +1 -1
  93. package/src/advisor/emission-guard.ts +172 -0
  94. package/src/advisor/index.ts +1 -0
  95. package/src/advisor/runtime.ts +11 -0
  96. package/src/advisor/watchdog.ts +12 -1
  97. package/src/cli/args.ts +5 -2
  98. package/src/cli/auth-broker-cli.ts +17 -0
  99. package/src/cli/config-cli.ts +4 -0
  100. package/src/cli/flag-tables.ts +7 -4
  101. package/src/cli/gallery-cli.ts +14 -2
  102. package/src/cli/gallery-fixtures/edit.ts +60 -0
  103. package/src/cli/gallery-fixtures/fs.ts +17 -17
  104. package/src/cli/gallery-fixtures/search.ts +4 -4
  105. package/src/cli/usage-cli.ts +20 -1
  106. package/src/cli/web-search-cli.ts +1 -1
  107. package/src/collab/display-name.ts +13 -0
  108. package/src/collab/guest.ts +5 -1
  109. package/src/collab/host.ts +2 -13
  110. package/src/commands/worktree.ts +6 -0
  111. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  112. package/src/config/model-discovery.ts +77 -8
  113. package/src/config/model-registry.ts +89 -11
  114. package/src/config/model-resolver.ts +39 -1
  115. package/src/config/models-config-schema.ts +41 -5
  116. package/src/config/models-config.ts +4 -1
  117. package/src/config/settings-schema.ts +85 -27
  118. package/src/config/settings.ts +172 -1
  119. package/src/cursor.ts +1 -1
  120. package/src/dap/config.ts +152 -8
  121. package/src/dap/session.ts +1 -1
  122. package/src/discovery/helpers.ts +3 -1
  123. package/src/edit/hashline/diff.ts +14 -3
  124. package/src/edit/hashline/execute.ts +42 -6
  125. package/src/edit/hashline/filesystem.ts +38 -2
  126. package/src/edit/index.ts +1 -0
  127. package/src/edit/modes/patch.ts +11 -1
  128. package/src/edit/renderer.ts +140 -13
  129. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  130. package/src/eval/agent-bridge.ts +26 -3
  131. package/src/eval/js/tool-bridge.ts +2 -2
  132. package/src/export/html/index.ts +1 -1
  133. package/src/export/html/template.js +3 -1
  134. package/src/export/html/tool-views.generated.js +20 -20
  135. package/src/extensibility/extensions/types.ts +22 -22
  136. package/src/extensibility/hooks/types.ts +11 -11
  137. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
  138. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
  139. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  140. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  141. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  142. package/src/internal-urls/docs-index.generated.txt +2 -2
  143. package/src/internal-urls/docs-index.ts +2 -3
  144. package/src/internal-urls/index.ts +1 -0
  145. package/src/internal-urls/registry-helpers.ts +19 -4
  146. package/src/internal-urls/router.ts +5 -3
  147. package/src/internal-urls/ssh-protocol.ts +367 -0
  148. package/src/internal-urls/types.ts +19 -2
  149. package/src/irc/bus.ts +11 -3
  150. package/src/mcp/tool-bridge.ts +32 -2
  151. package/src/memories/index.ts +1 -1
  152. package/src/modes/acp/acp-event-mapper.ts +2 -2
  153. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  154. package/src/modes/components/agent-hub.ts +3 -0
  155. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  156. package/src/modes/components/custom-editor.ts +90 -5
  157. package/src/modes/components/move-overlay.ts +282 -0
  158. package/src/modes/components/plan-review-overlay.ts +35 -35
  159. package/src/modes/components/plugin-selector.ts +6 -1
  160. package/src/modes/components/queue-mode-selector.ts +6 -1
  161. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  162. package/src/modes/components/session-selector.ts +11 -10
  163. package/src/modes/components/settings-defs.ts +14 -1
  164. package/src/modes/components/settings-selector.ts +196 -29
  165. package/src/modes/components/show-images-selector.ts +6 -1
  166. package/src/modes/components/status-line/component.ts +108 -28
  167. package/src/modes/components/status-line/segments.ts +5 -1
  168. package/src/modes/components/status-line/types.ts +2 -0
  169. package/src/modes/components/theme-selector.ts +6 -1
  170. package/src/modes/components/thinking-selector.ts +6 -1
  171. package/src/modes/components/tool-execution.ts +25 -9
  172. package/src/modes/components/tree-selector.ts +5 -5
  173. package/src/modes/controllers/command-controller.ts +140 -47
  174. package/src/modes/controllers/event-controller.ts +59 -3
  175. package/src/modes/controllers/input-controller.ts +68 -2
  176. package/src/modes/controllers/selector-controller.ts +5 -2
  177. package/src/modes/controllers/streaming-reveal.ts +17 -0
  178. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  179. package/src/modes/interactive-mode.ts +116 -50
  180. package/src/modes/internal-url-autocomplete.ts +17 -2
  181. package/src/modes/prompt-action-autocomplete.ts +3 -1
  182. package/src/modes/running-subagent-badge.ts +13 -0
  183. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  184. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  185. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  186. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  187. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  188. package/src/modes/theme/theme.ts +9 -1
  189. package/src/modes/types.ts +9 -1
  190. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  191. package/src/priority.json +15 -0
  192. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  193. package/src/prompts/advisor/system.md +21 -7
  194. package/src/prompts/agents/designer.md +1 -1
  195. package/src/prompts/agents/explore.md +1 -1
  196. package/src/prompts/agents/librarian.md +2 -2
  197. package/src/prompts/agents/plan.md +2 -2
  198. package/src/prompts/agents/reviewer.md +1 -1
  199. package/src/prompts/agents/task.md +2 -2
  200. package/src/prompts/system/active-repo-context.md +4 -0
  201. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  202. package/src/prompts/system/plan-mode-active.md +3 -3
  203. package/src/prompts/system/project-prompt.md +2 -2
  204. package/src/prompts/system/system-prompt.md +6 -6
  205. package/src/prompts/tools/bash.md +2 -2
  206. package/src/prompts/tools/checkpoint.md +1 -1
  207. package/src/prompts/tools/{find.md → glob.md} +1 -1
  208. package/src/prompts/tools/{search.md → grep.md} +3 -3
  209. package/src/prompts/tools/read.md +4 -2
  210. package/src/sdk.ts +55 -25
  211. package/src/session/agent-session.ts +508 -132
  212. package/src/session/session-history-format.ts +2 -2
  213. package/src/session/session-manager.ts +46 -0
  214. package/src/slash-commands/builtin-registry.ts +138 -20
  215. package/src/slash-commands/helpers/usage-report.ts +23 -2
  216. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  217. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  218. package/src/ssh/connection-manager.ts +12 -15
  219. package/src/ssh/file-transfer.ts +209 -0
  220. package/src/ssh/utils.ts +24 -0
  221. package/src/system-prompt.ts +56 -28
  222. package/src/task/executor.ts +96 -36
  223. package/src/task/index.ts +3 -3
  224. package/src/task/render.ts +14 -13
  225. package/src/task/worktree.ts +38 -1
  226. package/src/tiny/text.ts +49 -4
  227. package/src/tiny/worker.ts +3 -3
  228. package/src/tools/bash.ts +2 -2
  229. package/src/tools/builtin-names.ts +26 -2
  230. package/src/tools/{find.ts → glob.ts} +48 -42
  231. package/src/tools/{search.ts → grep.ts} +311 -129
  232. package/src/tools/index.ts +13 -14
  233. package/src/tools/irc.ts +6 -2
  234. package/src/tools/path-utils.ts +64 -1
  235. package/src/tools/read.ts +35 -6
  236. package/src/tools/render-utils.ts +14 -0
  237. package/src/tools/renderers.ts +15 -4
  238. package/src/tools/ssh.ts +17 -2
  239. package/src/tools/todo.ts +4 -26
  240. package/src/tools/write.ts +25 -9
  241. package/src/utils/active-repo-context.ts +143 -0
  242. package/src/utils/image-resize.ts +88 -7
  243. package/src/utils/lang-from-path.ts +3 -3
  244. package/src/utils/markit-cache.ts +166 -0
  245. package/src/utils/markit.ts +86 -18
  246. package/src/utils/prompt-path.ts +3 -0
  247. package/src/utils/title-generator.ts +1 -1
  248. package/src/web/search/provider.ts +54 -34
  249. package/src/web/search/providers/duckduckgo.ts +140 -0
  250. package/src/web/search/providers/firecrawl.ts +144 -0
  251. package/src/web/search/providers/tinyfish.ts +159 -0
  252. package/src/web/search/providers/xai.ts +292 -0
  253. package/src/web/search/providers/zai.ts +142 -56
  254. package/src/web/search/types.ts +6 -2
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as fsp from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { Settings } from "../../../config/settings";
7
+ import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
8
+ import { MoveOverlay, type MoveOverlayResult, resolveExistingDirectory, resolveMovePath } from "../move-overlay";
9
+
10
+ // Strip SGR colors so assertions see visible text only.
11
+ const strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
12
+
13
+ describe("resolveMovePath", () => {
14
+ it("expands ~ to homedir", () => {
15
+ expect(resolveMovePath("~", "/anywhere")).toBe(os.homedir());
16
+ });
17
+ it("expands ~/sub to homedir/sub", () => {
18
+ expect(resolveMovePath("~/foo", "/anywhere")).toBe(path.join(os.homedir(), "foo"));
19
+ });
20
+ it("resolves relative paths against cwd", () => {
21
+ expect(resolveMovePath("foo/bar", "/parent")).toBe(path.resolve("/parent", "foo/bar"));
22
+ });
23
+ it("passes absolute paths through (normalized)", () => {
24
+ expect(resolveMovePath("/abs/path", "/anywhere")).toBe(path.normalize("/abs/path"));
25
+ });
26
+ });
27
+
28
+ describe("resolveExistingDirectory", () => {
29
+ let tmp: string;
30
+
31
+ beforeEach(async () => {
32
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-resolve-"));
33
+ });
34
+ afterEach(async () => {
35
+ await fsp.rm(tmp, { recursive: true, force: true });
36
+ });
37
+
38
+ it("returns the resolved path for an existing directory", () => {
39
+ const sub = path.join(tmp, "sub");
40
+ fs.mkdirSync(sub);
41
+ expect(resolveExistingDirectory(sub, "/anywhere")).toBe(path.resolve(sub));
42
+ });
43
+ it("returns null for a non-existent path", () => {
44
+ expect(resolveExistingDirectory(path.join(tmp, "nope"), "/anywhere")).toBeNull();
45
+ });
46
+ it("returns null for a file (not a directory)", () => {
47
+ const file = path.join(tmp, "file.txt");
48
+ fs.writeFileSync(file, "x");
49
+ expect(resolveExistingDirectory(file, "/anywhere")).toBeNull();
50
+ });
51
+ });
52
+
53
+ describe("MoveOverlay", () => {
54
+ let tmp: string;
55
+ let cwd: string;
56
+ let uiTheme: Theme;
57
+
58
+ beforeAll(async () => {
59
+ await Settings.init({ inMemory: true });
60
+ const loaded = await getThemeByName("dark");
61
+ if (!loaded) throw new Error("theme unavailable");
62
+ uiTheme = loaded;
63
+ setThemeInstance(uiTheme);
64
+ });
65
+
66
+ beforeEach(async () => {
67
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-"));
68
+ cwd = tmp;
69
+ fs.mkdirSync(path.join(tmp, "alpha"));
70
+ fs.mkdirSync(path.join(tmp, "beta"));
71
+ fs.mkdirSync(path.join(tmp, ".hidden"));
72
+ fs.writeFileSync(path.join(tmp, "file.txt"), "x");
73
+ });
74
+ afterEach(async () => {
75
+ await fsp.rm(tmp, { recursive: true, force: true });
76
+ });
77
+
78
+ it("renders a box with a title and input prompt", () => {
79
+ const overlay = new MoveOverlay(cwd, () => {});
80
+ const text = strip(overlay.render(80));
81
+ expect(text).toContain("Move to directory");
82
+ expect(text).toContain("Path:");
83
+ });
84
+
85
+ it("lists child directories (excluding hidden and files) on empty input", () => {
86
+ const overlay = new MoveOverlay(cwd, () => {});
87
+ const text = strip(overlay.render(80));
88
+ expect(text).toContain("alpha/");
89
+ expect(text).toContain("beta/");
90
+ expect(text).not.toContain(".hidden/");
91
+ expect(text).not.toContain("file.txt");
92
+ });
93
+
94
+ it("filters results as the user types", () => {
95
+ const overlay = new MoveOverlay(cwd, () => {});
96
+ overlay.handleInput("a");
97
+ overlay.handleInput("l");
98
+ const text = strip(overlay.render(80));
99
+ expect(text).toContain("alpha/");
100
+ expect(text).not.toContain("beta/");
101
+ });
102
+
103
+ it("shows dot directories after a dot prefix is typed", () => {
104
+ const overlay = new MoveOverlay(cwd, () => {});
105
+ overlay.handleInput(".");
106
+ const text = strip(overlay.render(80));
107
+ expect(text).toContain(".hidden/");
108
+ });
109
+
110
+ it("accepts bracketed paste and multi-byte input while filtering controls", () => {
111
+ let result: MoveOverlayResult | undefined;
112
+ const overlay = new MoveOverlay(cwd, r => {
113
+ result = r;
114
+ });
115
+ overlay.handleInput("\x1b[200~new\nø\x1b[201~");
116
+ overlay.handleInput("\r");
117
+ expect(result).toBeDefined();
118
+ expect(result!.directory).toBe("newø");
119
+ });
120
+
121
+ it("calls done with undefined on Escape", () => {
122
+ let result: MoveOverlayResult | undefined = "sentinel" as unknown as MoveOverlayResult;
123
+ const overlay = new MoveOverlay(cwd, r => {
124
+ result = r;
125
+ });
126
+ overlay.handleInput("\x1b");
127
+ expect(result).toBeUndefined();
128
+ });
129
+
130
+ it("calls done with the highlighted directory on Enter", () => {
131
+ let result: MoveOverlayResult | undefined;
132
+ const overlay = new MoveOverlay(cwd, r => {
133
+ result = r;
134
+ });
135
+ // First result should be "alpha/" (sorted alphabetically).
136
+ overlay.handleInput("\r");
137
+ expect(result).toBeDefined();
138
+ expect(result!.directory).toBe(path.join(cwd, "alpha"));
139
+ });
140
+
141
+ it("calls done with the typed path on Enter when no results match", () => {
142
+ let result: MoveOverlayResult | undefined;
143
+ const overlay = new MoveOverlay(cwd, r => {
144
+ result = r;
145
+ });
146
+ // Type a path that won't match any directory in cwd.
147
+ overlay.handleInput("z");
148
+ overlay.handleInput("z");
149
+ overlay.handleInput("\r");
150
+ expect(result).toBeDefined();
151
+ expect(result!.directory).toBe("zz");
152
+ });
153
+
154
+ it("Tab accepts the highlighted suggestion into the input", () => {
155
+ let result: MoveOverlayResult | undefined;
156
+ const overlay = new MoveOverlay(cwd, r => {
157
+ result = r;
158
+ });
159
+ overlay.handleInput("\t");
160
+ // After tab, the input should be the full path of the first result.
161
+ // Press Enter to confirm — the result should be the alpha directory.
162
+ overlay.handleInput("\r");
163
+ expect(result).toBeDefined();
164
+ expect(result!.directory).toBe(path.join(cwd, "alpha"));
165
+ });
166
+ });
@@ -425,6 +425,9 @@ export class AgentHubOverlayComponent extends Container {
425
425
  const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))];
426
426
  parts.push(theme.fg("dim", replaceTabs(ref.displayName)));
427
427
  parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind));
428
+ if (ref.kind === "advisor") {
429
+ parts.push(theme.fg("warning", "read-only"));
430
+ }
428
431
  const observed = this.#observableFor(ref.id);
429
432
  const task = observed?.description ?? observed?.progress?.task;
430
433
  if (task) {
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import * as fs from "node:fs";
17
17
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
18
- import { type Component, Editor, matchesKey, parseSgrMouse, ScrollView, type TUI } from "@oh-my-pi/pi-tui";
18
+ import { type Component, Editor, matchesKey, routeSgrMouseInput, ScrollView, type TUI } from "@oh-my-pi/pi-tui";
19
19
  import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
20
20
  import type { KeyId } from "../../config/keybindings";
21
21
  import type { MessageRenderer } from "../../extensibility/extensions/types";
@@ -413,12 +413,14 @@ export class AgentTranscriptViewer implements Component {
413
413
 
414
414
  handleInput(data: string): void {
415
415
  if (data.startsWith("\x1b[<")) {
416
- const event = parseSgrMouse(data);
417
- if (event?.wheel != null) {
418
- this.#scrollView.scroll(event.wheel * 3);
419
- this.#syncFollow();
420
- this.deps.requestRender();
421
- }
416
+ routeSgrMouseInput(data, event => {
417
+ if (event.wheel !== null) {
418
+ this.#scrollView.scroll(event.wheel * 3);
419
+ this.#syncFollow();
420
+ this.deps.requestRender();
421
+ }
422
+ return true;
423
+ });
422
424
  return;
423
425
  }
424
426
 
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
3
  import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
4
+ import { BracketedPasteHandler } from "@oh-my-pi/pi-tui/bracketed-paste";
4
5
  import type { AppKeybinding } from "../../config/keybindings";
5
6
  import { isSettingsInitialized, settings } from "../../config/settings";
6
7
  import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references";
@@ -229,6 +230,17 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
229
230
  return paths?.every(isImagePath) ? paths : undefined;
230
231
  }
231
232
 
233
+ /**
234
+ * Same shape as {@link extractBracketedImagePastePaths} but operates on a
235
+ * payload that has already been stripped of the `\x1b[200~` / `\x1b[201~`
236
+ * markers — used by the assembled-paste router in {@link CustomEditor.handleInput}
237
+ * so split bracketed pastes get the same image-path detection as single-chunk ones.
238
+ */
239
+ export function extractImagePastePathsFromText(text: string): string[] | undefined {
240
+ const paths = extractPastePathsFromText(text);
241
+ return paths?.every(isImagePath) ? paths : undefined;
242
+ }
243
+
232
244
  export function extractBracketedImagePastePath(data: string): string | undefined {
233
245
  const paths = extractBracketedImagePastePaths(data);
234
246
  return paths?.length === 1 ? paths[0] : undefined;
@@ -422,6 +434,19 @@ export class CustomEditor extends Editor {
422
434
  /** Custom key handlers from extensions and non-built-in app actions. */
423
435
  #customKeyHandlers = new Map<KeyId, () => void>();
424
436
  #customMatchKeys = new Map<string, () => void>();
437
+ /** Bracketed-paste assembler that runs ahead of the inherited handler so terminals which
438
+ * deliver `\x1b[200~` and `\x1b[201~` in separate stdin chunks still resolve to a single
439
+ * assembled payload here; the empty-paste / image-path branches must see the full content,
440
+ * not the raw single-chunk byte sequence. */
441
+ #pasteHandler = new BracketedPasteHandler();
442
+ /** Number of async pastes (clipboard-image reads / image-path attachments) currently in flight.
443
+ * While > 0, `handleInput` queues subsequent keystrokes into {@link #pendingInput} instead of
444
+ * dispatching them so a trailing `Enter` after `Cmd+V` can't submit before the image lands on
445
+ * `pendingImages` (Codex PR #3602 review). */
446
+ #pasteInFlight = 0;
447
+ /** Input chunks deferred behind an in-flight paste, drained in FIFO order once the paste
448
+ * count returns to zero. */
449
+ #pendingInput: string[] = [];
425
450
  /** Spaces actually inserted in the current run; tracked back out when a hold is recognized. */
426
451
  #spaceRunInserted = 0;
427
452
  /** Consecutive "mechanical" deltas (fast + steady); a sustained run of these confirms a held bar. */
@@ -575,7 +600,34 @@ export class CustomEditor extends Editor {
575
600
  this.onSpaceHoldEnd?.();
576
601
  }
577
602
 
603
+ /** Decrement {@link #pasteInFlight} once an async paste settles and, when the count returns
604
+ * to zero, drain {@link #pendingInput} through `handleInput` so requeueing still works if a
605
+ * drained chunk triggers another async paste. Bound member so it can be passed straight to
606
+ * `Promise.then(callback, callback)`. */
607
+ #onPasteSettled = (): void => {
608
+ this.#pasteInFlight--;
609
+ if (this.#pasteInFlight > 0) return;
610
+ const drained = this.#pendingInput.splice(0);
611
+ for (const chunk of drained) this.handleInput(chunk);
612
+ };
613
+
614
+ /** Track `promise` as an in-flight paste so subsequent `handleInput` calls queue behind it,
615
+ * then drain the queue once it settles. Codex PR #3602 review: without this, a trailing
616
+ * keystroke (Enter most painfully) in the same stdin read processes synchronously while the
617
+ * clipboard read is still pending — submit fires with the text but `pendingImages` is still
618
+ * empty and the image lands on the *next* draft instead. */
619
+ #trackAsyncPaste(promise: Promise<unknown>): void {
620
+ this.#pasteInFlight++;
621
+ void promise.then(this.#onPasteSettled, this.#onPasteSettled);
622
+ }
623
+
578
624
  handleInput(data: string): void {
625
+ // Serialize behind any in-flight async paste so a trailing Enter / follow-up key can't
626
+ // submit before the clipboard image reaches `pendingImages` (Codex PR #3602 review).
627
+ if (this.#pasteInFlight > 0) {
628
+ this.#pendingInput.push(data);
629
+ return;
630
+ }
579
631
  const kittyParsed = parseKittySequence(data);
580
632
  if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
581
633
  // Caps Lock is modifier bit 64
@@ -583,11 +635,44 @@ export class CustomEditor extends Editor {
583
635
  return;
584
636
  }
585
637
 
586
- const pastedImagePaths = extractBracketedImagePastePaths(data);
587
- if (pastedImagePaths && this.onPasteImagePath) {
588
- void (async () => {
589
- for (const path of pastedImagePaths) await this.onPasteImagePath?.(path);
590
- })();
638
+ // Bracketed-paste assembly. Some terminals fragment the start marker,
639
+ // the payload, and the end marker across separate stdin chunks
640
+ // (Windows Terminal under heavy load, certain SSH muxes, …); the
641
+ // inherited handler then sees a zero-length payload and silently
642
+ // drops it through the normal text-insert path. Running our own
643
+ // `BracketedPasteHandler` ahead of `super.handleInput` lets us route
644
+ // the assembled content regardless of chunk boundaries:
645
+ // - empty payload → `onPasteImage` (#3601: `Cmd+V`/`Ctrl+V` on an
646
+ // image-only macOS pasteboard the terminal stripped to `""` first);
647
+ // - explicit image-file paths → `onPasteImagePath` (#3506);
648
+ // - anything else → the base editor's `pasteText` so `[Paste #N]`
649
+ // markers, autocomplete, and undo state stay intact.
650
+ const paste = this.#pasteHandler.process(data);
651
+ if (paste.handled) {
652
+ if (paste.pasteContent === undefined) return; // still buffering — wait for end marker
653
+ const content = paste.pasteContent;
654
+ const remaining = paste.remaining;
655
+ // Queue any trailing bytes from the same read (typically a follow-up keystroke such as
656
+ // Enter that the user pressed right after Cmd+V) so they only fire *after* the paste
657
+ // completes — fixes the race where submit runs against an empty `pendingImages`.
658
+ if (remaining.length > 0) this.#pendingInput.push(remaining);
659
+ if (content.length === 0 && this.onPasteImage) {
660
+ this.#trackAsyncPaste(Promise.resolve(this.onPasteImage()));
661
+ return;
662
+ }
663
+ const imagePaths = extractImagePastePathsFromText(content);
664
+ if (imagePaths && this.onPasteImagePath) {
665
+ this.#trackAsyncPaste(
666
+ (async () => {
667
+ for (const p of imagePaths) await this.onPasteImagePath?.(p);
668
+ })(),
669
+ );
670
+ return;
671
+ }
672
+ this.pasteText(content);
673
+ // No async paste was started; drain the queued trailing bytes ourselves.
674
+ const drained = this.#pendingInput.splice(0);
675
+ for (const chunk of drained) this.handleInput(chunk);
591
676
  return;
592
677
  }
593
678
 
@@ -0,0 +1,282 @@
1
+ /**
2
+ * `/move` overlay: a path input with live directory autocomplete.
3
+ *
4
+ * Rendered as a centered modal via `showHookCustom(..., { overlay: true })`.
5
+ * The user types a path, Tab autocomtes the highlighted directory, and Enter
6
+ * confirms — yielding the resolved directory string (or `undefined` on cancel).
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import { type Component, CURSOR_MARKER, type Focusable, Key, matchesKey } from "@oh-my-pi/pi-tui";
12
+ import { theme } from "../theme/theme";
13
+ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
14
+ import { bottomBorder, row, topBorder } from "./overlay-box";
15
+
16
+ export interface MoveOverlayResult {
17
+ directory: string;
18
+ }
19
+
20
+ interface DirEntry {
21
+ /** Full absolute path. */
22
+ value: string;
23
+ /** Display label (basename + trailing slash). */
24
+ label: string;
25
+ }
26
+
27
+ const MAX_RESULTS = 15;
28
+ const OVERLAY_WIDTH = 68;
29
+
30
+ /** TTL for the directory listing cache (ms). */
31
+ const DIR_CACHE_TTL = 500;
32
+ const dirCache = new Map<string, { time: number; entries: string[] }>();
33
+
34
+ function readDirCached(dir: string): string[] {
35
+ const now = Date.now();
36
+ const cached = dirCache.get(dir);
37
+ if (cached && now - cached.time < DIR_CACHE_TTL) return cached.entries;
38
+ try {
39
+ const entries = fs.readdirSync(dir);
40
+ dirCache.set(dir, { time: now, entries });
41
+ return entries;
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function printableInput(data: string): string {
48
+ const withoutPasteEnvelope = data.replaceAll("\x1b[200~", "").replaceAll("\x1b[201~", "");
49
+ if (withoutPasteEnvelope.includes("\x1b")) return "";
50
+ return Array.from(withoutPasteEnvelope)
51
+ .filter(ch => {
52
+ const code = ch.codePointAt(0);
53
+ return code !== undefined && code >= 32 && code !== 0x7f;
54
+ })
55
+ .join("");
56
+ }
57
+
58
+ /** Resolve a user-typed path (`~`, absolute, or relative to `cwd`) to an absolute path. */
59
+ export function resolveMovePath(input: string, cwd: string): string {
60
+ const trimmed = input.trim();
61
+ if (trimmed === "~") return os.homedir();
62
+ if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
63
+ if (path.isAbsolute(trimmed)) return path.normalize(trimmed);
64
+ return path.resolve(cwd, trimmed);
65
+ }
66
+
67
+ /** If `input` resolves to an existing directory, return it; otherwise `null`. */
68
+ export function resolveExistingDirectory(input: string, cwd: string): string | null {
69
+ const resolved = resolveMovePath(input, cwd);
70
+ try {
71
+ return fs.statSync(resolved).isDirectory() ? resolved : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function listChildDirectories(dirPath: string, max: number, includeHidden = false): DirEntry[] {
78
+ const results: DirEntry[] = [];
79
+ const names = readDirCached(dirPath);
80
+ for (const name of names) {
81
+ if (results.length >= max) break;
82
+ if (!includeHidden && name.startsWith(".")) continue;
83
+ const full = path.join(dirPath, name);
84
+ try {
85
+ if (!fs.statSync(full).isDirectory()) continue;
86
+ } catch {
87
+ continue;
88
+ }
89
+ results.push({ value: full, label: `${name}/` });
90
+ }
91
+ results.sort((a, b) => a.label.localeCompare(b.label));
92
+ return results;
93
+ }
94
+
95
+ function searchDirectories(prefix: string, cwd: string, max: number): DirEntry[] {
96
+ if (!prefix) return listChildDirectories(cwd, max);
97
+
98
+ // Split into base dir + query so dot-prefixed segments can reveal hidden directories.
99
+ const norm = prefix.replace(/\\/g, "/");
100
+ const slashIdx = norm.lastIndexOf("/");
101
+ let baseDir: string;
102
+ let query: string;
103
+ if (slashIdx === -1) {
104
+ baseDir = cwd;
105
+ query = prefix;
106
+ } else {
107
+ const base = norm.slice(0, slashIdx + 1);
108
+ query = norm.slice(slashIdx + 1);
109
+ baseDir = resolveMovePath(base, cwd);
110
+ }
111
+
112
+ const includeHidden = query.startsWith(".");
113
+
114
+ // If the prefix already resolves to an existing directory, list its children.
115
+ // A dot-prefixed query is treated as a filter so hidden directories become reachable.
116
+ const resolved = includeHidden ? null : resolveExistingDirectory(prefix, cwd);
117
+ if (resolved) return listChildDirectories(resolved, max);
118
+
119
+ const lower = query.toLowerCase();
120
+ const results: DirEntry[] = [];
121
+ const names = readDirCached(baseDir);
122
+ for (const name of names) {
123
+ if (results.length >= max) break;
124
+ if (!includeHidden && name.startsWith(".")) continue;
125
+ const full = path.join(baseDir, name);
126
+ try {
127
+ if (!fs.statSync(full).isDirectory()) continue;
128
+ } catch {
129
+ continue;
130
+ }
131
+ if (!query || name.toLowerCase().includes(lower)) {
132
+ results.push({ value: full, label: `${name}/` });
133
+ }
134
+ }
135
+ return results;
136
+ }
137
+
138
+ /**
139
+ * Overlay component for `/move`: a single-line path input with a live-filtered
140
+ * list of matching directories. Tab accepts the highlighted suggestion; Enter
141
+ * confirms the current input (or the highlighted suggestion if the input is
142
+ * empty); Escape cancels.
143
+ */
144
+ export class MoveOverlay implements Component, Focusable {
145
+ #focused = false;
146
+ #input = "";
147
+ #cursor = 0;
148
+ #selectedIndex = 0;
149
+ #results: DirEntry[] = [];
150
+ #cwd: string;
151
+ #done: (result: MoveOverlayResult | undefined) => void;
152
+
153
+ constructor(cwd: string, done: (result: MoveOverlayResult | undefined) => void) {
154
+ this.#cwd = cwd;
155
+ this.#done = done;
156
+ // Warm the cache for the current directory so the first keystroke is instant.
157
+ readDirCached(cwd);
158
+ this.#updateResults();
159
+ }
160
+
161
+ get focused(): boolean {
162
+ return this.#focused;
163
+ }
164
+
165
+ set focused(value: boolean) {
166
+ this.#focused = value;
167
+ }
168
+
169
+ handleInput(data: string): void {
170
+ if (matchesSelectCancel(data) || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
171
+ this.#done(undefined);
172
+ return;
173
+ }
174
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
175
+ this.#confirm();
176
+ return;
177
+ }
178
+ if (matchesSelectUp(data) || matchesKey(data, Key.up)) {
179
+ if (this.#results.length > 0) this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
180
+ return;
181
+ }
182
+ if (matchesSelectDown(data) || matchesKey(data, Key.down)) {
183
+ if (this.#results.length > 0)
184
+ this.#selectedIndex = Math.min(this.#results.length - 1, this.#selectedIndex + 1);
185
+ return;
186
+ }
187
+ if (matchesKey(data, Key.tab)) {
188
+ const selected = this.#results[this.#selectedIndex];
189
+ if (selected) {
190
+ this.#input = selected.value;
191
+ this.#cursor = this.#input.length;
192
+ this.#selectedIndex = 0;
193
+ this.#updateResults();
194
+ }
195
+ return;
196
+ }
197
+ if (matchesKey(data, Key.left)) {
198
+ this.#cursor = Math.max(0, this.#cursor - 1);
199
+ return;
200
+ }
201
+ if (matchesKey(data, Key.right)) {
202
+ this.#cursor = Math.min(this.#input.length, this.#cursor + 1);
203
+ return;
204
+ }
205
+ if (matchesKey(data, Key.backspace) && this.#cursor > 0) {
206
+ this.#input = this.#input.slice(0, this.#cursor - 1) + this.#input.slice(this.#cursor);
207
+ this.#cursor--;
208
+ this.#selectedIndex = 0;
209
+ this.#updateResults();
210
+ return;
211
+ }
212
+ const text = printableInput(data);
213
+ if (text.length > 0) {
214
+ this.#input = this.#input.slice(0, this.#cursor) + text + this.#input.slice(this.#cursor);
215
+ this.#cursor += text.length;
216
+ this.#selectedIndex = 0;
217
+ this.#updateResults();
218
+ }
219
+ }
220
+
221
+ render(_width: number): readonly string[] {
222
+ const w = OVERLAY_WIDTH;
223
+ const lines: string[] = [];
224
+
225
+ lines.push(topBorder(w, "Move to directory"));
226
+ lines.push(row(this.#renderInput(), w));
227
+ lines.push(row("", w));
228
+
229
+ if (this.#results.length === 0 && this.#input.length > 0) {
230
+ lines.push(row(theme.fg("dim", "No matching directories"), w));
231
+ } else {
232
+ for (let i = 0; i < Math.min(this.#results.length, MAX_RESULTS); i++) {
233
+ const item = this.#results[i]!;
234
+ const selected = i === this.#selectedIndex;
235
+ const marker = selected ? theme.fg("accent", "▶ ") : " ";
236
+ const label = selected ? theme.fg("accent", item.label) : theme.fg("text", item.label);
237
+ lines.push(row(`${marker}${label}`, w));
238
+ }
239
+ }
240
+
241
+ lines.push(row("", w));
242
+ lines.push(row(theme.fg("dim", "Type to filter · ↑↓ navigate · Tab accept · Enter confirm · Esc cancel"), w));
243
+ lines.push(bottomBorder(w));
244
+ return lines;
245
+ }
246
+
247
+ invalidate(): void {}
248
+
249
+ #renderInput(): string {
250
+ const prompt = theme.fg("dim", "Path: ");
251
+ if (this.#input.length === 0) {
252
+ const placeholder = theme.fg("dim", "Type a directory path…");
253
+ const marker = this.#focused ? CURSOR_MARKER : "";
254
+ return `${prompt}${placeholder}${marker}\x1b[7m \x1b[27m`;
255
+ }
256
+ const before = this.#input.slice(0, this.#cursor);
257
+ const cursorChar = this.#cursor < this.#input.length ? this.#input[this.#cursor] : " ";
258
+ const after = this.#input.slice(this.#cursor + 1);
259
+ const marker = this.#focused ? CURSOR_MARKER : "";
260
+ return `${prompt}${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
261
+ }
262
+
263
+ #updateResults(): void {
264
+ this.#results = searchDirectories(this.#input, this.#cwd, MAX_RESULTS + 5);
265
+ if (this.#selectedIndex >= this.#results.length) {
266
+ this.#selectedIndex = Math.max(0, this.#results.length - 1);
267
+ }
268
+ }
269
+
270
+ #confirm(): void {
271
+ const selected = this.#results[this.#selectedIndex];
272
+ if (selected) {
273
+ this.#done({ directory: selected.value });
274
+ return;
275
+ }
276
+ if (this.#input.trim().length > 0) {
277
+ this.#done({ directory: this.#input.trim() });
278
+ return;
279
+ }
280
+ this.#done(undefined);
281
+ }
282
+ }