@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.6

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 (207) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-x9zt79k8.md} +31 -0
  3. package/dist/cli.js +2989 -2978
  4. package/dist/types/capability/index.d.ts +1 -1
  5. package/dist/types/capability/types.d.ts +23 -1
  6. package/dist/types/config/settings-schema.d.ts +23 -1
  7. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  8. package/dist/types/internal-urls/parse.d.ts +12 -0
  9. package/dist/types/internal-urls/router.d.ts +6 -0
  10. package/dist/types/internal-urls/types.d.ts +6 -0
  11. package/dist/types/lsp/config.d.ts +1 -0
  12. package/dist/types/lsp/types.d.ts +2 -0
  13. package/dist/types/mcp/manager.d.ts +5 -0
  14. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  17. package/dist/types/sdk.d.ts +3 -1
  18. package/dist/types/session/agent-session-types.d.ts +3 -1
  19. package/dist/types/session/agent-session.d.ts +2 -0
  20. package/dist/types/session/model-controls.d.ts +3 -0
  21. package/dist/types/session/session-advisors.d.ts +7 -1
  22. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  23. package/dist/types/session/turn-recovery.d.ts +4 -2
  24. package/dist/types/task/index.d.ts +1 -1
  25. package/dist/types/task/types.d.ts +3 -11
  26. package/dist/types/thinking.d.ts +21 -2
  27. package/dist/types/utils/title-generator.d.ts +17 -16
  28. package/package.json +12 -12
  29. package/src/capability/index.ts +43 -12
  30. package/src/capability/mcp.ts +21 -0
  31. package/src/capability/types.ts +20 -1
  32. package/src/cli/read-cli.ts +44 -2
  33. package/src/config/settings-schema.ts +27 -1
  34. package/src/eval/py/runner.py +16 -2
  35. package/src/extensibility/extensions/runner.ts +91 -5
  36. package/src/extensibility/extensions/types.ts +0 -1
  37. package/src/extensibility/hooks/types.ts +0 -1
  38. package/src/internal-urls/mcp-protocol.ts +17 -3
  39. package/src/internal-urls/parse.ts +31 -0
  40. package/src/internal-urls/router.ts +24 -4
  41. package/src/internal-urls/types.ts +6 -0
  42. package/src/live/transport.ts +2 -2
  43. package/src/lsp/client.ts +2 -2
  44. package/src/lsp/config.ts +4 -0
  45. package/src/lsp/types.ts +2 -0
  46. package/src/mcp/config.ts +26 -14
  47. package/src/mcp/manager.ts +26 -9
  48. package/src/mcp/tool-bridge.ts +52 -1
  49. package/src/memories/index.ts +25 -6
  50. package/src/modes/components/status-line/segments.ts +3 -1
  51. package/src/modes/controllers/command-controller.ts +10 -10
  52. package/src/modes/controllers/extension-ui-controller.ts +3 -3
  53. package/src/modes/rpc/rpc-mode.ts +60 -47
  54. package/src/prompts/steering/user-interjection.md +2 -5
  55. package/src/prompts/tools/task.md +4 -2
  56. package/src/sdk.ts +17 -8
  57. package/src/session/agent-session-types.ts +3 -0
  58. package/src/session/agent-session.ts +21 -4
  59. package/src/session/model-controls.ts +43 -7
  60. package/src/session/session-advisors.ts +30 -14
  61. package/src/session/session-tools.ts +4 -2
  62. package/src/session/tool-choice-queue.ts +19 -4
  63. package/src/session/turn-recovery.ts +21 -2
  64. package/src/task/executor.ts +11 -3
  65. package/src/task/index.ts +43 -32
  66. package/src/task/types.ts +12 -17
  67. package/src/thinking.ts +68 -5
  68. package/src/tools/read.ts +2 -2
  69. package/src/utils/title-generator.ts +88 -34
  70. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  71. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  72. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  73. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  74. package/dist/types/cli/update-cli.test.d.ts +0 -1
  75. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  76. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  77. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  78. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  79. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  80. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  81. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  82. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  83. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  84. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  85. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  86. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  87. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  88. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  89. package/dist/types/hindsight/client.test.d.ts +0 -1
  90. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  91. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  92. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  93. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  94. package/dist/types/launch/protocol.test.d.ts +0 -1
  95. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  96. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  97. package/dist/types/live/protocol.test.d.ts +0 -1
  98. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  99. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  100. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  101. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  102. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  103. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  104. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  105. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  106. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  107. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  108. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  109. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  110. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  111. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  112. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  113. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  114. package/dist/types/modes/print-mode.test.d.ts +0 -1
  115. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  116. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  117. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  118. package/dist/types/modes/warp-events.test.d.ts +0 -1
  119. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  120. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  121. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  122. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  123. package/dist/types/session/blob-store.test.d.ts +0 -1
  124. package/dist/types/session/messages.test.d.ts +0 -1
  125. package/dist/types/session/session-context.test.d.ts +0 -1
  126. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  127. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  128. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  129. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  130. package/dist/types/system-prompt.test.d.ts +0 -1
  131. package/dist/types/task/render.test.d.ts +0 -1
  132. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  133. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  134. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  135. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  136. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  137. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  138. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  139. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  140. package/src/advisor/__tests__/config.test.ts +0 -349
  141. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  142. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  143. package/src/cli/update-cli.test.ts +0 -28
  144. package/src/config/__tests__/model-registry.test.ts +0 -182
  145. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  146. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  147. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  148. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  149. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  150. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  151. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  152. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  153. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  154. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  155. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  156. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  157. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  158. package/src/hindsight/client.test.ts +0 -75
  159. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  160. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  161. package/src/launch/broker-list-order.test.ts +0 -89
  162. package/src/launch/broker-output-snapshot.test.ts +0 -126
  163. package/src/launch/protocol.test.ts +0 -59
  164. package/src/launch/spawn-options.test.ts +0 -31
  165. package/src/launch/terminal-output.test.ts +0 -107
  166. package/src/live/protocol.test.ts +0 -140
  167. package/src/mcp/config-writer.test.ts +0 -43
  168. package/src/mcp/smithery-auth.test.ts +0 -29
  169. package/src/mcp/smithery-registry.test.ts +0 -51
  170. package/src/mcp/transports/stdio.test.ts +0 -427
  171. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  172. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  173. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  174. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  175. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  176. package/src/modes/components/custom-editor.test.ts +0 -510
  177. package/src/modes/components/login-dialog.test.ts +0 -56
  178. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  179. package/src/modes/components/status-line/component.test.ts +0 -84
  180. package/src/modes/components/tool-execution.test.ts +0 -162
  181. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  182. package/src/modes/noninteractive-dispose.test.ts +0 -73
  183. package/src/modes/print-mode.test.ts +0 -71
  184. package/src/modes/session-teardown.test.ts +0 -219
  185. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  186. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  187. package/src/modes/warp-events.test.ts +0 -794
  188. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  189. package/src/plan-mode/model-transition.test.ts +0 -60
  190. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  191. package/src/session/agent-session-error-log.test.ts +0 -59
  192. package/src/session/blob-store.test.ts +0 -56
  193. package/src/session/messages.test.ts +0 -282
  194. package/src/session/session-context.test.ts +0 -384
  195. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  196. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  197. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  198. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  199. package/src/system-prompt.test.ts +0 -236
  200. package/src/task/render.test.ts +0 -290
  201. package/src/task/spawn-policy.test.ts +0 -62
  202. package/src/tools/__tests__/eval-description.test.ts +0 -18
  203. package/src/tools/__tests__/glob.test.ts +0 -37
  204. package/src/tools/__tests__/json-tree.test.ts +0 -35
  205. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  206. package/src/tools/hub/launch-compat.test.ts +0 -40
  207. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -1,510 +0,0 @@
1
- import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
2
- import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
3
- import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
4
- import { $ } from "bun";
5
- import { getDefaultPasteImageKeys } from "../../config/keybindings";
6
- import { getEditorTheme, initTheme, theme } from "../theme/theme";
7
- import {
8
- CustomEditor,
9
- extractBracketedImagePastePaths,
10
- extractBracketedPastePaths,
11
- extractImagePastePathsFromText,
12
- extractImagePathFromText,
13
- extractPastePathsFromText,
14
- SPACE_HOLD_MECHANICAL_RUN,
15
- SPACE_HOLD_RELEASE_MS,
16
- SPACE_REPEAT_MAX_GAP_MS,
17
- } from "./custom-editor";
18
-
19
- function makeEditor() {
20
- const editor = new CustomEditor(getEditorTheme());
21
- const events: string[] = [];
22
- editor.sttHoldEnabled = () => true;
23
- editor.onSpaceHoldStart = () => events.push("start");
24
- editor.onSpaceHoldEnd = () => events.push("end");
25
- return { editor, events };
26
- }
27
-
28
- /** A gap below SPACE_REPEAT_MAX_GAP_MS — looks like OS key auto-repeat (a held bar). */
29
- const REPEAT_GAP_MS = 30;
30
- /** A gap above the threshold — looks like a deliberate keypress. */
31
- const TAP_GAP_MS = SPACE_REPEAT_MAX_GAP_MS + 80;
32
- const BRACKETED_PASTE_START = "\x1b[200~";
33
- const BRACKETED_PASTE_END = "\x1b[201~";
34
-
35
- function bracketedPaste(text: string): string {
36
- return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
37
- }
38
-
39
- /** Feed `count` spaces `gapMs` apart on the fake clock. The first space of a run has no prior
40
- * space, so its gap is effectively infinite and it always reads as a deliberate tap. */
41
- function feedSpaces(editor: CustomEditor, count: number, gapMs: number): void {
42
- for (let i = 0; i < count; i++) {
43
- vi.advanceTimersByTime(gapMs);
44
- editor.handleInput(" ");
45
- }
46
- }
47
-
48
- /** Feed spaces at explicit per-press gaps (ms) on the fake clock — for simulating an irregular cadence. */
49
- function feedGaps(editor: CustomEditor, gaps: number[]): void {
50
- for (const gapMs of gaps) {
51
- vi.advanceTimersByTime(gapMs);
52
- editor.handleInput(" ");
53
- }
54
- }
55
-
56
- async function decorateInFreshProcess(text: string, imageLinks?: readonly string[]): Promise<string> {
57
- const customEditorUrl = new URL("./custom-editor.ts", import.meta.url).href;
58
- const script = `
59
- import { CustomEditor } from ${JSON.stringify(customEditorUrl)};
60
- const editor = new CustomEditor({});
61
- editor.imageLinks = ${JSON.stringify(imageLinks)};
62
- process.stdout.write(editor.decorateText(${JSON.stringify(text)}));
63
- `;
64
- const child = await $`bun -e ${script}`.quiet().nothrow();
65
- const stdout = child.stdout.toString();
66
- const stderr = child.stderr.toString();
67
- if (child.exitCode !== 0) throw new Error(stderr || stdout || `decorate subprocess exited with ${child.exitCode}`);
68
- return stdout;
69
- }
70
-
71
- describe("CustomEditor placeholder decoration", () => {
72
- it("renders paste placeholders before theme initialization", async () => {
73
- const output = await decorateInFreshProcess("[Paste #1, +30 lines]");
74
- expect(output).toBe("[Paste #1, +30 lines]");
75
- });
76
-
77
- it("renders linked image placeholders before theme and settings initialization", async () => {
78
- const output = await decorateInFreshProcess("[Image #1]", ["/tmp/example.png"]);
79
- expect(output).toBe("[Image #1]");
80
- });
81
- });
82
-
83
- describe("CustomEditor queue shorthand decoration", () => {
84
- beforeAll(async () => {
85
- await initTheme();
86
- });
87
-
88
- it("reserves the first line as soon as either queue prefix is completed", () => {
89
- for (const prefix of ["->", "=>"]) {
90
- const editor = new CustomEditor(getEditorTheme());
91
- editor.handleInput(prefix[0] ?? "");
92
- expect(editor.getText()).toBe(prefix[0]);
93
-
94
- editor.handleInput(prefix[1] ?? "");
95
- expect(editor.getText()).toBe(`${prefix}\n`);
96
- expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
97
-
98
- editor.handleInput("\x7f");
99
- expect(editor.getText()).toBe(`${prefix}\n`);
100
- expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
101
- }
102
- });
103
-
104
- it("renders the reserved line as a dim Queueing header", () => {
105
- for (const prefix of ["->", "=>"]) {
106
- const editor = new CustomEditor(getEditorTheme());
107
- editor.setText(`${prefix}\nqueue this`);
108
-
109
- expect(editor.decorateText(prefix)).toBe(theme.fg("dim", `Queueing ${theme.nav.selected}`));
110
- editor.focused = true;
111
- const rendered = editor.render(40).map(line => Bun.stripANSI(line.replace(CURSOR_MARKER, "")));
112
- expect(rendered.some(line => line.includes(`Queueing ${theme.nav.selected}`))).toBe(true);
113
- expect(rendered.every(line => Bun.stringWidth(line) === 40)).toBe(true);
114
- expect(rendered.some(line => line.includes("queue this"))).toBe(true);
115
- }
116
- });
117
-
118
- it("highlights dot and parenthesis markers only for detected queue lists", () => {
119
- for (const [input, marker] of [
120
- ["=>\n1. first\n2. second", "1."],
121
- ["=>\n1) first\n2) second", "1)"],
122
- ]) {
123
- const editor = new CustomEditor(getEditorTheme());
124
- editor.setText(input);
125
- expect(editor.decorateText(`${marker} first`).startsWith(theme.fg("accent", marker))).toBe(true);
126
- }
127
-
128
- const unfinished = new CustomEditor(getEditorTheme());
129
- unfinished.setText("=>\n1. first\n2. second\n3. third\n4.");
130
- expect(unfinished.decorateText("1. first").startsWith(theme.fg("accent", "1."))).toBe(true);
131
- expect(unfinished.decorateText("4.").startsWith(theme.fg("accent", "4."))).toBe(true);
132
-
133
- const editor = new CustomEditor(getEditorTheme());
134
- editor.setText("=>\n1. first\n3. third");
135
- expect(editor.decorateText("1. first")).toBe("1. first");
136
- });
137
- });
138
-
139
- describe("CustomEditor bracketed path paste", () => {
140
- it("leaves a pasted bare .png filename on the normal text path", () => {
141
- expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
142
- });
143
-
144
- it("extracts explicit local image paths for attachment", () => {
145
- expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/icon-photo-default.png"))).toEqual([
146
- "/tmp/icon-photo-default.png",
147
- ]);
148
- expect(extractBracketedImagePastePaths(bracketedPaste("C:\\Users\\me\\icon-photo-default.png"))).toEqual([
149
- "C:\\Users\\me\\icon-photo-default.png",
150
- ]);
151
- });
152
-
153
- it("strips `file://` URLs to the local filesystem path before loading the image", () => {
154
- // macOS / Ghostty / iTerm2 sometimes forward the pasteboard's
155
- // `public.file-url` representation when the user does Finder→Copy
156
- // then Cmd+V. Without decoding, `loadImageInput` would try to read a
157
- // literal `file:///…` path and fail.
158
- expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/Pictures/photo.png"))).toEqual([
159
- "/Users/me/Pictures/photo.png",
160
- ]);
161
- });
162
-
163
- it("percent-decodes spaces inside `file://` URLs", () => {
164
- expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/My%20Pictures/photo.png"))).toEqual([
165
- "/Users/me/My Pictures/photo.png",
166
- ]);
167
- });
168
-
169
- it("extracts explicit non-image paths without classifying them as image paths", () => {
170
- expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
171
- expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
172
- });
173
-
174
- it("inserts non-image path pastes as literal text instead of attaching them", () => {
175
- const { editor } = makeEditor();
176
- let imagePathCalls = 0;
177
- editor.onPasteImagePath = () => {
178
- imagePathCalls++;
179
- };
180
-
181
- editor.handleInput(bracketedPaste("/tmp/report.csv"));
182
-
183
- expect(editor.getText()).toBe("/tmp/report.csv");
184
- expect(imagePathCalls).toBe(0);
185
- });
186
-
187
- it("attaches a spaced screenshot path as an image instead of inserting it as literal text", () => {
188
- // #6578: the raw macOS "copy screenshot path" payload has unescaped
189
- // spaces, which defeats the segment splitter. Before the whole-text
190
- // fallback the paste degraded to literal text in the prompt.
191
- const { editor } = makeEditor();
192
- const screenshot = "/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png";
193
- const pasted: string[] = [];
194
- editor.onPasteImagePath = path => {
195
- pasted.push(path);
196
- };
197
-
198
- editor.handleInput(bracketedPaste(screenshot));
199
-
200
- expect(pasted).toEqual([screenshot]);
201
- expect(editor.getText()).toBe("");
202
- });
203
-
204
- it("keeps a two-file drag with unescaped spaces as text instead of attaching one fused path", () => {
205
- // PR #6582 review: selecting two screenshots and dropping them together
206
- // emits a single space-separated payload the splitter also refuses
207
- // (`PM.png` carries no directory). Fusing it into one path attaches
208
- // nothing — `handleImagePathPaste` hits ENOENT and only shows a status,
209
- // never restoring the text — so the drop must degrade to a text paste.
210
- const { editor } = makeEditor();
211
- const dropped =
212
- "/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png /Users/me/Desktop/Screenshot 2026-07-24 at 1.56.00 PM.png";
213
- const pasted: string[] = [];
214
- editor.onPasteImagePath = path => {
215
- pasted.push(path);
216
- };
217
-
218
- editor.handleInput(bracketedPaste(dropped));
219
-
220
- expect(pasted).toEqual([]);
221
- expect(editor.getText()).toBe(dropped);
222
- });
223
- });
224
- describe("CustomEditor configured paste image keys", () => {
225
- it("routes Ghostty Cmd+V kitty key events through the macOS image-paste default", () => {
226
- const { editor } = makeEditor();
227
- const onPasteImage = vi.fn();
228
- editor.onPasteImage = onPasteImage;
229
- editor.setActionKeys("app.clipboard.pasteImage", getDefaultPasteImageKeys("darwin"));
230
- setKittyProtocolActive(true);
231
-
232
- try {
233
- editor.handleInput("\x1b[118;9u");
234
- } finally {
235
- setKittyProtocolActive(false);
236
- }
237
-
238
- expect(onPasteImage).toHaveBeenCalledTimes(1);
239
- expect(editor.getText()).toBe("");
240
- });
241
- });
242
-
243
- describe("extractImagePathFromText (issue #3506)", () => {
244
- it("returns the path when the text is a single image file path", () => {
245
- expect(extractImagePathFromText("/tmp/screenshot.png")).toBe("/tmp/screenshot.png");
246
- expect(extractImagePathFromText("/Users/me/Pictures/photo.jpeg")).toBe("/Users/me/Pictures/photo.jpeg");
247
- expect(extractImagePathFromText("C:\\Users\\me\\img.gif")).toBe("C:\\Users\\me\\img.gif");
248
- });
249
-
250
- it("ignores surrounding whitespace from a clipboard read", () => {
251
- expect(extractImagePathFromText(" /tmp/photo.webp\n")).toBe("/tmp/photo.webp");
252
- });
253
-
254
- it("returns undefined for a bare filename (no explicit directory)", () => {
255
- // Mirrors the bracketed-paste contract: a bare `.png` filename is
256
- // almost always a project-relative reference the user wants as text,
257
- // not a clipboard-anchored attachment.
258
- expect(extractImagePathFromText("icon.png")).toBeUndefined();
259
- });
260
-
261
- it("returns undefined for non-image extensions", () => {
262
- expect(extractImagePathFromText("/tmp/report.csv")).toBeUndefined();
263
- expect(extractImagePathFromText("/tmp/notes.txt")).toBeUndefined();
264
- });
265
-
266
- it("returns undefined when the text contains anything beyond a single path", () => {
267
- expect(extractImagePathFromText("see /tmp/screenshot.png")).toBeUndefined();
268
- expect(extractImagePathFromText("/tmp/a.png /tmp/b.png")).toBeUndefined();
269
- });
270
-
271
- it("returns undefined for empty/whitespace-only input", () => {
272
- expect(extractImagePathFromText("")).toBeUndefined();
273
- expect(extractImagePathFromText(" ")).toBeUndefined();
274
- });
275
-
276
- it("decodes a `file://` URL to its filesystem path", () => {
277
- expect(extractImagePathFromText("file:///Users/me/Pictures/photo.png")).toBe("/Users/me/Pictures/photo.png");
278
- });
279
-
280
- it("recovers a single anchored image path containing unescaped spaces (macOS screenshot name)", () => {
281
- const macScreenshot = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png";
282
- expect(extractImagePathFromText(macScreenshot)).toBe(macScreenshot);
283
- expect(extractImagePathFromText("~/Pictures/Cleanshot 2026-06-25 at 12.00.png")).toBe(
284
- "~/Pictures/Cleanshot 2026-06-25 at 12.00.png",
285
- );
286
- expect(extractImagePathFromText("C:\\Users\\me\\My Pictures\\img with space.jpg")).toBe(
287
- "C:\\Users\\me\\My Pictures\\img with space.jpg",
288
- );
289
- });
290
-
291
- it("returns undefined for two spaced paths the splitter could not separate", () => {
292
- // Only the whole-text pass survives the splitter here, and it must not
293
- // fuse the pair into one path the loader can never resolve.
294
- expect(extractImagePathFromText("/tmp/a.png /tmp/b shot.png")).toBeUndefined();
295
- });
296
-
297
- it("does not hijack prose that happens to contain a path-shaped fragment", () => {
298
- // The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
299
- // non-anchored prefix ("see ...") never triggers it.
300
- expect(extractImagePathFromText("see /Users/me/Desktop/Screenshot 1.png")).toBeUndefined();
301
- });
302
- });
303
-
304
- describe("extractPastePathsFromText", () => {
305
- it("delegates to the same logic the bracketed variant uses for path detection", () => {
306
- expect(extractPastePathsFromText("/tmp/a.png /tmp/b.png")).toEqual(["/tmp/a.png", "/tmp/b.png"]);
307
- expect(extractPastePathsFromText("just text")).toBeUndefined();
308
- });
309
- });
310
-
311
- describe("extractImagePastePathsFromText (issue #6578)", () => {
312
- const MAC_SCREENSHOT =
313
- "/var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.55.12 PM.png";
314
- const WINDOWS_SPACED = "C:\\Users\\me\\My Pictures\\shot 1.png";
315
-
316
- // Every case must resolve identically on the stripped-marker route
317
- // (assembled pastes) and the bracketed route, since the latter now
318
- // delegates to the former.
319
- const cases: { name: string; text: string; expected: string[] | undefined }[] = [
320
- { name: "a macOS screenshot path with unescaped spaces", text: MAC_SCREENSHOT, expected: [MAC_SCREENSHOT] },
321
- { name: "a Windows drive path with unescaped spaces", text: WINDOWS_SPACED, expected: [WINDOWS_SPACED] },
322
- {
323
- name: "a home-anchored path with unescaped spaces",
324
- text: "~/Pictures/Cleanshot 2026-07-24 at 12.00.png",
325
- expected: ["~/Pictures/Cleanshot 2026-07-24 at 12.00.png"],
326
- },
327
- {
328
- name: "a shell-escaped spaced path",
329
- text: "/tmp/My\\ Photos/shot\\ 1.png",
330
- expected: ["/tmp/My Photos/shot 1.png"],
331
- },
332
- {
333
- name: "a double-quoted spaced path",
334
- text: '"/tmp/My Photos/shot 1.png"',
335
- expected: ["/tmp/My Photos/shot 1.png"],
336
- },
337
- { name: "a spaced path with a non-image extension", text: "/tmp/my report 2026.csv", expected: undefined },
338
- {
339
- // Ends in a real image extension, so only the absolute-prefix
340
- // anchor keeps the whole-text fallback from swallowing the prose.
341
- name: "prose ending in a path-shaped fragment",
342
- text: "see /Users/me/Desktop/Screen Shot 1.png",
343
- expected: undefined,
344
- },
345
- {
346
- name: "two spaced image paths on separate lines",
347
- text: "/tmp/a shot.png\n/tmp/b shot.png",
348
- expected: undefined,
349
- },
350
- {
351
- name: "a bare spaced filename with no leading separator",
352
- text: "Screenshot 2026-07-24 at 1.55.12 PM.png",
353
- expected: undefined,
354
- },
355
- {
356
- name: "two POSIX paths dragged together when one has unescaped spaces",
357
- text: "/tmp/a.png /tmp/b shot.png",
358
- expected: undefined,
359
- },
360
- {
361
- name: "two macOS screenshots dragged together",
362
- text: `${MAC_SCREENSHOT} /var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.56.00 PM.png`,
363
- expected: undefined,
364
- },
365
- {
366
- name: "two home-anchored paths with unescaped spaces",
367
- text: "~/a.png ~/Pictures/b shot.png",
368
- expected: undefined,
369
- },
370
- {
371
- name: "two Windows drive paths with unescaped spaces",
372
- text: `C:\\Users\\me\\a.png ${WINDOWS_SPACED}`,
373
- expected: undefined,
374
- },
375
- {
376
- name: "two `file://` URLs with unescaped spaces",
377
- text: "file:///tmp/a.png file:///tmp/b shot.png",
378
- expected: undefined,
379
- },
380
- {
381
- name: "two UNC paths with unescaped spaces",
382
- text: "\\\\srv\\share\\a.png \\\\srv\\share\\b shot.png",
383
- expected: undefined,
384
- },
385
- {
386
- name: "a tab-separated pair of dragged paths",
387
- text: "/tmp/a.png\t/tmp/b shot.png",
388
- expected: undefined,
389
- },
390
- {
391
- name: "an absolute path followed by a dot-relative path with unescaped spaces",
392
- text: "/tmp/a.png ./b shot.png",
393
- expected: undefined,
394
- },
395
- {
396
- name: "an absolute path followed by a parent-relative path with unescaped spaces",
397
- text: "/tmp/a.png ../pics/b shot.png",
398
- expected: undefined,
399
- },
400
- {
401
- name: "a Windows drive path followed by a dot-relative path with unescaped spaces",
402
- text: "C:\\Users\\me\\a.png .\\b shot.png",
403
- expected: undefined,
404
- },
405
- {
406
- // The interior `Photos/shot` token after an unescaped space is the
407
- // shape of a spaced directory name, not of a second dragged path —
408
- // this is why bare relatives are not multi-path anchors.
409
- name: "a single path with an unescaped spaced directory name",
410
- text: "/Users/me/My Photos/shot 1.png",
411
- expected: ["/Users/me/My Photos/shot 1.png"],
412
- },
413
- {
414
- // The escape asserts the space belongs to the path, so the `/sub`
415
- // that follows is a component rather than a second drag payload.
416
- name: "a path whose escaped space precedes a slash-led component",
417
- text: "/tmp/odd dir\\ /sub/a b.png",
418
- expected: ["/tmp/odd dir /sub/a b.png"],
419
- },
420
- {
421
- // Splitter-success path: both segments are explicit, so the
422
- // whole-text pass never runs and the pair still attaches as two.
423
- name: "two explicit image paths the splitter can separate",
424
- text: "/tmp/a.png /tmp/b.png",
425
- expected: ["/tmp/a.png", "/tmp/b.png"],
426
- },
427
- ];
428
-
429
- for (const { name, text, expected } of cases) {
430
- it(`${expected ? "recovers" : "rejects"} ${name} on both paste routes`, () => {
431
- expect(extractImagePastePathsFromText(text)).toEqual(expected);
432
- expect(extractBracketedImagePastePaths(bracketedPaste(text))).toEqual(expected);
433
- });
434
- }
435
- });
436
-
437
- describe("CustomEditor space-hold push-to-talk", () => {
438
- beforeAll(async () => {
439
- await initTheme();
440
- });
441
-
442
- beforeEach(() => {
443
- vi.useFakeTimers();
444
- });
445
-
446
- afterEach(() => {
447
- vi.useRealTimers();
448
- });
449
-
450
- it("types deliberate space taps without triggering, even several in a row", () => {
451
- const { editor, events } = makeEditor();
452
- feedSpaces(editor, 3, TAP_GAP_MS);
453
- expect(editor.getText()).toBe(" ");
454
- expect(events).toEqual([]);
455
- });
456
-
457
- it("recognizes a held bar from a steady fast cadence and tracks back the burst", () => {
458
- const { editor, events } = makeEditor();
459
- editor.handleInput("h");
460
- editor.handleInput("i");
461
- // Metronomic auto-repeat: the few pre-burst spaces typed are tracked back out when the hold is
462
- // recognized, leaving only the pre-burst text.
463
- feedSpaces(editor, SPACE_HOLD_MECHANICAL_RUN + 2, REPEAT_GAP_MS);
464
- expect(editor.getText()).toBe("hi");
465
- expect(events).toEqual(["start"]);
466
- // Continued auto-repeat while the bar is held is swallowed: no spam, no re-trigger.
467
- feedSpaces(editor, 5, REPEAT_GAP_MS);
468
- expect(editor.getText()).toBe("hi");
469
- expect(events).toEqual(["start"]);
470
- // An idle gap with no further repeats means the bar was released -> stop + transcribe.
471
- vi.advanceTimersByTime(SPACE_HOLD_RELEASE_MS + 1);
472
- expect(events).toEqual(["start", "end"]);
473
- });
474
-
475
- it("does not trigger when the space bar is smashed at an irregular cadence", () => {
476
- const { editor, events } = makeEditor();
477
- // Fast but jittery, the way a human mashes — not the metronomic delta of OS auto-repeat.
478
- const gaps = [40, 95, 45, 100, 35, 90, 50, 105];
479
- feedGaps(editor, gaps);
480
- expect(events).toEqual([]);
481
- // Nothing is eaten: every smashed space still types a real space.
482
- expect(editor.getText()).toBe(" ".repeat(gaps.length));
483
- });
484
-
485
- it("does not trigger on steady but slow spacing", () => {
486
- const { editor, events } = makeEditor();
487
- // Even cadence, but slower than auto-repeat: consistent deltas alone must not start recording.
488
- feedSpaces(editor, 6, TAP_GAP_MS);
489
- expect(events).toEqual([]);
490
- expect(editor.getText()).toBe(" ".repeat(6));
491
- });
492
-
493
- it("does not trigger when a non-space breaks the run", () => {
494
- const { editor, events } = makeEditor();
495
- // Each partial run climbs the mechanical counter one short of the threshold; the non-space
496
- // resets it so they never combine into a hold.
497
- feedSpaces(editor, 3, REPEAT_GAP_MS);
498
- editor.handleInput("x");
499
- feedSpaces(editor, 3, REPEAT_GAP_MS);
500
- expect(events).toEqual([]);
501
- });
502
-
503
- it("leaves the space bar typing normally when the gesture is disabled", () => {
504
- const { editor, events } = makeEditor();
505
- editor.sttHoldEnabled = () => false;
506
- feedSpaces(editor, 8, REPEAT_GAP_MS);
507
- expect(editor.getText()).toBe(" ".repeat(8));
508
- expect(events).toEqual([]);
509
- });
510
- });
@@ -1,56 +0,0 @@
1
- import { beforeAll, describe, expect, it } from "bun:test";
2
- import type { TUI } from "@oh-my-pi/pi-tui";
3
- import { initTheme } from "../theme/theme";
4
- import { LoginDialogComponent } from "./login-dialog";
5
-
6
- /** Minimal TUI stub — the dialog only calls requestRender/setFocus. */
7
- function makeDialog(): LoginDialogComponent {
8
- const tui = { requestRender() {}, setFocus() {} } as unknown as TUI;
9
- return new LoginDialogComponent(tui, "openai-codex", () => {});
10
- }
11
-
12
- describe("LoginDialogComponent manual code input", () => {
13
- beforeAll(async () => {
14
- await initTheme();
15
- });
16
-
17
- it("captures a pasted fallback redirect URL and resolves on submit", async () => {
18
- // Regression for #5339: paste-code providers (Codex) route the fallback
19
- // URL through the focused dialog. Without a mounted input, the paste is
20
- // dropped and login never completes.
21
- const dialog = makeDialog();
22
- dialog.showProgress("Waiting for callback");
23
-
24
- const pending = dialog.showManualInput("Paste the authorization code:");
25
- expect(dialog.render(80).join("\n")).toContain("Paste the authorization code");
26
-
27
- const url = "http://localhost:1455/auth/callback?code=THECODE&state=abc";
28
- dialog.pasteText(url);
29
- dialog.handleInput("\r");
30
-
31
- expect(await pending).toBe(url);
32
- });
33
-
34
- it("reuses the mounted input across re-prompts instead of stacking duplicates", async () => {
35
- // The OAuth callback loop re-invokes onManualCodeInput after an invalid
36
- // paste; the second prompt must not append a duplicate input/hint block.
37
- const dialog = makeDialog();
38
- dialog.showProgress("Waiting for callback");
39
-
40
- const first = dialog.showManualInput("Paste the code:");
41
- dialog.handleInput("garbage");
42
- dialog.handleInput("\r");
43
- expect(await first).toBe("garbage");
44
-
45
- const second = dialog.showManualInput("Paste the code:");
46
- const rendered = dialog.render(80).join("\n");
47
- expect(rendered.split("Paste the code:").length - 1).toBe(1);
48
- // A stale value from the first attempt must not leak into the retry.
49
- expect(rendered).not.toContain("garbage");
50
-
51
- const url = "http://localhost:1455/auth/callback?code=OK&state=abc";
52
- dialog.pasteText(url);
53
- dialog.handleInput("\r");
54
- expect(await second).toBe(url);
55
- });
56
- });