Compare commits

...

2 Commits

Author SHA1 Message Date
Shoubhit Dash 864fc42e0a feat(tui): keyboard navigation for user messages 2026-05-30 01:54:34 +05:30
Shoubhit Dash b1491690cd refactor(tui): extract shared message actions 2026-05-30 01:54:28 +05:30
6 changed files with 400 additions and 63 deletions
@@ -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={() => {
@@ -142,6 +142,14 @@ export const Definitions = {
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
messages_toggle_conceal: keybind("<leader>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",
@@ -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()
},
},
@@ -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<string>()
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<typeof setInterval> | 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(() => (
<DialogMessage
messageID={messageID}
sessionID={route.sessionID}
// Revert refills the prompt; exit focus so the draft is editable.
setPrompt={(info) => {
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 (
<PathFormatterProvider path={session()?.directory}>
@@ -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() {
<Match when={message.role === "user"}>
<UserMessage
index={index()}
focused={focusedMessage() === message.id}
navigating={navMode()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
@@ -1277,6 +1477,7 @@ export function Session() {
toBottom()
}}
sessionID={route.sessionID}
inert={navMode()}
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</TuiPluginRuntime.Slot>
@@ -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: {
<box
id={props.message.id}
border={["left"]}
borderColor={color()}
borderColor={props.focused ? color() : props.navigating ? theme.border : color()}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
>
@@ -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 fg={theme.text}>{text()}</text>
<text fg={props.navigating && !props.focused ? theme.textMuted : theme.text}>{text()}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -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<typeof useSDK>
type Sync = ReturnType<typeof useSync>
type Navigate = ReturnType<typeof useRoute>["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"
@@ -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<string, string[][]>
base?: Record<string, number>
messages?: Record<string, number>
} = {}
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 (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />)
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<string, Record<string, number>> = {}