From 42322041c43c5e3a3fe3d736500fca50140efe5a Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 6 Aug 2026 13:54:19 +0000 Subject: [PATCH] fix(tui): make ctrl+o expand tool output regardless of focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/components/custom-editor.ts | 9 --- .../src/modes/controllers/input-controller.ts | 21 +++++- .../test/input-controller-keybindings.test.ts | 73 ++++++++++++++++++- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bd81a6f64..76fee3975 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Ctrl+O (`app.tools.expand`) not expanding truncated tool output while a tool-approval prompt or other selection dialog held keyboard focus, by promoting the shortcut to a global input listener that fires regardless of focus (it still defers to fullscreen overlays and the tree selector's own Ctrl+O filter cycle) ([#7837](https://github.com/can1357/oh-my-pi/issues/7837)). + ## [17.2.10] - 2026-08-06 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 9110fc6fe..c7c3e090c 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -30,7 +30,6 @@ type ConfigurableEditorAction = Extract< | "app.model.cycleBackward" | "app.model.select" | "app.model.selectTemporary" - | "app.tools.expand" | "app.tools.toggleVisibility" | "app.thinking.toggle" | "app.editor.external" @@ -53,7 +52,6 @@ const DEFAULT_ACTION_KEYS: Record = { "app.model.cycleBackward": ["shift+ctrl+p"], "app.model.select": ["alt+m"], "app.model.selectTemporary": ["alt+p"], - "app.tools.expand": ["ctrl+o"], "app.tools.toggleVisibility": ["ctrl+shift+o"], "app.thinking.toggle": ["ctrl+t"], "app.editor.external": ["ctrl+g"], @@ -551,7 +549,6 @@ export class CustomEditor extends Editor { onCycleModelForward?: () => void; onCycleModelBackward?: () => void; onSelectModel?: () => void; - onExpandTools?: () => void; onToggleToolActivity?: () => void; onToggleThinking?: () => void; onExternalEditor?: () => void; @@ -907,12 +904,6 @@ export class CustomEditor extends Editor { return; } - // Intercept configured tool output expansion shortcut - if (this.#matchesAction(canonical, "app.tools.expand") && this.onExpandTools) { - this.onExpandTools(); - return; - } - // Intercept configured tool activity visibility toggle if (this.#matchesAction(canonical, "app.tools.toggleVisibility") && this.onToggleToolActivity) { this.onToggleToolActivity(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 6b337cca1..a8d45bb29 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -13,6 +13,7 @@ import { renderSegmentTrack } from "../../modes/components/segment-track"; import { StrippedToolCallsPlaceholder } from "../../modes/components/stripped-tool-calls-placeholder"; import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress"; import { ToolExecutionComponent } from "../../modes/components/tool-execution"; +import { TreeSelectorComponent } from "../../modes/components/tree-selector"; import { expandEmoticons } from "../../modes/emoji-autocomplete"; import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references"; import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete"; @@ -177,6 +178,7 @@ export class InputController { #focusedPasteListenerInstalled = false; #btwBranchListenerInstalled = false; #btwCopyListenerInstalled = false; + #expandToolsListenerInstalled = false; // Tap counter for the double-← gesture; reset whenever a quiet gap // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See // #detectLeftDoubleTap. @@ -278,6 +280,23 @@ export class InputController { return { consume: true }; }); } + if (!this.#expandToolsListenerInstalled) { + this.#expandToolsListenerInstalled = true; + // `app.tools.expand` (Ctrl+O) toggles the transcript's tool-output + // preview. It must fire regardless of focus so a truncated edit stays + // expandable while an approval prompt / select dialog holds keyboard + // focus (#7837). 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). + this.ctx.ui.addInputListener(data => { + if (!this.ctx.keybindings.matches(data, "app.tools.expand")) return undefined; + if (this.ctx.ui.hasOverlay()) return undefined; + if (this.ctx.ui.getFocused() instanceof TreeSelectorComponent) return undefined; + this.toggleToolOutputExpansion(); + return { consume: true }; + }); + } this.ctx.editor.onEscape = () => { // Side-channel panels are the topmost view. Esc dismisses them before // touching loop mode, maintenance, or the underlying main turn. @@ -456,8 +475,6 @@ export class InputController { this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"), ); this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt(); - this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand")); - this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion(); this.ctx.editor.setActionKeys( "app.tools.toggleVisibility", this.ctx.keybindings.getKeys("app.tools.toggleVisibility"), diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 6e0b387c0..59a98c779 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -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 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); + }); +});