diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index 3516287cc10..2702db1b06f 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -86,7 +86,15 @@ export function DevToolsBar() { const offEscape = keymap.intercept( "key", ({ event }) => { - if (!panel() || event.name !== "escape") return + if (!panel() || keymap.mode.current() !== "base") return + if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return + if (renderer.getSelection()?.getSelectedText()) { + if ((config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) !== "select") return + renderer.clearSelection() + event.preventDefault() + event.stopPropagation() + return + } event.preventDefault() event.stopPropagation() close() diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index cba04456cd5..ad2b6027930 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -759,6 +759,14 @@ export function Autocomplete(props: { hide() }, }, + { + id: "prompt.clear", + title: "Dismiss autocomplete", + group: "Autocomplete", + run() { + hide(true) + }, + }, { id: "prompt.autocomplete.select", title: "Select autocomplete item", diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index d03ba13540b..d5efd847c7b 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -987,9 +987,19 @@ export function Prompt(props: PromptProps) { Keymap.createLayer(() => { return { + priority: 1, target: inputTarget, enabled: inputTarget() !== undefined && store.mode === "shell", - commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }], + commands: [ + { bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }, + { + bind: "ctrl+c", + title: "Exit shell mode", + group: "Prompt", + enabled: () => store.prompt.text === "", + run: () => setStore("mode", "normal"), + }, + ], } }) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index cbc5d4e641d..7ebb70614e0 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -38,6 +38,7 @@ import { projectName } from "../util/project" import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee" import { useDialog } from "../ui/dialog" import { DialogSessionRename } from "./dialog-session-rename" +import { Keymap } from "../context/keymap" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -266,6 +267,11 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo const dimensions = useTerminalDimensions() const theme = useTheme("elevated") const dialog = useDialog() + onCleanup(Keymap.use().mode.push("menu")) + Keymap.createLayer(() => ({ + mode: "menu", + commands: [{ bind: "escape,ctrl+c", title: "Close tab menu", group: "Tabs", run: props.onClose }], + })) const actions = createMemo(() => { const sessionID = props.state.sessionID return [ @@ -1050,7 +1056,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim if (closeHold() && heldLayout()) { const current = untrack(motion.value) const seeded = changed - ? seedSessionTabMotion(previous.split(":"), layout().tabs.map((tab) => tab.sessionID), current, next) + ? seedSessionTabMotion( + previous.split(":"), + layout().tabs.map((tab) => tab.sessionID), + current, + next, + ) : current if (!seeded) return motion.jump(next) motion.jump({ ...seeded, widths: next.widths }) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index b0090c9dae1..509d52f1b60 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -423,6 +423,12 @@ function DiffViewer(props: { context: Plugin.Context }) { group: "VCS", run: close, }, + { + id: "app.exit", + title: "Close diff viewer", + group: "VCS", + run: close, + }, { id: "diff.down", title: "Move diff viewer down", diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx index 04fbf59330d..f773668b1bc 100644 --- a/packages/tui/src/routes/session/composer/index.tsx +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -99,6 +99,7 @@ export function Composer(props: ComposerProps) { { bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) }, { bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) }, { bind: "escape", title: "Close composer", group: "Composer", run: close }, + { bind: "ctrl+c", title: "Close composer", group: "Composer", run: close }, ], })) diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index afb6a7b0965..b9e69557fd8 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -126,10 +126,6 @@ export function SubagentsTab(props: { sessionID: string }) { }, ] }, - onClose: () => { - const parentID = session()?.parentID - if (parentID) navigate({ type: "session", sessionID: parentID }) - }, }) onCleanup(cleanup) }) diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index de93cfb4bd8..2e45791a682 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -540,6 +540,10 @@ export function FormPrompt(props: { form: FormWithLocation }) { run() { const text = textarea?.plainText ?? "" if (!text) { + if (textual()) { + cancel() + return + } setStore("editing", false) return } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index cef7f18a005..564ab55be6b 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -242,6 +242,11 @@ export function Session(props: { verticalTabsWidth: number }) { if (sidebar() === "auto" && wide()) return true return false }) + Keymap.createLayer(() => ({ + priority: 10, + enabled: () => sidebarOpen() && !wide() && !disabled(), + commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: () => setSidebarOpen(false) }], + })) const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4) const models = createMemo(() => data.location.model.list(location()) ?? []) @@ -1294,7 +1299,14 @@ export function Session(props: { verticalTabsWidth: number }) { sessionID={route.sessionID} open={composer.open || (!!session()?.parentID && forms().length === 0)} defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)} - onClose={() => setComposer("open", false)} + onClose={() => { + const parent = session()?.parentID + if (parent) { + navigate({ type: "session", sessionID: parent }) + return + } + setComposer("open", false) + }} /> {null} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index a4ef6e3cf08..4b46d829bfd 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -299,7 +299,11 @@ function RejectPrompt(props: { id: "app.exit", title: "Cancel permission rejection", group: "Permission", - run() { + run(_input, event) { + if (event?.ctrl && event.name === "c" && input.plainText) { + input.setText("") + return + } props.onCancel() }, }, @@ -436,6 +440,13 @@ export function SessionQuestion>(props: { const shortcuts = Keymap.useShortcuts() const id = () => props.id ?? "session.permission" const group = () => props.group ?? "Permission" + const dismiss = () => { + if (store.expanded) { + setStore("expanded", false) + return + } + if (props.escapeKey) props.onSelect(props.escapeKey) + } Keymap.createLayer(() => ({ mode: "base", @@ -447,7 +458,7 @@ export function SessionQuestion>(props: { title: "Reject permission", group: group(), bind: false as const, - run: () => props.onSelect(props.escapeKey!), + run: dismiss, }, ] : []), @@ -490,9 +501,7 @@ export function SessionQuestion>(props: { group: group(), run: () => props.onSelect(store.selected), }, - ...(props.escapeKey - ? [{ bind: "escape", title: "Reject permission", group: group(), run: () => props.onSelect(props.escapeKey!) }] - : []), + ...(props.escapeKey ? [{ bind: "escape", title: "Reject permission", group: group(), run: dismiss }] : []), ], bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])], })) diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 4abe30e1b2b..4dae6fff822 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -2,7 +2,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { MouseButton, Renderable, RGBA } from "@opentui/core" +import { InputRenderable, MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" import { useClipboard } from "../context/clipboard" @@ -112,7 +112,7 @@ function init() { Keymap.createLayer(() => ({ mode: "modal", - enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), + enabled: store.stack.length > 0, commands: [ { bind: "escape", @@ -121,6 +121,7 @@ function init() { run: () => { if (renderer.getSelection()) { renderer.clearSelection() + return } const current = store.stack.at(-1) current?.onClose?.() @@ -135,6 +136,13 @@ function init() { run: () => { if (renderer.getSelection()) { renderer.clearSelection() + return + } + const editor = renderer.currentFocusedEditor + if (editor?.plainText) { + if (editor instanceof InputRenderable) editor.value = "" + else editor.setText("") + return } const current = store.stack.at(-1) current?.onClose?.() @@ -225,7 +233,9 @@ export function DialogProvider(props: ParentProps) { evt.preventDefault() evt.stopPropagation() }} - onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined} + onMouseUp={ + copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined + } > value.clear()} size={value.size} centered={value.centered}> diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index a71d94ce42b..9a7e7f0b0f7 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -636,3 +636,48 @@ test("configured app bindings execute settings and permission commands", async ( await server.stop() } }) + +test("ctrl+c dismisses autocomplete and shell mode before exiting", async () => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const ready = Promise.withResolvers() + const events = createEventStream() + const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ animations: false }), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: {}, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await ready.promise + await setup.waitForFrame((frame) => frame.includes("commands")) + await setup.mockInput.typeText("/theme") + await setup.waitForFrame((frame) => frame.includes("Switch theme")) + + setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => !frame.includes("Switch theme")) + expect(setup.renderer.isDestroyed).toBe(false) + + await setup.mockInput.typeText("!") + await setup.waitForFrame((frame) => frame.includes("Shell")) + setup.mockInput.pressKey("c", { ctrl: true }) + await setup.waitForFrame((frame) => !frame.includes("Shell")) + expect(setup.renderer.isDestroyed).toBe(false) + + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } +}) diff --git a/packages/tui/test/cli/tui/composer-keymap.test.tsx b/packages/tui/test/cli/tui/composer-keymap.test.tsx index 0e00ef2ba60..88a2381aac5 100644 --- a/packages/tui/test/cli/tui/composer-keymap.test.tsx +++ b/packages/tui/test/cli/tui/composer-keymap.test.tsx @@ -187,6 +187,17 @@ test("configured composer bindings work with a focused textarea", async () => { } }) +test("ctrl+c closes the active composer", async () => { + const composer = await renderComposer("shell", {}) + + try { + composer.app.mockInput.pressKey("c", { ctrl: true }) + await composer.app.waitFor(() => composer.closed() === 1) + } finally { + composer.app.renderer.destroy() + } +}) + function session(id: string, title: string, parentID?: string) { return { id, diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 54275bacdf5..aa7f54ea681 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -163,6 +163,48 @@ test("budgets option content for constrained and full-width large dialogs", () = expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69) }) +test("ctrl+c clears a dialog filter before closing the dialog", async () => { + await using tmp = await tmpdir() + const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }]) + + try { + await select.app.mockInput.typeText("alpha") + await select.app.waitFor(() => select.app.renderer.currentFocusedEditor?.plainText === "alpha") + + select.app.mockInput.pressKey("c", { ctrl: true }) + await select.app.waitFor(() => select.app.renderer.currentFocusedEditor?.plainText === "") + expect(select.app.captureCharFrame()).toContain("Mutable options") + + select.app.mockInput.pressKey("c", { ctrl: true }) + await select.app.waitForFrame((frame) => !frame.includes("Mutable options")) + } finally { + select.app.renderer.destroy() + } +}) + +test("ctrl+c clears a dialog text selection before closing the dialog", async () => { + await using tmp = await tmpdir() + const select = await mountSelect(tmp.path, [{ title: "Alpha", value: "alpha" }]) + + try { + const frame = select.app.captureCharFrame().split("\n") + const row = frame.findIndex((line) => line.includes("Alpha")) + const column = frame[row]!.indexOf("Alpha") + 1 + await select.app.mockMouse.click(column, row) + await select.app.mockMouse.click(column, row) + expect(select.app.renderer.getSelection()?.getSelectedText()).toBe("Alpha") + + select.app.mockInput.pressKey("c", { ctrl: true }) + await select.app.waitFor(() => !select.app.renderer.getSelection()) + expect(select.app.captureCharFrame()).toContain("Mutable options") + + select.app.mockInput.pressKey("c", { ctrl: true }) + await select.app.waitForFrame((frame) => !frame.includes("Mutable options")) + } finally { + select.app.renderer.destroy() + } +}) + test("renders the complete truncated footer within the option row", async () => { await using tmp = await tmpdir() const title = "Project" diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 426478f2bbc..7090a663c79 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -49,6 +49,18 @@ test("closing the diff viewer returns to the route it opened from", async () => } }) +test("ctrl+c closes the diff viewer without exiting the application", async () => { + const viewer = await renderDiffViewer([]) + + try { + viewer.app.mockInput.pressKey("c", { ctrl: true }) + await viewer.app.waitFor(() => viewer.current().type !== "plugin") + expect(viewer.current()).toEqual(startRoute) + } finally { + viewer.app.renderer.destroy() + } +}) + test("shows an error instead of an empty diff when loading fails", async () => { const viewer = await renderDiffViewer([], { fail: true }) try { diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 4c762f9c9b5..0bc08b69970 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -606,6 +606,25 @@ test("text fields retain default paste behavior", async () => { } }) +test("ctrl+c clears a text field before cancelling its form", async () => { + await using tmp = await tmpdir() + const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }]) + + try { + await prompt.app.mockInput.typeText("draft answer") + await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "draft answer") + + prompt.app.mockInput.pressKey("c", { ctrl: true }) + await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "") + expect(prompt.cancellations).toEqual([]) + + prompt.app.mockInput.pressKey("c", { ctrl: true }) + await prompt.app.waitFor(() => prompt.cancellations.length === 1) + } finally { + prompt.app.renderer.destroy() + } +}) + test("pasting on a choice without custom answers does not open an editor", async () => { await using tmp = await tmpdir() const prompt = await mountForm(tmp.path, 80, [ diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index e8206b0a270..51ba6ccabf0 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -108,7 +108,9 @@ test("the tab context menu keeps preview tabs open without offering promotion fo await app.waitForFrame((frame) => frame.includes("Rename")) expect(app.captureCharFrame()).not.toContain("Keep open") - await app.mockMouse.click(5, 0) + app.mockInput.pressKey("c", { ctrl: true }) + await app.waitForFrame((frame) => !frame.includes("Rename")) + await app.mockMouse.click(40, 0, MouseButton.RIGHT) await app.waitForFrame((frame) => frame.includes("Keep open")) const frame = app.captureCharFrame().split("\n")