Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/opencode/src/cli/cmd/tui/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
Expand Down Expand Up @@ -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",
Expand Down
169 changes: 167 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
})
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -356,7 +382,7 @@ export function Session() {
})

// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const findNextVisibleMessage = (direction: "next" | "prev", role?: "user"): string | null => {
const children = scroll.getChildren()
const messagesList = messages()
const scrollTop = scroll.y
Expand All @@ -367,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]
Expand All @@ -386,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<typeof useDialog>) => {
const targetID = findNextVisibleMessage(direction)
Expand All @@ -401,6 +430,15 @@ export function Session() {
dialog.clear()
}

const scrollToUserMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
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
Expand Down Expand Up @@ -741,6 +779,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: () => scrollToUserMessage("next", dialog),
},
{
title: "Navigation mode previous message",
value: "session.navigation.message.previous",
category: "Session",
hidden: true,
enabled: navigationMode(),
run: () => scrollToUserMessage("prev", dialog),
},
{
title: "Page up",
value: "session.page.up",
Expand Down Expand Up @@ -1070,6 +1215,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)

Expand All @@ -1095,6 +1250,7 @@ export function Session() {

// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
createEffect(on(() => route.sessionID, () => setNavigationMode(false)))

return (
<PathFormatterProvider path={session()?.directory}>
Expand Down Expand Up @@ -1262,7 +1418,16 @@ export function Session() {
toBottom()
}}
sessionID={route.sessionID}
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
right={
<>
<Show when={navigationMode()}>
<text>
<span style={{ fg: theme.warning, bold: true }}>NAV</span>
</text>
</Show>
<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />
</>
}
/>
</TuiPluginRuntime.Slot>
</Show>
Expand Down
62 changes: 62 additions & 0 deletions packages/opencode/test/config/tui.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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"
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"

Expand Down Expand Up @@ -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: "<leader>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("<leader>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* () {
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading