From d446f8f24603f768ae99c87ea1722ddc97af975b Mon Sep 17 00:00:00 2001 From: Samir Alibabic Date: Fri, 15 May 2026 22:44:50 +0200 Subject: [PATCH 1/2] feat(tui): add session navigation mode --- .../src/cli/cmd/tui/config/keybind.ts | 24 +++ .../src/cli/cmd/tui/routes/session/index.tsx | 157 +++++++++++++++++- packages/opencode/test/config/tui.test.ts | 62 +++++++ packages/web/src/content/docs/config.mdx | 2 + packages/web/src/content/docs/keybinds.mdx | 48 ++++++ 5 files changed, 291 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index bd26cd5d95e4..6aca26acc81b 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -79,6 +79,18 @@ export const Definitions = { session_share: keybind("none", "Share current session"), session_unshare: keybind("none", "Unshare current session"), session_interrupt: keybind("escape", "Interrupt current session"), + session_navigation_enter: keybind("escape", "Enter session navigation mode"), + session_navigation_exit: keybind("i,a,return", "Exit session navigation mode and focus prompt"), + session_navigation_line_down: keybind("j", "Navigation mode: scroll messages down one line"), + session_navigation_line_up: keybind("k", "Navigation mode: scroll messages up one line"), + session_navigation_half_page_down: keybind("ctrl+d", "Navigation mode: scroll messages down half page"), + session_navigation_half_page_up: keybind("ctrl+u", "Navigation mode: scroll messages up half page"), + session_navigation_page_down: keybind("ctrl+f", "Navigation mode: scroll messages down one page"), + session_navigation_page_up: keybind("ctrl+b", "Navigation mode: scroll messages up one page"), + session_navigation_first: keybind("gg", "Navigation mode: jump to first message"), + session_navigation_last: keybind("shift+g", "Navigation mode: jump to last message"), + session_navigation_next_message: keybind("n", "Navigation mode: jump to next user message"), + session_navigation_previous_message: keybind("shift+n,p", "Navigation mode: jump to previous user message"), session_compact: keybind("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), @@ -262,6 +274,18 @@ export const CommandMap = { session_share: "session.share", session_unshare: "session.unshare", session_interrupt: "session.interrupt", + session_navigation_enter: "session.navigation.enter", + session_navigation_exit: "session.navigation.exit", + session_navigation_line_down: "session.navigation.line.down", + session_navigation_line_up: "session.navigation.line.up", + session_navigation_half_page_down: "session.navigation.half.page.down", + session_navigation_half_page_up: "session.navigation.half.page.up", + session_navigation_page_down: "session.navigation.page.down", + session_navigation_page_up: "session.navigation.page.up", + session_navigation_first: "session.navigation.first", + session_navigation_last: "session.navigation.last", + session_navigation_next_message: "session.navigation.message.next", + session_navigation_previous_message: "session.navigation.message.previous", session_compact: "session.compact", session_toggle_timestamps: "session.toggle.timestamps", session_toggle_generic_tool_output: "session.toggle.generic_tool_output", 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 376fed0d7dd7..80747a2a1437 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -154,6 +154,22 @@ const sessionBindingCommands = [ "session.child.previous", ] as const +const sessionNavigationEnterBindingCommands = ["session.navigation.enter"] as const + +const sessionNavigationBindingCommands = [ + "session.navigation.exit", + "session.navigation.line.down", + "session.navigation.line.up", + "session.navigation.half.page.down", + "session.navigation.half.page.up", + "session.navigation.page.down", + "session.navigation.page.up", + "session.navigation.first", + "session.navigation.last", + "session.navigation.message.next", + "session.navigation.message.previous", +] as const + const context = createContext<{ width: number sessionID: string @@ -208,6 +224,8 @@ export function Session() { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id }) + const status = createMemo(() => sync.data.session_status?.[route.sessionID] ?? { type: "idle" }) + const lastAssistant = createMemo(() => { return messages().findLast((x) => x.role === "assistant") }) @@ -226,6 +244,7 @@ export function Session() { const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const [navigationMode, setNavigationMode] = createSignal(false) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -314,6 +333,13 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() + createEffect(() => { + if (!navigationMode()) return + if (visible() && status().type === "idle" && dialog.stack.length === 0) return + if (visible() && dialog.stack.length === 0) prompt?.focus() + setNavigationMode(false) + }) + event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.status.type !== "retry") return @@ -355,7 +381,7 @@ export function Session() { ) }) - // Helper: Find next visible message boundary in direction + // Helper: Find next visible user message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() const messagesList = messages() @@ -741,6 +767,113 @@ export function Session() { dialog.clear() }, }, + { + title: "Enter navigation mode", + value: "session.navigation.enter", + category: "Session", + hidden: true, + enabled: visible() && status().type === "idle" && !navigationMode(), + run: () => { + if (dialog.stack.length > 0) return + if (!visible()) return + if (status().type !== "idle") return + setNavigationMode(true) + prompt?.blur() + dialog.clear() + }, + }, + { + title: "Exit navigation mode", + value: "session.navigation.exit", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => { + setNavigationMode(false) + prompt?.focus() + dialog.clear() + }, + }, + { + title: "Navigation mode line down", + value: "session.navigation.line.down", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.line.down"), + }, + { + title: "Navigation mode line up", + value: "session.navigation.line.up", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.line.up"), + }, + { + title: "Navigation mode half page down", + value: "session.navigation.half.page.down", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.half.page.down"), + }, + { + title: "Navigation mode half page up", + value: "session.navigation.half.page.up", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.half.page.up"), + }, + { + title: "Navigation mode page down", + value: "session.navigation.page.down", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.page.down"), + }, + { + title: "Navigation mode page up", + value: "session.navigation.page.up", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.page.up"), + }, + { + title: "Navigation mode first message", + value: "session.navigation.first", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.first"), + }, + { + title: "Navigation mode last message", + value: "session.navigation.last", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.last"), + }, + { + title: "Navigation mode next message", + value: "session.navigation.message.next", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.message.next"), + }, + { + title: "Navigation mode previous message", + value: "session.navigation.message.previous", + category: "Session", + hidden: true, + enabled: navigationMode(), + run: () => command.run("session.message.previous"), + }, { title: "Page up", value: "session.page.up", @@ -1070,6 +1203,16 @@ export function Session() { bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) + useBindings(() => ({ + enabled: () => command.matcher.get() && !navigationMode() && visible() && status().type === "idle", + bindings: tuiConfig.keybinds.gather("session-navigation-enter", sessionNavigationEnterBindingCommands), + })) + + useBindings(() => ({ + enabled: () => command.matcher.get() && navigationMode(), + bindings: tuiConfig.keybinds.gather("session-navigation", sessionNavigationBindingCommands), + })) + const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) @@ -1095,6 +1238,7 @@ export function Session() { // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) + createEffect(on(() => route.sessionID, () => setNavigationMode(false))) return ( @@ -1262,7 +1406,16 @@ export function Session() { toBottom() }} sessionID={route.sessionID} - right={} + right={ + <> + + + NAV + + + + + } /> diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 4eb96b95761d..09988fbd4212 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { createTestKeymap } from "@opentui/keymap/testing" import { Effect, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" @@ -8,6 +9,7 @@ import { Config } from "@/config/config" import { ConfigPlugin } from "@/config/plugin" import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" +import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -501,6 +503,66 @@ it.instance("resolves keybind lookup from canonical keybinds", () => ), ) +it.instance("resolves session navigation keybinds separately from normal message keybinds", () => + withCleanState( + Effect.gen(function* () { + const test = yield* TestInstance + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("session.navigation.enter")?.[0]?.key).toBe("escape") + expect(config.keybinds.get("session.navigation.exit")?.[0]?.key).toBe("i,a,return") + expect(config.keybinds.get("session.navigation.line.down")?.[0]?.key).toBe("j") + expect(config.keybinds.get("session.navigation.line.up")?.[0]?.key).toBe("k") + expect(config.keybinds.get("session.navigation.first")?.[0]?.key).toBe("gg") + expect(config.keybinds.get("session.navigation.last")?.[0]?.key).toBe("shift+g") + expect(config.keybinds.get("session.navigation.message.previous")?.[0]?.key).toBe("shift+n,p") + expect(config.keybinds.get("session.line.down")?.[0]?.key).toBe("ctrl+alt+e") + expect(config.keybinds.get("session.line.up")?.[0]?.key).toBe("ctrl+alt+y") + expect( + config.keybinds + .gather("session-navigation-test", ["session.navigation.line.down", "session.navigation.line.up"]) + .map((binding) => binding.cmd), + ).toEqual(["session.navigation.line.down", "session.navigation.line.up"]) + }), + ), +) + +it.instance("applies explicit session navigation keybind overrides", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + keybinds: { + session_navigation_enter: "v", + session_navigation_line_down: "down", + session_navigation_first: "ctrl+g", + }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("session.navigation.enter")?.[0]?.key).toBe("v") + expect(config.keybinds.get("session.navigation.line.down")?.[0]?.key).toBe("down") + expect(config.keybinds.get("session.navigation.first")?.[0]?.key).toBe("ctrl+g") + expect(config.keybinds.get("session.line.down")?.[0]?.key).toBe("ctrl+alt+e") + }), + ), +) + +it.effect("parses session navigation first as a gg sequence", () => + Effect.sync(() => { + const first = TuiKeybind.defaultValue("session_navigation_first") + if (typeof first !== "string") throw new Error("session_navigation_first should be a string keybind") + + const harness = createTestKeymap({ defaultKeys: true }) + try { + expect(harness.keymap.parseKeySequence(first).map((part) => part.display)).toEqual(["g", "g"]) + } finally { + harness.cleanup() + } + }), +) + it.instance("keybinds accept OpenTUI binding specs", () => withCleanState( Effect.gen(function* () { diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 3cb9d93748b9..a0facaf7deeb 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -285,6 +285,8 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings. Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file. +Use `mouse: false` to disable mouse capture. Session navigation mode provides keyboard-first message navigation. + Set `attention.enabled` to turn on TUI desktop notifications and sounds. See [TUI attention](/docs/tui#attention). Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible. diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index f083bb40b0b6..b0c417bfe32d 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -43,6 +43,18 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "session_share": "none", "session_unshare": "none", "session_interrupt": "escape", + "session_navigation_enter": "escape", + "session_navigation_exit": "i,a,return", + "session_navigation_line_down": "j", + "session_navigation_line_up": "k", + "session_navigation_half_page_down": "ctrl+d", + "session_navigation_half_page_up": "ctrl+u", + "session_navigation_page_down": "ctrl+f", + "session_navigation_page_up": "ctrl+b", + "session_navigation_first": "gg", + "session_navigation_last": "shift+g", + "session_navigation_next_message": "n", + "session_navigation_previous_message": "shift+n,p", "session_compact": "c", "session_toggle_timestamps": "none", "session_toggle_generic_tool_output": "none", @@ -199,6 +211,42 @@ Some navigation keybinds intentionally do not use the leader key by default. For --- +## Session Navigation Mode + +Session navigation mode lets you navigate long session output with Vim-style keys without changing prompt editing behavior. + +While the prompt is focused, letters type normally. In navigation mode, the prompt is blurred, a compact `NAV` indicator is shown, and bare navigation keys control the session output. + +Entering navigation mode does not override dialogs, autocomplete, or running session interrupts. + +| Shortcut | Action | +| ----------------- | ---------------------------------------------------------------------- | +| `escape` | Enter session navigation mode | +| `i`, `a`, `enter` | Exit navigation mode and focus prompt | +| `j` | Scroll messages down one line | +| `k` | Scroll messages up one line | +| `ctrl+d` | Scroll messages down half page | +| `ctrl+u` | Scroll messages up half page | +| `ctrl+f` | Scroll messages down one page | +| `ctrl+b` | Scroll messages up one page | +| `gg` | Jump to first message | +| `G` | Jump to last message | +| `n` | Jump to next user message | +| `N`, `p` | Jump to previous user message | + +This is not full Vim mode for prompt editing. Prompt text editing still uses normal input keybinds. + +For terminal-native keyboard navigation, you can also disable mouse capture: + +```jsonc title="tui.jsonc" +{ + "$schema": "https://opencode.ai/tui.json", + "mouse": false +} +``` + +--- + ## Binding Values A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts. From 687c9c8b996cbc5f983addc6e9c4343702e5b5f6 Mon Sep 17 00:00:00 2001 From: Samir Alibabic Date: Fri, 15 May 2026 22:57:34 +0200 Subject: [PATCH 2/2] fix(tui): limit nav message jumps to users --- .../src/cli/cmd/tui/routes/session/index.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 80747a2a1437..6f931e685cae 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -381,8 +381,8 @@ export function Session() { ) }) - // Helper: Find next visible user message boundary in direction - const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { + // Helper: Find next visible message boundary in direction + const findNextVisibleMessage = (direction: "next" | "prev", role?: "user"): string | null => { const children = scroll.getChildren() const messagesList = messages() const scrollTop = scroll.y @@ -393,6 +393,7 @@ export function Session() { if (!c.id) return false const message = messagesList.find((m) => m.id === c.id) if (!message) return false + if (role && message.role !== role) return false // Check if message has valid non-synthetic, non-ignored text parts const parts = sync.data.part[message.id] @@ -412,6 +413,8 @@ export function Session() { return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null } + const findNextVisibleUserMessage = (direction: "next" | "prev") => findNextVisibleMessage(direction, "user") + // Helper: Scroll to message in direction or fallback to page scroll const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType) => { const targetID = findNextVisibleMessage(direction) @@ -427,6 +430,15 @@ export function Session() { dialog.clear() } + const scrollToUserMessage = (direction: "next" | "prev", dialog: ReturnType) => { + const targetID = findNextVisibleUserMessage(direction) + if (!targetID) return + + const child = scroll.getChildren().find((c) => c.id === targetID) + if (child) scroll.scrollBy(child.y - scroll.y - 1) + dialog.clear() + } + function toBottom() { setTimeout(() => { if (!scroll || scroll.isDestroyed) return @@ -864,7 +876,7 @@ export function Session() { category: "Session", hidden: true, enabled: navigationMode(), - run: () => command.run("session.message.next"), + run: () => scrollToUserMessage("next", dialog), }, { title: "Navigation mode previous message", @@ -872,7 +884,7 @@ export function Session() { category: "Session", hidden: true, enabled: navigationMode(), - run: () => command.run("session.message.previous"), + run: () => scrollToUserMessage("prev", dialog), }, { title: "Page up",