From e48ff0520475af07e4fcf0d9e50c256468e6cea0 Mon Sep 17 00:00:00 2001 From: littledsw <19301662+littledsw@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:21:59 +0800 Subject: [PATCH] feat(tui): add markdown preview panel /preview opens a fuzzy file picker and renders the selected file as rendered markdown in a side panel next to the session: inline split on wide terminals, right-docked overlay on narrow ones, matching the sidebar layout pattern. Token-/command-configurable keybinds preview_toggle and preview_close default to unbound. --- packages/tui/src/config/keybind.ts | 4 + .../routes/session/dialog-preview-file.tsx | 54 ++++++++ packages/tui/src/routes/session/index.tsx | 73 +++++++++- packages/tui/src/routes/session/preview.tsx | 106 +++++++++++++++ .../test/cli/tui/markdown-preview.test.tsx | 126 ++++++++++++++++++ 5 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/routes/session/dialog-preview-file.tsx create mode 100644 packages/tui/src/routes/session/preview.tsx create mode 100644 packages/tui/test/cli/tui/markdown-preview.test.tsx diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..7328f72dcca6 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -80,6 +80,8 @@ export const Definitions = { theme_mode_lock: keybind("none", "Lock or unlock theme mode"), sidebar_toggle: keybind("b", "Toggle sidebar"), scrollbar_toggle: keybind("none", "Toggle session scrollbar"), + preview_toggle: keybind("none", "Toggle markdown preview"), + preview_close: keybind("none", "Close markdown preview"), status_view: keybind("s", "View status"), debug_view: keybind("none", "View debug info"), @@ -288,6 +290,8 @@ export const CommandMap = { theme_mode_lock: "theme.mode.lock", sidebar_toggle: "session.sidebar.toggle", scrollbar_toggle: "session.toggle.scrollbar", + preview_toggle: "session.preview.toggle", + preview_close: "session.preview.close", status_view: "opencode.status", debug_view: "opencode.debug", session_export: "session.export", diff --git a/packages/tui/src/routes/session/dialog-preview-file.tsx b/packages/tui/src/routes/session/dialog-preview-file.tsx new file mode 100644 index 000000000000..46a8265fb362 --- /dev/null +++ b/packages/tui/src/routes/session/dialog-preview-file.tsx @@ -0,0 +1,54 @@ +import path from "node:path" +import { createResource, createSignal } from "solid-js" +import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" +import { useDialog } from "../../ui/dialog" +import { useSDK } from "../../context/sdk" +import { useProject } from "../../context/project" + +export function DialogPreviewFile(props: { directory?: string; workspace?: string; onSelect: (file: string) => void }) { + const sdk = useSDK() + const dialog = useDialog() + const project = useProject() + + const [query, setQuery] = createSignal("") + const [files] = createResource( + () => query(), + async (q) => { + const result = await sdk.client.v2.fs + .find({ + query: q, + limit: "20", + location: { + directory: props.directory, + workspace: props.workspace ?? project.workspace.current(), + }, + }) + .catch(() => undefined) + if (!result || result.error || !result.data) return [] + const directory = result.data.location.directory + return result.data.data + .filter((item) => item.type === "file") + .map( + (item): DialogSelectOption => ({ + title: item.path, + value: path.join(directory, item.path), + }), + ) + }, + { initialValue: [] }, + ) + + return ( + { + props.onSelect(option.value) + dialog.clear() + }} + /> + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 61abe4abd8d9..6058b29c3480 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -54,6 +54,8 @@ import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" +import { DialogPreviewFile } from "./dialog-preview-file" +import { PreviewPanel } from "./preview" import { SubagentFooter } from "./subagent-footer.tsx" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" @@ -123,6 +125,8 @@ const sessionBindingCommands = [ "session.undo", "session.redo", "session.sidebar.toggle", + "session.preview.toggle", + "session.preview.close", "session.toggle.conceal", "session.toggle.timestamps", "session.toggle.thinking", @@ -275,8 +279,16 @@ export function Session() { if (sidebar() === "auto" && wide()) return true return false }) + const [previewFile, setPreviewFile] = createSignal() + const previewVisible = createMemo(() => previewFile() !== undefined) + const previewInline = createMemo(() => previewVisible() && wide()) + const previewWidth = createMemo(() => + Math.min(dimensions().width - 6, Math.max(40, Math.floor(dimensions().width * 0.4))), + ) const showTimestamps = createMemo(() => timestamps() === "show") - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + const contentWidth = createMemo( + () => dimensions().width - (sidebarVisible() ? 42 : 0) - (previewInline() ? previewWidth() : 0) - 4, + ) const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -683,6 +695,38 @@ export function Session() { dialog.clear() }, }, + { + title: previewVisible() ? "Close markdown preview" : "Preview markdown file", + value: "session.preview.toggle", + category: "Session", + slash: { + name: "preview", + }, + run: () => { + if (previewVisible()) { + setPreviewFile(undefined) + dialog.clear() + return + } + dialog.replace(() => ( + setPreviewFile(file)} + /> + )) + }, + }, + { + title: "Close markdown preview", + value: "session.preview.close", + category: "Session", + hidden: true, + enabled: previewVisible(), + run: () => { + setPreviewFile(undefined) + }, + }, { title: conceal() ? "Disable code concealment" : "Enable code concealment", value: "session.toggle.conceal", @@ -1356,6 +1400,33 @@ export function Session() { + + {(file) => ( + + + file()} directory={() => session()?.directory} width={previewWidth} /> + + + + file()} + directory={() => session()?.directory} + width={previewWidth} + overlay + /> + + + + )} + diff --git a/packages/tui/src/routes/session/preview.tsx b/packages/tui/src/routes/session/preview.tsx new file mode 100644 index 000000000000..e5d623b7df76 --- /dev/null +++ b/packages/tui/src/routes/session/preview.tsx @@ -0,0 +1,106 @@ +import path from "node:path" +import { createMemo, createResource, Show } from "solid-js" +import { TextAttributes } from "@opentui/core" +import { useTuiConfig } from "../../config" +import { useTheme } from "../../context/theme" +import { useCommandShortcut } from "../../keymap" +import { Locale } from "../../util/locale" +import { getScrollAcceleration } from "../../util/scroll" + +export function PreviewPanel(props: { + file: () => string + directory: () => string | undefined + width: () => number + overlay?: boolean +}) { + const { theme, syntax } = useTheme() + const tuiConfig = useTuiConfig() + const toggleShortcut = useCommandShortcut("session.preview.toggle") + const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + + const [content] = createResource( + () => props.file(), + (file) => + Bun.file(file) + .text() + .then((text) => ({ content: text }) as const) + .catch(() => ({ error: true }) as const), + ) + + const text = createMemo(() => { + const result = content() + if (!result || !("content" in result)) return + return result.content + }) + + const title = createMemo(() => { + const directory = props.directory() + const file = props.file() + if (!directory) return file + const relative = path.relative(directory, file) + if (relative.startsWith("..")) return file + return relative + }) + + return ( + + + + {Locale.truncateLeft(title(), props.width() - 5)} + + + + Loading… + + } + > + + Unable to read file + + } + > + + + + + + + + {toggleShortcut() || "/preview"} to close + + + + ) +} diff --git a/packages/tui/test/cli/tui/markdown-preview.test.tsx b/packages/tui/test/cli/tui/markdown-preview.test.tsx new file mode 100644 index 000000000000..99d1a83a2dfe --- /dev/null +++ b/packages/tui/test/cli/tui/markdown-preview.test.tsx @@ -0,0 +1,126 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onCleanup } from "solid-js" +import { tmpdir } from "../../fixture/fixture" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { TestTuiContexts } from "../../fixture/tui-environment" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +async function mountPreview(input: { root: string; file: string }) { + const state = path.join(input.root, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + + const [ + { KVProvider }, + { ThemeProvider }, + { TuiConfigProvider }, + { DialogProvider }, + { ToastProvider }, + { OpencodeKeymapProvider, registerOpencodeKeymap }, + { PreviewPanel }, + ] = await Promise.all([ + import("../../../src/context/kv"), + import("../../../src/context/theme"), + import("../../../src/config"), + import("../../../src/ui/dialog"), + import("../../../src/ui/toast"), + import("../../../src/keymap"), + import("../../../src/routes/session/preview"), + ]) + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const resolvedConfig = createTuiResolvedConfig({ + keybinds: {}, + leader_timeout: 1000, + }) + const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) + onCleanup(off) + + return ( + + + + + + + + + input.file} directory={() => input.root} width={() => 60} /> + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 80, height: 30 }) + return { + app, + async cleanup() { + app.renderer.destroy() + }, + } +} + +test("markdown preview renders file content", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "README.md") + await Bun.write(file, "# Preview Title\n\nfirst **bold** line\n") + const preview = await mountPreview({ root: tmp.path, file }) + + const settledFrame = async () => { + // theme config loads async at startup and triggers a re-render; let it settle + await wait(() => preview.app.captureCharFrame().includes("README.md")) + await Bun.sleep(300) + return preview.app.captureCharFrame() + } + + try { + const frame = await settledFrame() + expect(frame).toContain("Preview Title") + expect(frame).toContain("bold") + expect(frame).toContain("README.md") + } finally { + await preview.cleanup() + } +}) + +test("markdown preview shows error for unreadable file", async () => { + await using tmp = await tmpdir() + const preview = await mountPreview({ + root: tmp.path, + file: path.join(tmp.path, "missing.md"), + }) + + try { + await wait(() => preview.app.captureCharFrame().includes("Unable to read file")) + expect(preview.app.captureCharFrame()).toContain("Unable to read file") + } finally { + await preview.cleanup() + } +})