소스 검색

feat(tui): add message navigation shortcuts (#37362)

Dustin Deus 1 개월 전
부모
커밋
5ee4c1082a

+ 8 - 4
packages/tui/src/config/v1/keybind.ts

@@ -134,11 +134,13 @@ export const Definitions = {
   messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
   messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
   messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
-  messages_first: keybind("ctrl+g,home", "Navigate to first message"),
+  messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
   messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
-  messages_next: keybind("none", "Navigate to next message"),
-  messages_previous: keybind("none", "Navigate to previous message"),
-  messages_last_user: keybind("none", "Navigate to last user message"),
+  messages_next: keybind("alt+down", "Navigate to next message"),
+  messages_previous: keybind("alt+up", "Navigate to previous message"),
+  messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
+  messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
+  messages_last_user: keybind("alt+end", "Navigate to last user message"),
   messages_copy: keybind("<leader>y", "Copy message"),
   messages_undo: keybind("<leader>u", "Undo message"),
   messages_redo: keybind("<leader>r", "Redo message"),
@@ -335,6 +337,8 @@ export const CommandMap = {
   messages_last: "session.last",
   messages_next: "session.message.next",
   messages_previous: "session.message.previous",
+  messages_next_user: "session.message.user.next",
+  messages_previous_user: "session.message.user.previous",
   messages_last_user: "session.messages_last_user",
   messages_copy: "messages.copy",
   messages_undo: "session.undo",

+ 99 - 48
packages/tui/src/routes/session/index.tsx

@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
 import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
 import { usePathFormatter } from "../../context/path-format"
 import { useLocation } from "../../context/location"
-import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
+import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
 import { switchLabel } from "../../util/model"
+import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
 
 addDefaultParsers(parsers.parsers)
 
+// Exclude temporary bottom space when measuring the real transcript height.
+const NAVIGATION_SLACK_ID = "session-navigation-slack"
+
 const context = createContext<{
   width: number
   sessionID: string
@@ -180,6 +184,23 @@ export function Session() {
   const client = useClient()
   const editor = useEditorContext()
   const rows = createSessionRows(() => route.sessionID)
+  const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
+  const [navigationMessage, setNavigationMessage] = createSignal<string>()
+  const [navigationSlack, setNavigationSlack] = createSignal(0)
+
+  const clearMessageNavigation = () => {
+    setNavigationSlack(0)
+    setNavigationMessage(undefined)
+  }
+
+  createEffect(
+    on(
+      () => [dimensions().width, dimensions().height] as const,
+      (_, previous) => {
+        if (previous) clearMessageNavigation()
+      },
+    ),
+  )
 
   createEffect(
     on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
@@ -239,53 +260,55 @@ export function Session() {
     dialog.clear()
   }
 
-  // Helper: Find next visible message boundary in direction
-  const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
-    const children = scroll.getChildren()
-    const messagesList = messages()
-    const scrollTop = scroll.y
-
-    // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
-    const visibleMessages = children
-      .filter((c) => {
-        if (!c.id) return false
-        const message = messagesList.find((m) => m.id === c.id)
-        if (!message) return false
-
-        if (message.type === "user") return Boolean(message.text.trim())
-        return (
-          message.type === "assistant" &&
-          message.content.some((content) => content.type === "text" && content.text.trim())
-        )
+  const alignMessage = (messageID: string, top: number) => {
+    scroll.stickyScroll = false
+    setNavigationMessage(messageID)
+    setNavigationSlack(
+      messageNavigationSlack({
+        top,
+        viewportHeight: scroll.viewport.height,
+        scrollHeight: scroll.scrollHeight,
+        currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
+      }),
+    )
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        if (scroll.isDestroyed || navigationMessage() !== messageID) return
+        scroll.scrollTo(top)
       })
-      .sort((a, b) => a.y - b.y)
-
-    if (visibleMessages.length === 0) return null
-
-    if (direction === "next") {
-      // Find first message below current position
-      return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
-    }
-    // Find last message above current position
-    return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
+    })
   }
 
-  // Helper: Scroll to message in direction or fallback to page scroll
-  const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
-    const targetID = findNextVisibleMessage(direction)
+  const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
+    const target = findMessageBoundary({
+      direction,
+      children: scroll.getChildren(),
+      messages: messages(),
+      scrollTop: scroll.scrollTop,
+      viewportY: scroll.viewport.y,
+      currentID: navigationMessage(),
+      userOnly,
+    })
 
-    if (!targetID) {
-      scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
+    if (!target) {
       dialog.clear()
       return
     }
 
-    const child = scroll.getChildren().find((c) => c.id === targetID)
-    if (child) scroll.scrollBy(child.y - scroll.y - 1)
+    alignMessage(target.id, target.top)
     dialog.clear()
   }
 
+  const jumpToMessage = (messageID: string) => {
+    const child = scroll.getRenderable(messageID)
+    if (!child) return
+    const y = scroll.scrollTop + child.y - scroll.viewport.y
+    const message = data.session.message.get(route.sessionID, messageID)
+    alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
+  }
+
   function toBottom() {
+    clearMessageNavigation()
     setTimeout(() => {
       if (!scroll || scroll.isDestroyed) return
       scroll.scrollTo(scroll.scrollHeight)
@@ -299,6 +322,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollBy(-scroll.height / 2)
         dialog.clear()
       },
@@ -309,6 +333,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollBy(scroll.height / 2)
         dialog.clear()
       },
@@ -319,6 +344,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollBy(-1)
         dialog.clear()
       },
@@ -329,6 +355,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollBy(1)
         dialog.clear()
       },
@@ -339,6 +366,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollBy(-scroll.height / 4)
         dialog.clear()
       },
@@ -349,6 +377,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollBy(scroll.height / 4)
         dialog.clear()
       },
@@ -362,6 +391,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollTo(0)
         dialog.clear()
       },
@@ -372,6 +402,7 @@ export function Session() {
       category: "Session",
       hidden: true,
       run: () => {
+        clearMessageNavigation()
         scroll.scrollTo(scroll.scrollHeight)
         dialog.clear()
       },
@@ -412,8 +443,7 @@ export function Session() {
             sessionID={route.sessionID}
             onMove={(messageID) => {
               if (!messageID) return
-              const child = scroll.getChildren().find((child) => child.id === messageID)
-              if (child) scroll.scrollBy(child.y - scroll.y - 1)
+              jumpToMessage(messageID)
             }}
           />
         ))
@@ -574,10 +604,7 @@ export function Session() {
           const message = messages[i]
           if (!message || message.type !== "user" || !message.text.trim()) continue
           {
-            const child = scroll.getChildren().find((child) => {
-              return child.id === message.id
-            })
-            if (child) scroll.scrollBy(child.y - scroll.y - 1)
+            jumpToMessage(message.id)
             break
           }
         }
@@ -597,6 +624,20 @@ export function Session() {
       hidden: true,
       run: () => scrollToMessage("prev", dialog),
     },
+    {
+      title: "Next user message",
+      name: "session.message.user.next",
+      category: "Session",
+      hidden: true,
+      run: () => scrollToMessage("next", dialog, true),
+    },
+    {
+      title: "Previous user message",
+      name: "session.message.user.previous",
+      category: "Session",
+      hidden: true,
+      run: () => scrollToMessage("prev", dialog, true),
+    },
     {
       title: "Copy last assistant message",
       name: "messages.copy",
@@ -810,7 +851,10 @@ export function Session() {
   createEffect(
     on(
       () => route.sessionID,
-      () => setComposer("open", false),
+      () => {
+        setComposer("open", false)
+        clearMessageNavigation()
+      },
     ),
   )
 
@@ -846,16 +890,17 @@ export function Session() {
                   foregroundColor: themeV2.border(),
                 },
               }}
-              stickyScroll={true}
+              stickyScroll={!navigationMessage()}
               stickyStart="bottom"
               flexGrow={1}
               scrollAcceleration={scrollAcceleration()}
             >
               <For each={rows}>
-                {(row) => (
+                {(row, index) => (
                   <SessionRowView
                     row={row}
                     message={(messageID) => data.session.message.get(route.sessionID, messageID)}
+                    boundaryID={boundaries()[index()]}
                   />
                 )}
               </For>
@@ -870,6 +915,9 @@ export function Session() {
                   files={session()!.revert!.files ?? []}
                 />
               </Show>
+              <Show when={navigationSlack()}>
+                {(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
+              </Show>
             </scrollbox>
             <box flexShrink={0}>
               <Composer
@@ -942,9 +990,13 @@ export function Session() {
   )
 }
 
-function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
+function SessionRowView(props: {
+  row: SessionRow
+  message: (messageID: string) => SessionMessageInfo | undefined
+  boundaryID?: string
+}) {
   return (
-    <box marginTop={1} flexShrink={0}>
+    <box id={props.boundaryID} marginTop={1} flexShrink={0}>
       <Switch>
         <Match when={props.row.type === "message" ? props.row : undefined}>
           {(row) => (
@@ -1563,7 +1615,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
   return (
     <Show when={props.message.text.trim() || files().length}>
       <box
-        id={props.message.id}
         border={["left"]}
         borderColor={queued() ? themeV2.border() : color()}
         customBorderChars={SplitBorder.customBorderChars}

+ 48 - 0
packages/tui/src/routes/session/message-navigation.ts

@@ -0,0 +1,48 @@
+import type { SessionMessageInfo } from "@opencode-ai/client"
+
+type MessageChild = {
+  readonly id?: string
+  readonly y: number
+}
+
+export function messageNavigationSlack(input: {
+  top: number
+  viewportHeight: number
+  scrollHeight: number
+  currentSlack: number
+}) {
+  const contentHeight = input.scrollHeight - input.currentSlack
+  return Math.max(0, Math.ceil(input.top + input.viewportHeight - contentHeight))
+}
+
+export function findMessageBoundary(input: {
+  direction: "next" | "prev"
+  children: readonly MessageChild[]
+  messages: readonly SessionMessageInfo[]
+  scrollTop: number
+  viewportY: number
+  currentID?: string
+  userOnly?: boolean
+}) {
+  const messages = new Map(input.messages.map((message) => [message.id, message]))
+  const visible = input.children
+    .flatMap((child) => {
+      if (!child.id) return []
+      const message = messages.get(child.id)
+      if (!message) return []
+      if (message.type === "user" && message.text.trim()) {
+        const y = input.scrollTop + child.y - input.viewportY
+        return [{ id: child.id, y, top: y }]
+      }
+      if (input.userOnly || message.type !== "assistant") return []
+      if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
+      const y = input.scrollTop + child.y - input.viewportY
+      return [{ id: child.id, y, top: Math.max(0, y - 1) }]
+    })
+    .sort((a, b) => a.y - b.y)
+
+  const current = visible.findIndex((child) => child.id === input.currentID)
+  if (current !== -1) return visible[current + (input.direction === "next" ? 1 : -1)] ?? null
+  if (input.direction === "next") return visible.find((child) => child.y > input.scrollTop) ?? null
+  return visible.findLast((child) => child.y < input.scrollTop) ?? null
+}

+ 30 - 0
packages/tui/src/routes/session/rows.ts

@@ -285,6 +285,36 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
   }, [])
 }
 
+export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
+  const byID = new Map(messages.map((message) => [message.id, message]))
+  const seen = new Set<string>()
+  return rows.map((row) => {
+    const id = rowBoundaryMessageID(row, byID)
+    if (!id || seen.has(id)) return undefined
+    seen.add(id)
+    return id
+  })
+}
+
+function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
+  if (row.type === "message") {
+    const message = messages.get(row.messageID)
+    if (message?.type === "user" && message.text.trim()) return message.id
+    return undefined
+  }
+  const messageID =
+    row.type === "part"
+      ? row.ref.messageID
+      : row.type === "group"
+        ? row.refs[0]?.messageID
+        : row.type === "assistant-footer"
+          ? row.messageID
+          : undefined
+  if (!messageID) return undefined
+  const message = messages.get(messageID)
+  if (message?.type === "assistant") return message.id
+}
+
 export function resolvePart(message: SessionMessageAssistant, partID: string) {
   const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
   if (tool) return tool

+ 164 - 0
packages/tui/test/cli/tui/message-navigation.test.ts

@@ -0,0 +1,164 @@
+import { expect, test } from "bun:test"
+import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
+import { findMessageBoundary, messageNavigationSlack } from "../../../src/routes/session/message-navigation"
+
+const messages: SessionMessageInfo[] = [
+  { type: "user", id: "user-1", text: "First", time: { created: 0 } },
+  assistant("assistant-1", "Response"),
+  { type: "user", id: "user-2", text: "Second", time: { created: 2 } },
+]
+const children = [
+  { id: "user-1", y: 0 },
+  { id: "assistant-1", y: 20 },
+  { id: "user-2", y: 40 },
+]
+
+test("adds only enough slack to align the selected message", () => {
+  expect(messageNavigationSlack({ top: 80, viewportHeight: 50, scrollHeight: 100, currentSlack: 0 })).toBe(30)
+  expect(messageNavigationSlack({ top: 20, viewportHeight: 50, scrollHeight: 130, currentSlack: 30 })).toBe(0)
+})
+
+test("finds the next user message without stopping at an assistant message", () => {
+  expect(
+    findMessageBoundary({
+      direction: "next",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      userOnly: true,
+    }),
+  ).toEqual({ id: "user-2", y: 40, top: 40 })
+})
+
+test("finds the previous user message without stopping at an assistant message", () => {
+  expect(
+    findMessageBoundary({
+      direction: "prev",
+      children: children.map((child) => ({ ...child, y: child.y - 35 })),
+      messages,
+      scrollTop: 35,
+      viewportY: 0,
+      userOnly: true,
+    }),
+  ).toEqual({ id: "user-1", y: 0, top: 0 })
+})
+
+test("preserves navigation across both user and assistant messages", () => {
+  expect(
+    findMessageBoundary({
+      direction: "next",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+    }),
+  ).toEqual({ id: "assistant-1", y: 20, top: 19 })
+  expect(
+    findMessageBoundary({
+      direction: "prev",
+      children: children.map((child) => ({ ...child, y: child.y - 35 })),
+      messages,
+      scrollTop: 35,
+      viewportY: 0,
+    }),
+  ).toEqual({ id: "assistant-1", y: 20, top: 19 })
+})
+
+test("uses the selected message when the viewport is too tall to scroll", () => {
+  expect(
+    findMessageBoundary({
+      direction: "next",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      currentID: "user-1",
+      userOnly: true,
+    }),
+  ).toEqual({ id: "user-2", y: 40, top: 40 })
+  expect(
+    findMessageBoundary({
+      direction: "prev",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      currentID: "user-2",
+      userOnly: true,
+    }),
+  ).toEqual({ id: "user-1", y: 0, top: 0 })
+})
+
+test("stops at the first and last selected user message", () => {
+  expect(
+    findMessageBoundary({
+      direction: "next",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      currentID: "user-2",
+      userOnly: true,
+    }),
+  ).toBeNull()
+  expect(
+    findMessageBoundary({
+      direction: "prev",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      currentID: "user-1",
+      userOnly: true,
+    }),
+  ).toBeNull()
+})
+
+test("keeps the logical boundary when layout temporarily moves it outside the viewport", () => {
+  expect(
+    findMessageBoundary({
+      direction: "next",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      currentID: "user-2",
+      userOnly: true,
+    }),
+  ).toBeNull()
+})
+
+test("stops at the first and last message", () => {
+  expect(
+    findMessageBoundary({
+      direction: "next",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      currentID: "user-2",
+    }),
+  ).toBeNull()
+  expect(
+    findMessageBoundary({
+      direction: "prev",
+      children,
+      messages,
+      scrollTop: 0,
+      viewportY: 0,
+      currentID: "user-1",
+    }),
+  ).toBeNull()
+})
+
+function assistant(id: string, text: string): SessionMessageAssistant {
+  return {
+    type: "assistant",
+    id,
+    agent: "build",
+    model: { providerID: "test", id: "test" },
+    content: [{ type: "text", text }],
+    time: { created: 1, completed: 1 },
+  }
+}

+ 15 - 1
packages/tui/test/cli/tui/session-rows.test.ts

@@ -1,6 +1,20 @@
 import { expect, test } from "bun:test"
 import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
-import { reduceSessionRows } from "../../../src/routes/session/rows"
+import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
+
+test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
+  const messages: SessionMessageInfo[] = [
+    { type: "user", id: "user-1", text: "Question", time: { created: 0 } },
+    assistant("assistant-1", [
+      { type: "reasoning", text: "Thinking" },
+      { type: "text", text: "First" },
+      { type: "text", text: "Second" },
+    ]),
+  ]
+  const rows = reduceSessionRows(messages)
+
+  expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
+})
 
 test("groups exploration parts across assistant messages until a delimiter", () => {
   const messages: SessionMessageInfo[] = [

+ 11 - 0
packages/tui/test/config.test.tsx

@@ -86,6 +86,17 @@ test("resolves a session move keybind", () => {
   expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
 })
 
+test("resolves message navigation defaults", () => {
+  const config = resolve({}, { terminalSuspend: true })
+
+  expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
+  expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
+  expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
+  expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
+  expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
+  expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
+})
+
 test("opens the subagent picker with down", () => {
   const config = resolve({}, { terminalSuspend: true })
 

+ 63 - 4
packages/tui/test/keymap.test.tsx

@@ -1,9 +1,10 @@
 /** @jsxImportSource @opentui/solid */
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
 import { createBindingLookup } from "@opentui/keymap/extras"
+import { type TextareaRenderable } from "@opentui/core"
 import { testRender, useRenderer } from "@opentui/solid"
 import { expect, test } from "bun:test"
-import { onCleanup } from "solid-js"
+import { onCleanup, onMount } from "solid-js"
 import { TuiKeybind } from "../src/config/keybind"
 import {
   formatKeySequence,
@@ -110,6 +111,64 @@ test("formats navigation keys as arrows", async () => {
   }
 })
 
+test("dispatches message navigation while the composer is focused", async () => {
+  for (const kittyKeyboard of [false, true]) {
+    const counts = {
+      "session.first": 0,
+      "session.message.previous": 0,
+      "session.message.next": 0,
+      "session.messages_last_user": 0,
+    }
+
+    function Harness() {
+      const renderer = useRenderer()
+      const keymap = createDefaultOpenTuiKeymap(renderer)
+      const config = createResolvedKeymapConfig()
+      const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
+      const commands = Object.keys(counts) as (keyof typeof counts)[]
+      const offLayer = keymap.registerLayer({
+        commands: commands.map((name) => ({
+          name,
+          run() {
+            counts[name]++
+          },
+        })),
+        bindings: commands.flatMap((command) => config.keybinds.get(command)),
+      })
+      let textarea: TextareaRenderable
+      onMount(() => textarea.focus())
+      onCleanup(() => {
+        offLayer()
+        offKeymap()
+      })
+
+      return (
+        <OpencodeKeymapProvider keymap={keymap}>
+          <textarea ref={(value) => (textarea = value)} />
+        </OpencodeKeymapProvider>
+      )
+    }
+
+    const app = await testRender(() => <Harness />, { kittyKeyboard })
+    try {
+      await app.renderOnce()
+      app.mockInput.pressArrow("up", { meta: true })
+      app.mockInput.pressArrow("down", { meta: true })
+      app.mockInput.pressKey("HOME", { meta: true })
+      app.mockInput.pressKey("END", { meta: true })
+      expect(counts).toEqual({
+        "session.first": 1,
+        "session.message.previous": 1,
+        "session.message.next": 1,
+        "session.messages_last_user": 1,
+      })
+    } finally {
+      app.renderer.currentFocusedEditor?.blur()
+      app.renderer.destroy()
+    }
+  }
+})
+
 test("mode-less bindings stay active when opencode mode changes", async () => {
   const counts: Record<string, Record<string, number>> = {}
 
@@ -169,13 +228,13 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
   const app = await testRender(() => <Harness />)
   try {
     expect(counts).toEqual({
-      base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
-      question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
+      base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 1 },
+      question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 0 },
       autocomplete: {
         "session.list": 1,
         "session.new": 1,
         "session.page.up": 2,
-        "session.first": 2,
+        "session.first": 3,
         "model.list": 0,
       },
     })