From 0d5cacfad532e0e2d05280a68af2ade22bf2aaa7 Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Fri, 23 Oct 2026 13:40:23 +0000 Subject: [PATCH] Let a programmatic runCommand leave focus where it is After any command the terminal re-focuses its input. That suits typing, but a Run button that calls runCommand() pulled focus off the button too, which on a touch device opens the keyboard and makes the browser zoom and scroll. runCommand now takes { focus?: boolean }. The default is unchanged; callers that are Run buttons pass { focus: false }. Co-Authored-By: Claude Sonnet 5.5 --- .../simulator/src/react/MockTerminal.tsx | 29 ++++++-- app/packages/simulator/src/react/index.ts | 1 + .../test/react/MockTerminal.test.tsx | 69 +++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/app/packages/simulator/src/react/MockTerminal.tsx b/app/packages/simulator/src/react/MockTerminal.tsx index 6ed112f..29f4afb 100644 --- a/app/packages/simulator/src/react/MockTerminal.tsx +++ b/app/packages/simulator/src/react/MockTerminal.tsx @@ -25,10 +25,22 @@ export interface TerminalEvent { type: "state" | "reset"; } +/** Options for {@link MockTerminalHandle.runCommand}. */ +export interface RunCommandOptions { + /** Re-focus the terminal input after the command completes. Default true. */ + focus?: boolean; +} + /** Imperative API for driving the terminal from outside (Run / Save buttons). */ export interface MockTerminalHandle { - /** Runs a command line as if it were typed at the prompt. */ - runCommand: (text: string) => void; + /** + * Runs a command line as if it were typed at the prompt. By default the + * input is re-focused once the command completes, as it is after a typed + * command. Pass `{ focus: false }` when the caller is a Run button: focus + * stays where it is, so on a touch device the keyboard does not open and + * the page does not zoom or jump away from the button that was pressed. + */ + runCommand: (text: string, options?: RunCommandOptions) => void; /** Writes content to a path in the virtual filesystem. */ saveFile: (path: string, content: string) => void; } @@ -217,6 +229,8 @@ export const MockTerminal = forwardRef( const scrollRef = useRef(null); const inputRef = useRef(null); const prevBusyRef = useRef(false); + // Set for the one run that must leave focus where it is (see runCommand). + const skipRefocusRef = useRef(false); const completionRef = useRef<{ candidates: string[]; index: number; @@ -316,9 +330,9 @@ export const MockTerminal = forwardRef( // in the submit() finally block because setBusy(false) only queues a render — // the input is still disabled when finally runs, so focus() would be a no-op. // This effect fires after React commits the busy=false update, when the input - // is already re-enabled. + // is already re-enabled. A run started with `focus: false` skips it. useEffect(() => { - if (prevBusyRef.current && !busy) { + if (prevBusyRef.current && !busy && !skipRefocusRef.current) { inputRef.current?.focus(); } prevBusyRef.current = busy; @@ -585,7 +599,7 @@ export const MockTerminal = forwardRef( // Runs a raw line through the terminal exactly as if it had been typed and // submitted. Shared by the Enter handler and the imperative runCommand(). const runLine = useCallback( - async (raw: string) => { + async (raw: string, { focus = true }: RunCommandOptions = {}) => { if (busy || !simulator) return; const line = raw.trim(); @@ -609,6 +623,7 @@ export const MockTerminal = forwardRef( // While collecting input, every line is a value (empty included); only // the abort words are special. Dispatch to the input handler. if (inInput) { + skipRefocusRef.current = !focus; setBusy(true); try { await runInputTurn(line, currentMode); @@ -627,6 +642,7 @@ export const MockTerminal = forwardRef( return; } + skipRefocusRef.current = !focus; setBusy(true); try { if (currentMode.kind === "session") { @@ -787,7 +803,8 @@ export const MockTerminal = forwardRef( useImperativeHandle( ref, () => ({ - runCommand: (text: string) => void runLine(text), + runCommand: (text: string, options?: RunCommandOptions) => + void runLine(text, options), saveFile, }), [runLine, saveFile], diff --git a/app/packages/simulator/src/react/index.ts b/app/packages/simulator/src/react/index.ts index 96412a1..156af96 100644 --- a/app/packages/simulator/src/react/index.ts +++ b/app/packages/simulator/src/react/index.ts @@ -20,6 +20,7 @@ export { MockTerminal } from "./MockTerminal"; export type { MockTerminalHandle, MockTerminalProps, + RunCommandOptions, TerminalChange, TerminalEvent, } from "./MockTerminal"; diff --git a/app/packages/simulator/test/react/MockTerminal.test.tsx b/app/packages/simulator/test/react/MockTerminal.test.tsx index 6e55332..8d04516 100644 --- a/app/packages/simulator/test/react/MockTerminal.test.tsx +++ b/app/packages/simulator/test/react/MockTerminal.test.tsx @@ -210,6 +210,75 @@ scenarios: }); }); +describe("MockTerminal focus after a run", () => { + /* + The refocus is a reaction to busy going true and then false, so a run has to + take real time for React to commit both. That needs streaming on (the + fixture turns it off) and the run outside `act`, which would batch the two + renders into one. Waiting for the input to come back says the run is over. + */ + const TypingTerminal = ({ + handle, + }: { + handle: React.RefObject; + }) => ( + + ); + + const run = ( + ref: React.RefObject, + line: string, + options?: { focus?: boolean }, + ) => { + ref.current!.runCommand(line, options); + }; + + it("re-focuses the input after a command by default", async () => { + const ref = createRef(); + render(); + run(ref, "docker ps"); + await screen.findByText("abc123 nginx"); + const input = await screen.findByLabelText("terminal input"); + await waitFor(() => expect(document.activeElement).toBe(input)); + }); + + it("leaves focus alone when the run asks not to take it", async () => { + const ref = createRef(); + render( +
+ + +
, + ); + const button = screen.getByRole("button", { name: "Run" }); + button.focus(); + run(ref, "docker ps", { focus: false }); + await screen.findByText("abc123 nginx"); + await screen.findByLabelText("terminal input"); + expect(document.activeElement).toBe(button); + }); + + it("does not let a skipped refocus leak into the next run", async () => { + const ref = createRef(); + render(); + run(ref, "docker ps", { focus: false }); + await screen.findByText("abc123 nginx"); + await screen.findByLabelText("terminal input"); + // Two output lines, so the run outlasts a render and the input unmounts. + run(ref, "docker ps"); + await waitFor(() => + expect(screen.getAllByText("abc123 nginx")).toHaveLength(2), + ); + const input = await screen.findByLabelText("terminal input"); + await waitFor(() => expect(document.activeElement).toBe(input)); + }); +}); + describe("MockTerminal persistence", () => { // The behaviour this refactor introduced: persistence is opt-in via an // explicit key, so an embedded terminal on a docs page starts clean.