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) {
>
-