Răsfoiți Sursa

feat(tui): keyboard navigation for user messages

Shoubhit Dash 2 luni în urmă
părinte
comite
864fc42e0a

+ 5 - 4
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={() => {

+ 16 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -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",

+ 209 - 6
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<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()}>

+ 92 - 0
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<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>> = {}