From b1491690cddf1c3351d3d9486209ab1ba1ae2b52 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 30 May 2026 01:54:28 +0530 Subject: [PATCH 1/2] refactor(tui): extract shared message actions --- .../cmd/tui/routes/session/dialog-message.tsx | 67 ++++--------------- .../cmd/tui/routes/session/message-actions.ts | 64 ++++++++++++++++++ 2 files changed, 78 insertions(+), 53 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/message-actions.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index aeea2f52ad0b..cdf3fe333131 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -5,7 +5,7 @@ import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import * as Clipboard from "@tui/util/clipboard" import type { PromptInfo } from "@tui/component/prompt/history" -import { strip } from "@tui/component/prompt/part" +import { MessageActions } from "./message-actions" export function DialogMessage(props: { messageID: string @@ -26,29 +26,14 @@ export function DialogMessage(props: { value: "session.revert", description: "undo messages and file changes", onSelect: (dialog) => { - const msg = message() - if (!msg) return - - void sdk.client.session.revert({ + if (!message()) return + MessageActions.revert({ + sdk, + sync, sessionID: props.sessionID, - messageID: msg.id, + messageID: props.messageID, + setPrompt: props.setPrompt, }) - - if (props.setPrompt) { - const parts = sync.data.part[msg.id] - const promptInfo = parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(strip(part)) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ) - props.setPrompt(promptInfo) - } - dialog.clear() }, }, @@ -57,18 +42,8 @@ export function DialogMessage(props: { value: "message.copy", description: "message text to clipboard", onSelect: async (dialog) => { - const msg = message() - if (!msg) return - - const parts = sync.data.part[msg.id] - const text = parts.reduce((agg, part) => { - if (part.type === "text" && !part.synthetic) { - agg += part.text - } - return agg - }, "") - - await Clipboard.copy(text) + if (!message()) return + await Clipboard.copy(MessageActions.collectText(sync, props.messageID)) dialog.clear() }, }, @@ -77,28 +52,14 @@ export function DialogMessage(props: { value: "session.fork", description: "create a new session", onSelect: async (dialog) => { - const result = await sdk.client.session.fork({ + if (!message()) return + await MessageActions.fork({ + sdk, + sync, + navigate: route.navigate, sessionID: props.sessionID, messageID: props.messageID, }) - const msg = message() - const prompt = msg - ? sync.data.part[msg.id].reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(part) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ) - : undefined - route.navigate({ - sessionID: result.data!.id, - type: "session", - prompt, - }) dialog.clear() }, }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/message-actions.ts b/packages/opencode/src/cli/cmd/tui/routes/session/message-actions.ts new file mode 100644 index 000000000000..1f1eb50fec25 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/message-actions.ts @@ -0,0 +1,64 @@ +import type { useSDK } from "@tui/context/sdk" +import type { useSync } from "@tui/context/sync" +import type { useRoute } from "@tui/context/route" +import type { PromptInfo } from "@tui/component/prompt/history" +import { strip } from "@tui/component/prompt/part" + +type SDK = ReturnType +type Sync = ReturnType +type Navigate = ReturnType["navigate"] + +// File parts keep their server identifiers when seeding a new session (fork) but +// are stripped when the draft is re-submitted into the same session (revert). +export function collectPrompt(sync: Sync, messageID: string, options: { stripFiles: boolean }): PromptInfo | undefined { + const parts = sync.data.part[messageID] + if (!parts) return + return parts.reduce( + (agg, part) => { + if (part.type === "text") { + if (!part.synthetic) agg.input += part.text + } + if (part.type === "file") agg.parts.push(options.stripFiles ? strip(part) : part) + return agg + }, + { input: "", parts: [] as PromptInfo["parts"] }, + ) +} + +export function collectText(sync: Sync, messageID: string) { + const parts = sync.data.part[messageID] ?? [] + return parts.reduce((text, part) => { + if (part.type === "text" && !part.synthetic) text += part.text + return text + }, "") +} + +export function revert(options: { + sdk: SDK + sync: Sync + sessionID: string + messageID: string + setPrompt?: (prompt: PromptInfo) => void +}) { + void options.sdk.client.session.revert({ sessionID: options.sessionID, messageID: options.messageID }) + if (!options.setPrompt) return + const prompt = collectPrompt(options.sync, options.messageID, { stripFiles: true }) + if (prompt) options.setPrompt(prompt) +} + +export async function fork(options: { + sdk: SDK + sync: Sync + navigate: Navigate + sessionID: string + messageID: string +}) { + const result = await options.sdk.client.session.fork({ sessionID: options.sessionID, messageID: options.messageID }) + options.navigate({ + type: "session", + sessionID: result.data!.id, + prompt: collectPrompt(options.sync, options.messageID, { stripFiles: false }), + }) +} + +export * as MessageActions from "./message-actions" From 864fc42e0ad78bcf8c0c853ba0ad0db227a99547 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 30 May 2026 01:54:34 +0530 Subject: [PATCH 2/2] feat(tui): keyboard navigation for user messages --- .../cli/cmd/tui/component/prompt/index.tsx | 9 +- .../src/cli/cmd/tui/config/keybind.ts | 16 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 215 +++++++++++++++++- .../opencode/test/cli/tui/keymap.test.tsx | 92 ++++++++ 4 files changed, 322 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 28b1f62f697f..6db47c123ec9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -66,6 +66,7 @@ export type PromptProps = { sessionID?: string visible?: boolean disabled?: boolean + inert?: boolean onSubmit?: () => void ref?: (ref: PromptRef | undefined) => void hint?: JSX.Element @@ -700,7 +701,7 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - if (props.visible === false || dialog.stack.length > 0) { + if (props.visible === false || dialog.stack.length > 0 || props.inert) { if (input.focused) input.blur() return } @@ -1388,7 +1389,7 @@ export function Prompt(props: PromptProps) { } const highlight = createMemo(() => { - if (leader()) return theme.border + if (leader() || props.inert) return theme.border if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border @@ -1494,8 +1495,8 @@ export function Prompt(props: PromptProps) { width="100%" placeholder={placeholderText()} placeholderColor={theme.textMuted} - textColor={leader() ? theme.textMuted : theme.text} - focusedTextColor={leader() ? theme.textMuted : theme.text} + textColor={leader() || props.inert ? theme.textMuted : theme.text} + focusedTextColor={leader() || props.inert ? theme.textMuted : theme.text} minHeight={1} maxHeight={maxHeight()} onContentChange={() => { diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index c03123aed1c0..7493fe686122 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -142,6 +142,14 @@ export const Definitions = { messages_undo: keybind("u", "Undo message"), messages_redo: keybind("r", "Redo message"), messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), + messages_focus_toggle: keybind("none", "Toggle keyboard focus on user messages"), + messages_focus_previous: keybind("up,k", "Focus the previous user message"), + messages_focus_next: keybind("down,j", "Focus the next user message"), + messages_focus_actions: keybind("return", "Open actions for the focused message"), + messages_focus_revert: keybind("r", "Revert to the focused message"), + messages_focus_copy: keybind("c", "Copy the focused message"), + messages_focus_fork: keybind("f", "Fork from the focused message"), + messages_focus_exit: keybind("i", "Exit user message focus"), tool_details: keybind("none", "Toggle tool details visibility"), display_thinking: keybind("none", "Toggle thinking blocks visibility"), @@ -337,6 +345,14 @@ export const CommandMap = { messages_undo: "session.undo", messages_redo: "session.redo", messages_toggle_conceal: "session.toggle.conceal", + messages_focus_toggle: "session.message.focus.toggle", + messages_focus_previous: "session.message.focus.previous", + messages_focus_next: "session.message.focus.next", + messages_focus_actions: "session.message.focus.actions", + messages_focus_revert: "session.message.focus.revert", + messages_focus_copy: "session.message.focus.copy", + messages_focus_fork: "session.message.focus.fork", + messages_focus_exit: "session.message.focus.exit", tool_details: "session.toggle.actions", display_thinking: "session.toggle.thinking", prompt_submit: "prompt.submit", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b3dc2a4a7de2..c1d7b0d9d629 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onCleanup, onMount, Show, Switch, @@ -57,6 +58,7 @@ import { useDialog } from "../../ui/dialog" import { DialogAlert } from "../../ui/dialog-alert" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" +import { MessageActions } from "./message-actions" import type { PromptInfo } from "../../component/prompt/history" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogTimeline } from "./dialog-timeline" @@ -89,7 +91,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { DialogRetryAction } from "../../component/dialog-retry-action" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap, useOpencodeModeStack } from "../../keymap" import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" addDefaultParsers(parsers.parsers) @@ -118,6 +120,8 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) { } } +const FOCUS_MODE = "messages" + const sessionBindingCommands = [ "session.share", "session.rename", @@ -1109,8 +1113,202 @@ export function Session() { } }) + const modeStack = useOpencodeModeStack() + const [navMode, setNavMode] = createSignal(false) + const [focusedMessage, setFocusedMessage] = createSignal() + + const navigableMessages = createMemo(() => { + const revertID = revertMessageID() + return messages().filter((message): message is UserMessage => { + if (message.role !== "user") return false + if (revertID && message.id >= revertID) return false + return (sync.data.part[message.id] ?? []).some( + (part) => part.type === "text" && !part.synthetic && !part.ignored, + ) + }) + }) + + let scrollAnimation: ReturnType | undefined + function cancelScrollAnimation() { + if (!scrollAnimation) return + clearInterval(scrollAnimation) + scrollAnimation = undefined + } + + function scrollToFocused(messageID: string) { + if (!scroll || scroll.isDestroyed) return + const child = scroll.getChildren().find((c) => c.id === messageID) + if (!child) return + + const desiredTop = Math.max(1, Math.floor((scroll.height - child.height) / 2)) + const target = scroll.scrollTop + (child.y - scroll.y) - desiredTop + cancelScrollAnimation() + if (!kv.get("animations_enabled", true)) { + scroll.scrollTo(target) + return + } + scrollAnimation = setInterval(() => { + if (!scroll || scroll.isDestroyed) return cancelScrollAnimation() + const remaining = target - scroll.scrollTop + if (Math.abs(remaining) <= 1) { + scroll.scrollTo(target) + cancelScrollAnimation() + } else { + scroll.scrollTo(scroll.scrollTop + remaining * 0.3) + } + renderer.requestRender() + }, 16) + } + + function focusEnter() { + if (!visible()) return false + if (navigableMessages().length === 0) return false + setNavMode(true) + } + + function focusExit() { + if (!navMode()) return + cancelScrollAnimation() + setFocusedMessage(undefined) + setNavMode(false) + toBottom() + } + + function focusMessage(messageID: string) { + setFocusedMessage(messageID) + scrollToFocused(messageID) + } + + function focusMove(direction: 1 | -1) { + const list = navigableMessages() + if (list.length === 0) return focusExit() + const current = focusedMessage() + if (current === undefined) { + if (direction === -1) return focusMessage(list[list.length - 1].id) + return focusExit() + } + const index = list.findIndex((message) => message.id === current) + const next = index === -1 ? list.length - 1 : index + direction + if (next < 0) return + if (next >= list.length) return focusExit() + focusMessage(list[next].id) + } + + function withFocused(action: (messageID: string) => void) { + const messageID = focusedMessage() + if (messageID) action(messageID) + } + + const focusCommands = createMemo(() => + [ + { + name: "session.message.focus.toggle", + title: "Toggle message focus", + run: () => (navMode() ? focusExit() : focusEnter()), + }, + { name: "session.message.focus.exit", title: "Exit message focus", run: focusExit }, + { name: "session.message.focus.previous", title: "Focus previous message", run: () => focusMove(-1) }, + { name: "session.message.focus.next", title: "Focus next message", run: () => focusMove(1) }, + { + name: "session.message.focus.actions", + title: "Message actions", + run: () => + withFocused((messageID) => + dialog.replace(() => ( + { + prompt?.set(info) + focusExit() + }} + /> + )), + ), + }, + { + name: "session.message.focus.revert", + title: "Revert to message", + run: () => + withFocused((messageID) => { + MessageActions.revert({ + sdk, + sync, + sessionID: route.sessionID, + messageID, + setPrompt: (info) => prompt?.set(info), + }) + focusExit() + }), + }, + { + name: "session.message.focus.copy", + title: "Copy message", + run: () => + withFocused((messageID) => { + const text = MessageActions.collectText(sync, messageID) + if (!text) return + void Clipboard.copy(text) + .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) + }), + }, + { + name: "session.message.focus.fork", + title: "Fork from message", + run: () => + withFocused((messageID) => void MessageActions.fork({ sdk, sync, navigate, sessionID: route.sessionID, messageID })), + }, + ].map((command) => ({ namespace: "palette", hidden: true, category: "Session", ...command })), + ) + + useBindings(() => ({ commands: focusCommands() })) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + bindings: tuiConfig.keybinds.gather("messages.focus.enter", ["session.message.focus.toggle"]), + })) + useBindings(() => ({ + mode: FOCUS_MODE, + bindings: tuiConfig.keybinds.gather("messages.focus", [ + "session.message.focus.toggle", + "session.message.focus.exit", + "session.message.focus.previous", + "session.message.focus.next", + "session.message.focus.actions", + "session.message.focus.revert", + "session.message.focus.copy", + "session.message.focus.fork", + ]), + })) + + createEffect(() => { + if (!navMode()) return + const dispose = modeStack.push(FOCUS_MODE) + onCleanup(dispose) + }) + + createEffect(() => { + if (!navMode()) return + const current = focusedMessage() + if (!visible() || (current !== undefined && !navigableMessages().some((message) => message.id === current))) + focusExit() + }) + + onCleanup(cancelScrollAnimation) + // snap to bottom when session changes - createEffect(on(() => route.sessionID, toBottom)) + createEffect( + on( + () => route.sessionID, + () => { + cancelScrollAnimation() + setNavMode(false) + setFocusedMessage(undefined) + toBottom() + }, + ), + ) return ( @@ -1148,7 +1346,7 @@ export function Session() { foregroundColor: theme.border, }, }} - stickyScroll={true} + stickyScroll={!navMode()} stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} @@ -1223,6 +1421,8 @@ export function Session() { { if (renderer.getSelection()?.getSelectedText()) return dialog.replace(() => ( @@ -1277,6 +1477,7 @@ export function Session() { toBottom() }} sessionID={route.sessionID} + inert={navMode()} right={} /> @@ -1326,6 +1527,8 @@ function UserMessage(props: { parts: Part[] onMouseUp: () => void index: number + focused?: boolean + navigating?: boolean pending?: string }) { const ctx = use() @@ -1357,7 +1560,7 @@ function UserMessage(props: { @@ -1372,10 +1575,10 @@ function UserMessage(props: { paddingTop={1} paddingBottom={1} paddingLeft={2} - backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} + backgroundColor={props.focused || hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()} + {text()} diff --git a/packages/opencode/test/cli/tui/keymap.test.tsx b/packages/opencode/test/cli/tui/keymap.test.tsx index d1ebefb4c165..1e65c4cf5440 100644 --- a/packages/opencode/test/cli/tui/keymap.test.tsx +++ b/packages/opencode/test/cli/tui/keymap.test.tsx @@ -58,6 +58,98 @@ test("legacy page key aliases compile as page keys", async () => { } }) +test("message focus bindings resolve and are scoped to the focus mode", async () => { + const result: { + sequences?: Record + base?: Record + messages?: Record + } = {} + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = createTuiResolvedConfig() + const offKeymap = registerOpencodeKeymap(keymap, renderer, config) + + const offBase = keymap.registerLayer({ + mode: OPENCODE_BASE_MODE, + commands: [{ name: "session.parent", run() {} }], + bindings: config.keybinds.gather("test.base", ["session.parent"]), + }) + const offFocus = keymap.registerLayer({ + mode: "messages", + commands: [ + { name: "session.message.focus.previous", run() {} }, + { name: "session.message.focus.next", run() {} }, + { name: "session.message.focus.revert", run() {} }, + ], + bindings: config.keybinds.gather("test.focus", [ + "session.message.focus.previous", + "session.message.focus.next", + "session.message.focus.revert", + ]), + }) + + const sequence = (command: string) => + keymap + .getCommandBindings({ visibility: "registered", commands: [command] }) + .get(command) + ?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? [] + result.sequences = { + previous: sequence("session.message.focus.previous"), + next: sequence("session.message.focus.next"), + revert: sequence("session.message.focus.revert"), + } + + const activeCounts = () => + Object.fromEntries( + Array.from( + keymap.getCommandBindings({ + visibility: "active", + commands: ["session.parent", "session.message.focus.previous"], + }), + ([command, bindings]) => [command, bindings.length], + ), + ) + result.base = activeCounts() + const popFocus = getOpencodeModeStack(keymap).push("messages") + result.messages = activeCounts() + popFocus() + + onCleanup(() => { + offFocus() + offBase() + offKeymap() + }) + + return ( + + + + ) + } + + const app = await testRender(() => ) + try { + expect(result.sequences).toEqual({ + previous: [["up"], ["k"]], + next: [["down"], ["j"]], + revert: [["r"]], + }) + expect(result.base).toEqual({ + "session.parent": 1, + "session.message.focus.previous": 0, + }) + expect(result.messages).toEqual({ + "session.parent": 0, + // up and k both bind here + "session.message.focus.previous": 2, + }) + } finally { + app.renderer.destroy() + } +}) + test("mode-less bindings stay active when opencode mode changes", async () => { const counts: Record> = {}