fix(tui): dismiss the active interaction with ctrl-c (#45111)

This commit is contained in:
Kit Langton
2026-08-25 22:38:50 -04:00
committed by GitHub
parent 695c043e6b
commit cb863b8ea5
17 changed files with 223 additions and 17 deletions
+9 -1
View File
@@ -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()
@@ -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",
+11 -1
View File
@@ -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"),
},
],
}
})
+12 -1
View File
@@ -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 })
@@ -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",
@@ -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 },
],
}))
@@ -126,10 +126,6 @@ export function SubagentsTab(props: { sessionID: string }) {
},
]
},
onClose: () => {
const parentID = session()?.parentID
if (parentID) navigate({ type: "session", sessionID: parentID })
},
})
onCleanup(cleanup)
})
+4
View File
@@ -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
}
+13 -1
View File
@@ -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)
}}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
+14 -5
View File
@@ -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<const T extends Record<string, string>>(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<const T extends Record<string, string>>(props: {
title: "Reject permission",
group: group(),
bind: false as const,
run: () => props.onSelect(props.escapeKey!),
run: dismiss,
},
]
: []),
@@ -490,9 +501,7 @@ export function SessionQuestion<const T extends Record<string, string>>(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"] : [])],
}))
+13 -3
View File
@@ -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
}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
+45
View File
@@ -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<void>()
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()
}
})
@@ -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,
@@ -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"
@@ -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 {
+19
View File
@@ -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, [
@@ -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")