Merge remote-tracking branch 'origin/farm/d563ece8/fix-always-ask-settings-focus'

This commit is contained in:
can1357
2026-06-16 21:46:24 +02:00
4 changed files with 210 additions and 8 deletions
@@ -1,4 +1,12 @@
import { type Component, matchesKey, padding, parseSgrMouse, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import {
type Component,
matchesKey,
type OverlayFocusOwner,
padding,
parseSgrMouse,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { APP_NAME } from "@oh-my-pi/pi-utils";
import { gradientLogo, PI_LOGO } from "../components/welcome";
import { theme } from "../theme/theme";
@@ -53,7 +61,7 @@ function dissolveFrames(from: string[], to: string[], progress: number, height:
return out;
}
export class SetupWizardComponent implements Component {
export class SetupWizardComponent implements Component, OverlayFocusOwner {
#phase: WizardPhase = "splash";
#phaseStartedAt = performance.now();
#sceneIndex = 0;
@@ -63,6 +71,7 @@ export class SetupWizardComponent implements Component {
#disposed = false;
/** Screen row where the active scene's body began in the last rendered frame. */
#bodyRowStart = 0;
#sceneFocusTarget: Component | undefined;
constructor(
readonly ctx: InteractiveModeContext,
@@ -87,6 +96,11 @@ export class SetupWizardComponent implements Component {
this.#activeScene?.invalidate?.();
}
ownsOverlayFocusTarget(component: Component): boolean {
if (this.#sceneFocusTarget !== component) return false;
return true;
}
handleInput(data: string): void {
if (this.#phase === "done") return;
if (data.startsWith("\x1b[<")) {
@@ -260,12 +274,19 @@ export class SetupWizardComponent implements Component {
ctx: this.ctx,
requestRender: () => this.ctx.ui.requestRender(),
finish: (_result: SetupSceneResult) => this.#finishScene(),
setFocus: component => this.ctx.ui.setFocus(component),
restoreFocus: () => this.ctx.ui.setFocus(this),
setFocus: component => {
this.#sceneFocusTarget = component ?? undefined;
this.ctx.ui.setFocus(component);
},
restoreFocus: () => {
this.#sceneFocusTarget = undefined;
this.ctx.ui.setFocus(this);
},
};
this.#activeScene = scene.mount(host);
this.#phase = targetPhase;
this.#phaseStartedAt = performance.now();
this.#sceneFocusTarget = undefined;
this.ctx.ui.setFocus(this);
void this.#activeScene.onMount?.();
this.ctx.ui.requestRender();
@@ -288,6 +309,7 @@ export class SetupWizardComponent implements Component {
}
#unmountActiveScene(): void {
this.#sceneFocusTarget = undefined;
this.#activeScene?.onUnmount?.();
this.#activeScene?.dispose?.();
this.#activeScene = undefined;
+4
View File
@@ -14,6 +14,10 @@
- Fixed inline math span detection so escaped dollars and currency-like patterns (such as `$5` and `$10`) are not converted as math
- Fixed Mermaid diagram rendering in Markdown code blocks to clip each ASCII line to content width before wrapping, preventing preformatted diagram rows from fragmenting
### Fixed
- Fixed fullscreen overlays losing keyboard focus to hidden prompt surfaces, which could make settings unresponsive while a background approval request was pending ([#2789](https://github.com/can1357/oh-my-pi/issues/2789)).
## [16.0.2] - 2026-06-16
### Fixed
+25 -4
View File
@@ -171,6 +171,12 @@ export interface Component {
dispose?(): void;
}
/** Lets an overlay root delegate keyboard focus to components it owns. */
export interface OverlayFocusOwner {
/** Returns true when `component` is a focus target inside this overlay. */
ownsOverlayFocusTarget(component: Component): boolean;
}
/**
* Component seam for append-only native-scrollback commits. A component that
* renders a finalized prefix followed by a live/mutating suffix reports the
@@ -221,6 +227,13 @@ function setNativeScrollbackCommittedRows(component: Component, rows: number): v
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
}
function isOverlayFocusTarget(owner: Component, component: Component | null): boolean {
if (component === owner) return true;
if (!component) return false;
const candidate = owner as Component & Partial<OverlayFocusOwner>;
return candidate.ownsOverlayFocusTarget?.(component) === true;
}
function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
}
@@ -1171,6 +1184,14 @@ export class TUI extends Container {
}
setFocus(component: Component | null): void {
const topVisibleOverlay = this.#getTopmostVisibleOverlay();
if (topVisibleOverlay && !isOverlayFocusTarget(topVisibleOverlay.component, component)) {
const currentFocus = this.#focusedComponent;
component = isOverlayFocusTarget(topVisibleOverlay.component, currentFocus)
? currentFocus
: topVisibleOverlay.component;
}
const previousFocusedComponent = this.#focusedComponent;
// Clear focused flag on old component
if (isFocusable(previousFocusedComponent)) {
@@ -1213,8 +1234,8 @@ export class TUI extends Container {
const index = this.overlayStack.indexOf(entry);
if (index !== -1) {
this.overlayStack.splice(index, 1);
// Restore focus if this overlay had focus
if (this.#focusedComponent === component) {
// Restore focus if this overlay or one of its owned targets had focus
if (isOverlayFocusTarget(component, this.#focusedComponent)) {
const topVisible = this.#getTopmostVisibleOverlay();
this.setFocus(topVisible?.component ?? entry.preFocus);
}
@@ -1230,8 +1251,8 @@ export class TUI extends Container {
entry.hidden = hidden;
// Update focus when hiding/showing
if (hidden) {
// If this overlay had focus, move focus to next visible or preFocus
if (this.#focusedComponent === component) {
// If this overlay or one of its owned targets had focus, move focus to next visible or preFocus
if (isOverlayFocusTarget(component, this.#focusedComponent)) {
const topVisible = this.#getTopmostVisibleOverlay();
this.setFocus(topVisible?.component ?? entry.preFocus);
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "bun:test";
import { type Component, type Focusable, type OverlayFocusOwner, TUI } from "@oh-my-pi/pi-tui";
import type { Terminal, TerminalAppearance } from "@oh-my-pi/pi-tui/terminal";
class MinimalTerminal implements Terminal {
columns = 80;
rows = 24;
kittyProtocolActive = false;
kittyEnableSequence: string | null = null;
appearance: TerminalAppearance | undefined;
#onInput: ((data: string) => void) | undefined;
#onResize: (() => void) | undefined;
output = "";
cursorHidden = false;
cursorTransitions = 0;
start(onInput: (data: string) => void, onResize: () => void): void {
this.#onInput = onInput;
this.#onResize = onResize;
}
stop(): void {
this.#onInput = undefined;
this.#onResize = undefined;
}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
write(data: string): void {
this.output += data;
if (data.length === 0) this.output += "";
}
moveBy(_lines: number): void {}
hideCursor(): void {
this.cursorHidden = true;
this.cursorTransitions += 1;
}
showCursor(): void {
this.cursorHidden = false;
this.cursorTransitions += 1;
}
clearLine(): void {}
clearFromCursor(): void {}
clearScreen(): void {}
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
onAppearanceChange(_callback: (appearance: TerminalAppearance) => void): void {}
sendInput(data: string): void {
const onInput = this.#onInput;
if (onInput) onInput(data);
}
emitResize(): void {
const onResize = this.#onResize;
if (onResize) onResize();
}
}
class FocusRecorder implements Component, Focusable {
focused = false;
inputs: string[] = [];
lastInput = "";
constructor(readonly label: string) {}
handleInput(data: string): void {
this.inputs.push(data);
this.lastInput = data;
}
render(_width: number): string[] {
const suffix = this.focused ? "-focused" : "";
return [`${this.label}${suffix}`];
}
}
class OwningOverlay extends FocusRecorder implements OverlayFocusOwner {
focusTarget: Component | undefined;
ownsOverlayFocusTarget(component: Component): boolean {
if (component !== this.focusTarget) return false;
return true;
}
}
describe("TUI overlay focus", () => {
it("keeps keyboard focus on the visible overlay when a hidden surface requests focus", () => {
const terminal = new MinimalTerminal();
const tui = new TUI(terminal);
const editor = new FocusRecorder("editor");
const settingsOverlay = new FocusRecorder("settings");
const approvalPrompt = new FocusRecorder("approval");
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
tui.showOverlay(settingsOverlay, { fullscreen: true });
tui.setFocus(approvalPrompt);
terminal.sendInput("x");
expect(tui.getFocused()).toBe(settingsOverlay);
expect(settingsOverlay.inputs).toEqual(["x"]);
expect(approvalPrompt.inputs).toEqual([]);
} finally {
tui.stop();
}
});
it("allows a visible overlay to delegate focus to an owned prompt", () => {
const terminal = new MinimalTerminal();
const tui = new TUI(terminal);
const editor = new FocusRecorder("editor");
const wizardOverlay = new OwningOverlay("wizard");
const authorizationCodeInput = new FocusRecorder("code");
const approvalPrompt = new FocusRecorder("approval");
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
tui.showOverlay(wizardOverlay, { fullscreen: true });
wizardOverlay.focusTarget = authorizationCodeInput;
tui.setFocus(authorizationCodeInput);
terminal.sendInput("code");
expect(tui.getFocused()).toBe(authorizationCodeInput);
expect(authorizationCodeInput.inputs).toEqual(["code"]);
expect(wizardOverlay.inputs).toEqual([]);
tui.setFocus(approvalPrompt);
terminal.sendInput("still-code");
expect(tui.getFocused()).toBe(authorizationCodeInput);
expect(authorizationCodeInput.inputs).toEqual(["code", "still-code"]);
expect(approvalPrompt.inputs).toEqual([]);
} finally {
tui.stop();
}
});
});