mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:51:21 -04:00
fix(tui): dismiss the active interaction with ctrl-c (#45111)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] : [])],
|
||||
}))
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user