From e66d77b286b1ced82f57a9d0e97cc82044139726 Mon Sep 17 00:00:00 2001 From: Kai Date: Sun, 21 Jun 2026 17:26:58 +0200 Subject: [PATCH] feat(tui): add auto_scroll config to disable session viewport follow Setting auto_scroll to false stops the conversation pane from snapping to the bottom when new content arrives. Default stays true, so existing behavior is unchanged. --- packages/opencode/src/config/tui-migrate.ts | 6 +++++- packages/opencode/test/config/tui.test.ts | 4 +++- packages/sdk/js/src/gen/types.gen.ts | 4 ++++ packages/tui/src/config/index.tsx | 4 ++++ packages/tui/src/routes/session/index.tsx | 3 ++- packages/tui/src/util/scroll.ts | 1 + packages/tui/test/config.test.tsx | 4 +++- packages/web/src/content/docs/tui.mdx | 2 ++ 8 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/config/tui-migrate.ts index 6ca254311e3b..767de912dd09 100644 --- a/packages/opencode/src/config/tui-migrate.ts +++ b/packages/opencode/src/config/tui-migrate.ts @@ -14,6 +14,7 @@ const decodeTheme = Schema.decodeUnknownOption(Schema.String) const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed) const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration) +const decodeAutoScroll = Schema.decodeUnknownOption(Schema.Boolean) const decodeDiffStyle = Schema.decodeUnknownOption(TuiConfig.DiffStyle) interface MigrateInput { @@ -71,17 +72,20 @@ function normalizeTui(data: Record): | { scroll_speed: number | undefined scroll_acceleration: { enabled: boolean } | undefined + auto_scroll: boolean | undefined diff_style: "auto" | "stacked" | undefined } | undefined { const parsed = { scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)), scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)), + auto_scroll: Option.getOrUndefined(decodeAutoScroll(data.auto_scroll)), diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)), } return parsed.scroll_speed === undefined && parsed.diff_style === undefined && - parsed.scroll_acceleration === undefined + parsed.scroll_acceleration === undefined && + parsed.auto_scroll === undefined ? undefined : parsed } diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 3050467a42f1..733345e0312b 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -191,17 +191,19 @@ it.instance("migrates tui-specific keys from opencode.json when tui.json does no const source = path.join(test.directory, "opencode.json") yield* fs.writeJson(source, { theme: "migrated-theme", - tui: { scroll_speed: 5 }, + tui: { scroll_speed: 5, auto_scroll: false }, keybinds: { app_exit: "ctrl+q" }, }) const config = yield* getTuiConfig(test.directory) expect(config.theme).toBe("migrated-theme") expect(config.scroll_speed).toBe(5) + expect(config.auto_scroll).toBe(false) expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") expect(JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))).toMatchObject({ theme: "migrated-theme", scroll_speed: 5, + auto_scroll: false, }) const server = JSON.parse(yield* fs.readFileString(source)) expect(server.theme).toBeUndefined() diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5e4fd8906155..7efafb144ae4 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1203,6 +1203,10 @@ export type Config = { */ enabled: boolean } + /** + * Follow new output by sticking the conversation viewport to the bottom. When the user scrolls up, follow pauses until they return to the bottom (default: true) + */ + auto_scroll?: boolean /** * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column */ diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index df9239763a68..704fc41e0a06 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -61,6 +61,10 @@ export const Info = Schema.Struct({ prompt: Schema.optional(Prompt), scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }), scroll_acceleration: Schema.optional(ScrollAcceleration), + auto_scroll: Schema.optional(Schema.Boolean).annotate({ + description: + "Follow new output by sticking the conversation viewport to the bottom. When the user scrolls up, follow pauses until they return to the bottom (default: true)", + }), diff_style: Schema.optional(DiffStyle), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e3758374c870..3064bbf164c7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -267,6 +267,7 @@ export function Session() { const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const autoScroll = createMemo(() => tuiConfig.auto_scroll ?? true) const toast = useToast() const sdk = useSDK() const editor = useEditorContext() @@ -1173,7 +1174,7 @@ export function Session() { foregroundColor: theme.border, }, }} - stickyScroll={true} + stickyScroll={autoScroll()} stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} diff --git a/packages/tui/src/util/scroll.ts b/packages/tui/src/util/scroll.ts index 44645932dfea..7be18e24fd78 100644 --- a/packages/tui/src/util/scroll.ts +++ b/packages/tui/src/util/scroll.ts @@ -3,6 +3,7 @@ import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core" export type ScrollConfig = { scroll_acceleration?: { enabled?: boolean } scroll_speed?: number + auto_scroll?: boolean } export class CustomSpeedScroll implements ScrollAcceleration { diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 1ccb0986d15f..3b392452c419 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -30,14 +30,16 @@ test("validates config constraints", () => { attention: { volume: 1, sounds: { done: "done.wav" } }, prompt: { max_height: 10, max_width: "auto" }, scroll_speed: 0.001, + auto_scroll: false, diff_style: "stacked", plugin: ["example-plugin"], }), - ).toMatchObject({ leader_timeout: 250, attention: { volume: 1 }, diff_style: "stacked" }) + ).toMatchObject({ leader_timeout: 250, attention: { volume: 1 }, auto_scroll: false, diff_style: "stacked" }) expect(() => decodeInfo({ leader_timeout: 0 })).toThrow() expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow() expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow() expect(() => decodeInfo({ scroll_speed: 0 })).toThrow() + expect(() => decodeInfo({ auto_scroll: "yes" })).toThrow() expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } }) }) diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index a03797b302be..359cf9f0e4c2 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -368,6 +368,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). "scroll_acceleration": { "enabled": false }, + "auto_scroll": true, "diff_style": "auto", "mouse": true, "attention": { @@ -394,6 +395,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`. - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** +- `auto_scroll` - Follow new output by sticking the conversation viewport to the bottom (default: `true`). Set to `false` to disable auto-follow so the viewport stays where you left it while messages stream in. - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. - `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved. - `attention` - Configures TUI desktop notifications and sounds. Disabled by default.