diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 99b745097..ca76281cc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed TUI prompts beginning with shell-style variables such as `$HOME` being misrouted to Python eval; Python shortcuts now require `$ ` or `$$ `. ([#2944](https://github.com/can1357/oh-my-pi/issues/2944)) + ## [16.0.6] - 2026-06-18 ### Added diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 921d5516b..5ec88340d 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -44,6 +44,26 @@ function hasPasteText(value: unknown): value is PasteTarget { return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function"; } +function pythonCommandPrefixLength(trimmedText: string): 0 | 1 | 2 { + if (trimmedText.charCodeAt(0) !== 36 /* $ */) return 0; + if (trimmedText.charCodeAt(1) === 123 /* { */) return 0; + + const prefixLength = trimmedText.charCodeAt(1) === 36 /* $ */ ? 2 : 1; + const next = trimmedText.charCodeAt(prefixLength); + if (Number.isNaN(next)) return prefixLength; + return next === 32 || next === 9 || next === 10 || next === 13 ? prefixLength : 0; +} + +function parsePythonCommandInput(text: string): { code: string; isExcluded: boolean } | undefined { + const trimmed = text.trimStart(); + const prefixLength = pythonCommandPrefixLength(trimmed); + if (prefixLength === 0) return undefined; + return { + code: trimmed.slice(prefixLength).trim(), + isExcluded: prefixLength === 2, + }; +} + /** Wrap pasted text in `` tags so the model treats it as one quoted block. */ function wrapPasteInAttachmentBlock(content: string): string { return `\n${content}\n`; @@ -381,8 +401,8 @@ export class InputController { const wasBashMode = this.ctx.isBashMode; const wasPythonMode = this.ctx.isPythonMode; const trimmed = text.trimStart(); - this.ctx.isBashMode = text.trimStart().startsWith("!"); - this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${"); + this.ctx.isBashMode = trimmed.startsWith("!"); + this.ctx.isPythonMode = pythonCommandPrefixLength(trimmed) > 0; if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) { this.ctx.updateEditorBorderColor(); } @@ -550,7 +570,7 @@ export class InputController { this.ctx.editor.setText(""); return; } - if (text.startsWith("!") || text.startsWith("$")) { + if (text.startsWith("!") || parsePythonCommandInput(text)) { this.ctx.showStatus("Local execution is host-only during a collab session"); this.ctx.editor.setText(""); return; @@ -598,10 +618,11 @@ export class InputController { } } - // Handle python command ($ for normal, $$ for excluded from context) - if (text.startsWith("$")) { - const isExcluded = text.startsWith("$$"); - const code = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); + // Handle python command (`$ ` for normal, `$$ ` for excluded from context). + // Shell-style variables such as `$HOME` are normal prose unless a space follows the sigil. + const pythonCommand = parsePythonCommandInput(text); + if (pythonCommand) { + const { code, isExcluded } = pythonCommand; if (code) { if (this.ctx.session.isEvalRunning) { this.ctx.showWarning("A Python execution is already running. Press Esc to cancel it first."); @@ -768,7 +789,7 @@ export class InputController { } return; } - if (text.startsWith("/") || text.startsWith("!") || text.startsWith("$")) { + if (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text)) { this.ctx.showStatus("Commands run in the main session — press ←← to return first"); return; // editor text not cleared: Editor does not auto-clear on submit } diff --git a/packages/coding-agent/test/input-controller-python-prefix.test.ts b/packages/coding-agent/test/input-controller-python-prefix.test.ts new file mode 100644 index 000000000..f57239f65 --- /dev/null +++ b/packages/coding-agent/test/input-controller-python-prefix.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "bun:test"; +import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; + +type FakeEditor = { + onSubmit?: (text: string) => Promise; + imageLinks?: readonly (string | undefined)[]; + setText(text: string): void; + getText(): string; + addToHistory(text: string): void; + setActionKeys(action: string, keys: string[]): void; + setCustomKeyHandler(key: string, handler: () => void): void; + clearCustomKeyHandlers(): void; +}; + +function createContext() { + let editorText = ""; + const submitted: unknown[] = []; + const handlePythonCommand = vi.fn(async (_code: string, _isExcluded: boolean) => {}); + const handleBashCommand = vi.fn(async (_command: string, _isExcluded: boolean) => {}); + const startPendingSubmission = vi.fn((submission: unknown) => submission); + const onInputCallback = vi.fn((submission: unknown) => submitted.push(submission)); + const prompt = vi.fn(async (_text: string, _options?: unknown) => {}); + + const editor: FakeEditor = { + setText(text) { + editorText = text; + }, + getText() { + return editorText; + }, + addToHistory: vi.fn(), + setActionKeys: vi.fn(), + setCustomKeyHandler: vi.fn(), + clearCustomKeyHandlers: vi.fn(), + }; + + const ctx = { + editor: editor as unknown as InteractiveModeContext["editor"], + ui: { requestRender: vi.fn() } as unknown as InteractiveModeContext["ui"], + session: { + isStreaming: false, + isCompacting: false, + isBashRunning: false, + isEvalRunning: false, + extensionRunner: undefined, + prompt, + queuedMessageCount: 0, + getQueuedMessages: () => ({ steering: [], followUp: [] }), + } as unknown as InteractiveModeContext["session"], + sessionManager: { getSessionName: () => "named-session" } as unknown as InteractiveModeContext["sessionManager"], + pendingImages: [] as InteractiveModeContext["pendingImages"], + pendingImageLinks: [] as InteractiveModeContext["pendingImageLinks"], + compactionQueuedMessages: [] as InteractiveModeContext["compactionQueuedMessages"], + locallySubmittedUserSignatures: new Set(), + onInputCallback, + startPendingSubmission, + updatePendingMessagesDisplay: vi.fn(), + flushPendingBashComponents: vi.fn(), + updateEditorBorderColor: vi.fn(), + showError: vi.fn(), + showWarning: vi.fn(), + showStatus: vi.fn(), + isBashMode: false, + isPythonMode: false, + fileSlashCommands: new Set(), + isKnownSlashCommand: () => false, + handlePythonCommand, + handleBashCommand, + withLocalSubmission: async (_text: string, fn: () => Promise) => fn(), + } as unknown as InteractiveModeContext; + + return { + ctx, + editor, + handlePythonCommand, + onInputCallback, + startPendingSubmission, + submitted, + }; +} + +describe("InputController Python prompt prefix", () => { + it("submits leading shell-variable prose as a normal prompt", async () => { + const { ctx, editor, handlePythonCommand, onInputCallback, startPendingSubmission, submitted } = createContext(); + const controller = new InputController(ctx); + controller.setupEditorSubmitHandler(); + + await editor.onSubmit?.("$HOME is home"); + + expect(handlePythonCommand).not.toHaveBeenCalled(); + expect(startPendingSubmission).toHaveBeenCalledWith({ + text: "$HOME is home", + images: undefined, + imageLinks: undefined, + streamingBehavior: "steer", + }); + expect(onInputCallback).toHaveBeenCalledTimes(1); + expect(submitted).toEqual([ + { + text: "$HOME is home", + images: undefined, + imageLinks: undefined, + streamingBehavior: "steer", + }, + ]); + }); + + it("keeps space-separated Python shortcuts available", async () => { + const { ctx, editor, handlePythonCommand, onInputCallback } = createContext(); + const controller = new InputController(ctx); + controller.setupEditorSubmitHandler(); + + await editor.onSubmit?.("$ print(1)"); + + expect(handlePythonCommand).toHaveBeenCalledWith("print(1)", false); + expect(onInputCallback).not.toHaveBeenCalled(); + }); + + it("keeps excluded Python shortcuts space-separated too", async () => { + const { ctx, editor, handlePythonCommand, onInputCallback } = createContext(); + const controller = new InputController(ctx); + controller.setupEditorSubmitHandler(); + + await editor.onSubmit?.("$$ print(1)"); + + expect(handlePythonCommand).toHaveBeenCalledWith("print(1)", true); + expect(onInputCallback).not.toHaveBeenCalled(); + }); +});