fix(tui): make ctrl+o expand tool output regardless of focus

app.tools.expand (Ctrl+O) was wired only through the editor's input path,
so when a tool-approval prompt or other selection dialog took keyboard
focus the key was delivered to that component and never reached the expand
handler — a large truncated edit could not be expanded while the user was
deciding whether to approve or deny it.

Promote the shortcut to a global TUI input listener (matching the existing
debug and branch/copy shortcuts) so it fires regardless of focus. It defers
when the main transcript is not the active surface (a fullscreen/anchored
overlay: agent hub, transcript viewer, log viewer, model picker) or when the
focused component rebinds Ctrl+O for its own use (the tree selector's filter
cycle). The editor-scoped handler is removed as a clean cutover.

Fixes #7837
This commit is contained in:
roboomp
2026-08-06 13:54:19 +00:00
parent 3a8591a8af
commit 42322041c4
4 changed files with 95 additions and 12 deletions
@@ -1,7 +1,10 @@
import { describe, expect, it, type Mock, vi } from "bun:test";
import { beforeAll, describe, expect, it, type Mock, vi } from "bun:test";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import type { SessionTreeNode } from "@oh-my-pi/pi-coding-agent/session/session-entries";
import { type KeyId, matchesKey } from "@oh-my-pi/pi-tui";
import manualContinuePrompt from "../src/prompts/system/manual-continue.md" with { type: "text" };
@@ -64,6 +67,7 @@ async function createContext() {
"app.retry": ["alt+r"],
"app.clipboard.pasteImage": ["ctrl+v"],
"app.tools.toggleVisibility": ["ctrl+shift+o"],
"app.tools.expand": ["ctrl+o"],
};
const customHandlers = new Map<string, () => void>();
const setActionKeys = vi.fn();
@@ -79,6 +83,7 @@ async function createContext() {
const requestRender = vi.fn();
const showError = vi.fn();
let focused: unknown;
let overlayVisible = false;
const addInputListener = vi.fn((listener: InputListener) => {
void listener;
});
@@ -149,6 +154,7 @@ async function createContext() {
addInputListener,
addStartListener,
getFocused: vi.fn(() => focused),
hasOverlay: vi.fn(() => overlayVisible),
terminal: { write: terminalWrite, refreshAppearance },
} as unknown as InteractiveModeContext["ui"],
loadingAnimation: undefined,
@@ -230,6 +236,9 @@ async function createContext() {
setFocused(target: unknown) {
focused = target;
},
setOverlayVisible(visible: boolean) {
overlayVisible = visible;
},
spies: {
setActionKeys,
showModelSelector,
@@ -661,3 +670,65 @@ describe("InputController keybinding setup", () => {
}
});
});
describe("InputController global tool-output expand (ctrl+o)", () => {
const CTRL_O = "\x0f";
beforeAll(async () => {
await initTheme(false);
});
async function setup() {
const context = await createContext();
const controller = new context.InputController(context.ctx);
controller.setupKeyHandlers();
return { ...context, listeners: registeredInputListeners(context.spies.addInputListener) };
}
it("toggles tool-output expansion when a non-editor prompt holds focus (#7837)", async () => {
const { ctx, listeners, setFocused } = await setup();
// An approval / select prompt owns keyboard focus, not the editor.
setFocused({ handleInput() {} });
expect(ctx.toolOutputExpanded).toBe(false);
expect(dispatchInput(listeners, CTRL_O)).toEqual({ consume: true });
expect(ctx.toolOutputExpanded).toBe(true);
});
it("still toggles when the editor holds focus", async () => {
const { ctx, listeners } = await setup();
// The editor is the default focus target in the harness.
expect(dispatchInput(listeners, CTRL_O)).toEqual({ consume: true });
expect(ctx.toolOutputExpanded).toBe(true);
});
it("defers while a fullscreen/anchored overlay owns the surface", async () => {
const { ctx, listeners, setOverlayVisible } = await setup();
setOverlayVisible(true);
expect(dispatchInput(listeners, CTRL_O)).toBeUndefined();
expect(ctx.toolOutputExpanded).toBe(false);
});
it("defers to the tree selector's own ctrl+o filter cycle", async () => {
const { ctx, listeners, setFocused } = await setup();
const tree = [
{
entry: { id: "root", type: "message", parentId: null, message: { role: "user", content: "hi" } },
children: [],
},
] as unknown as SessionTreeNode[];
setFocused(
new TreeSelectorComponent(
tree,
"root",
20,
() => {},
() => {},
),
);
expect(dispatchInput(listeners, CTRL_O)).toBeUndefined();
expect(ctx.toolOutputExpanded).toBe(false);
});
});