diff --git a/.github/scrollbar.gif b/.github/scrollbar.gif new file mode 100644 index 00000000000..8618d69246c Binary files /dev/null and b/.github/scrollbar.gif differ diff --git a/README.md b/README.md index c30aa7a07a7..9c52b090200 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,28 @@ Works similarly to tmux copy mode within opencode tui. +### Prompt Input + +Prompt input height is configurable with `prompt_max_height` in `tui.json`. + +When the prompt grows past the visible area, a scrollbar appears automatically. + +```json +{ + "prompt_max_height": 35, + "prompt_scrollbar": true +} +``` + + + +> [!NOTE] +> When typing `gg` / `G` focus the prompt input. +> `H` / `M` / `L` are supported for viewport-relative navigation. + +> [!WARNING] +> Setting `prompt_max_height` above `40` is not recommended. + ### Minimal UI Hides extra UI hints and tips. diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 6b058eb23d0..0bc7a26a5ee 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -53,6 +53,7 @@ import { clearSelection } from "../vim/vim-motions" import { vimScroll } from "../vim/vim-scroll" import { useVimIndicator } from "../vim/vim-indicator" import { emptyRows } from "./empty-selection" +import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { sessionID?: string @@ -129,9 +130,64 @@ export function Prompt(props: PromptProps) { const { theme, syntax } = useTheme() const kv = useKV() const vimEnabled = useVimEnabled() + const tuiConfig = useTuiConfig() const mini = createMemo(() => kv.get("ui_minimal", false)) const list = createMemo(() => props.placeholders?.normal ?? PLACEHOLDERS) const shell = createMemo(() => props.placeholders?.shell ?? SHELL_PLACEHOLDERS) + const maxHeight = createMemo(() => tuiConfig?.prompt_max_height ?? 6) + const showScrollbar = createMemo(() => tuiConfig?.prompt_scrollbar !== false) + + // Scrollbar state: array of chars to render in the 1-col gutter + const [scrollbar, setScrollbar] = createSignal(null) + function syncScrollbar() { + setTimeout(() => { + if (!input || input.isDestroyed) return + if (!showScrollbar()) { + if (scrollbar() !== null) { + setScrollbar(null) + renderer.requestRender() + } + return + } + const total = input.editorView.getTotalVirtualLineCount() + const h = input.height + if (total <= h) { + if (scrollbar() !== null) { + setScrollbar(null) + renderer.requestRender() + } + return + } + // Replicate native slider half-block rendering + const range = total - h + const virtual = h * 2 + const size = Math.max(1, Math.floor(virtual * (h / total))) + const ratio = range > 0 ? input.scrollY / range : 0 + const start = Math.round(ratio * (virtual - size)) + const end = start + size + const chars: string[] = [] + for (let row = 0; row < h; row++) { + const cs = row * 2 + const ce = cs + 2 + const ts = Math.max(start, cs) + const te = Math.min(end, ce) + const cov = te - ts + if (cov >= 2) chars.push("\u2588") + else if (cov > 0) chars.push(ts - cs === 0 ? "\u2580" : "\u2584") + else chars.push(" ") + } + setScrollbar(chars) + renderer.requestRender() + }, 0) + } + + createEffect(() => { + if (showScrollbar()) { + syncScrollbar() + return + } + if (scrollbar() !== null) setScrollbar(null) + }) function promptModelWarning() { toast.show({ @@ -243,6 +299,39 @@ export function Prompt(props: PromptProps) { onCleanup(() => { if (timer) clearTimeout(timer) }) + + function promptActive() { + if (!input || input.isDestroyed) return false + return input.plainText.length > 0 + } + + function promptJump(action: "top" | "bottom" | "high" | "middle" | "low") { + if (!input || input.isDestroyed) return + if (action === "top") { + input.gotoBufferHome() + return + } + if (action === "bottom") { + input.gotoBufferEnd() + return + } + + const row = + action === "high" ? 0 : action === "middle" ? Math.max(0, Math.floor((input.height - 1) / 2)) : input.height - 1 + + let prev = -1 + while (input.visualCursor.visualRow > row && input.cursorOffset !== prev) { + prev = input.cursorOffset + input.moveCursorUp() + } + + prev = -1 + while (input.visualCursor.visualRow < row && input.cursorOffset !== prev) { + prev = input.cursorOffset + input.moveCursorDown() + } + } + const vim = createVimHandler({ enabled: vimEnabled, state: vimState, @@ -257,6 +346,14 @@ export function Prompt(props: PromptProps) { if (action === "page-up") command.trigger("session.page.up") }, jump(action) { + if (action === "high" || action === "middle" || action === "low") { + promptJump(action) + return + } + if (promptActive()) { + promptJump(action) + return + } if (action === "top") command.trigger("session.first") if (action === "bottom") command.trigger("session.last") }, @@ -1081,235 +1178,263 @@ export function Prompt(props: PromptProps) { > -