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