fix(tui): required space after python prompt sigil

Treat shell-style leading variables such as $HOME as normal chat text instead of Python eval input. Keep local Python shortcuts available through explicit $ <code> and $$ <code> prefixes.\n\nFixes #2944
This commit is contained in:
roboomp
2026-06-18 07:39:15 +00:00
parent bd9418f142
commit 6cd16d6373
3 changed files with 163 additions and 8 deletions
+4
View File
@@ -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 `$ <code>` or `$$ <code>`. ([#2944](https://github.com/can1357/oh-my-pi/issues/2944))
## [16.0.6] - 2026-06-18
### Added
@@ -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 `<attachment>` tags so the model treats it as one quoted block. */
function wrapPasteInAttachmentBlock(content: string): string {
return `<attachment>\n${content}\n</attachment>`;
@@ -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 (`$ <code>` for normal, `$$ <code>` 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
}
@@ -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<void>;
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<string>(),
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<string>(),
isKnownSlashCommand: () => false,
handlePythonCommand,
handleBashCommand,
withLocalSubmission: async (_text: string, fn: () => Promise<unknown>) => 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();
});
});