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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user