Merge remote-tracking branch 'origin/farm/d563ece8/fix-always-ask-settings-focus'
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user