From 70bda1ca11f4ed2e7594b1c8073ab7fb659f7839 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Sun, 20 Sep 2026 11:06:01 +0800 Subject: [PATCH] feat(tui): add an interactive fullscreen transcript scrollbar Adapt drowzeys/minimax-code commit 0b24a7778741fda335a9a2074464c69911feb1aa from issue #216. Reserve the three-column hit gutter, preserve content selection and mouse routing, and cancel scrollbar drags beneath overlays. Co-authored-by: drowzeys --- .../tui/src/tui/engine/LOCAL_CHANGES.json | 15 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 9 + .../src/tui/engine/components/scroll-view.ts | 10 +- packages/tui/src/tui/engine/tui-alt-screen.ts | 60 ++-- packages/tui/src/tui/shell/chat-layout.ts | 2 + .../unit/tui-scrollbar-interaction.test.ts | 305 ++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + 8 files changed, 379 insertions(+), 24 deletions(-) create mode 100644 packages/tui/test/unit/tui-scrollbar-interaction.test.ts diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 8d88e13ec..3fb374d81 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -47,6 +47,13 @@ "reason": "Keep strict TypeScript fixes and product Markdown hooks, plus Pi's post-baseline wrapped-table style restoration. 链接标签使用独立样式上下文,防止外层正文或标题颜色覆盖链接颜色。 稳定 Markdown 块缓存", "behaviorImpact": "MCode keeps its compact code presentation without reimplementing Pi Markdown or leaking wrapped link styles into table borders and adjacent cells. 链接保留嵌套强调与行内代码,并在结束后恢复外层样式。 保留两个尾部块并对引用回退全文。" }, + { + "path": "components/scroll-view.ts", + "currentSha256": "6f82fb1f426fdf81ca7058e6a1ea4a94c9ddd8f0e15d69c9020f9c66ca5411dd", + "changeIds": ["L035"], + "reason": "Allow hosts to reserve a wider always-visible scrollbar gutter while retaining at least one content column.", + "behaviorImpact": "The fullscreen transcript reserves three columns; other scroll views retain the default one-column gutter." + }, { "path": "components/settings-list.ts", "currentSha256": "959f7db23190e0e526ca84c3032443d7d15248d891809f28c837cfa958692b5c", @@ -148,11 +155,11 @@ }, { "path": "tui-alt-screen.ts", - "currentSha256": "dd8acaa441bab2e674e6cba946dcb130b97e51098adf158bda1f73bd027d7a4b", - "changeIds": ["L005", "L011", "L019", "L020", "L025", "L031"], + "currentSha256": "b1e6b332e976c5224f77ce82ee850d35c601ad18c3719d9c824b7c9db1245a78", + "changeIds": ["L005", "L011", "L019", "L020", "L025", "L031", "L035"], "upstreamCommit": "4e494929998d6bc4fccf75e0a233f727db4b70ee,1ac6128e66bd44668b58db773c2a76c29163ade5", - "reason": "Keep strict TypeScript fixes and layout-first mouse dispatch, join path and kebab-case word segments, and expose configurable fullscreen selection copying helpers. 活动搜索索引与命中复用 独立面板声明键盘分页归属。", - "behaviorImpact": "Fullscreen product interaction clicks and Pi-owned text selection coexist; double-click selects complete path-like tokens, and hosts can disable automatic copy or copy an active selection explicitly. 搜索交互复用索引。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" + "reason": "Keep strict TypeScript fixes and layout-first mouse dispatch, join path and kebab-case word segments, and expose configurable fullscreen selection copying helpers. 活动搜索索引与命中复用 独立面板声明键盘分页归属。 Reserve scrollbar hit targets for gutter columns and support track jumps and thumb dragging.", + "behaviorImpact": "Fullscreen product interaction clicks and Pi-owned text selection coexist; double-click selects complete path-like tokens, and hosts can disable automatic copy or copy an active selection explicitly. 搜索交互复用索引。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。 Fullscreen track presses jump and drag without consuming adjacent content; visible overlays cancel active scrollbar drags." }, { "path": "tui-main-screen.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index a0ec30447..e2f84903b 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -106,3 +106,12 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Change: strip leading zone prefixes before comparing screen lines, so initial frames, differential redraws, and history caches stay clean. Fullscreen behavior is unchanged. - Evidence: `test/unit/tui-engine-local-deltas.test.ts` checks initial and differential writes. - Removal condition: the selected Pi baseline supplies equivalent normal-mode filtering. + +## L035: Fullscreen scrollbar track interaction + +- Origin: adapted from [drowzeys/minimax-code commit 0b24a7778741fda335a9a2074464c69911feb1aa](https://github.com/drowzeys/minimax-code/commit/0b24a7778741fda335a9a2074464c69911feb1aa), contributed by drowzeys under the existing MIT license in [issue #216](https://github.com/MiniMax-AI/minimax-code/issues/216). +- Product contract: the fullscreen transcript enables an always-visible scrollbar with a three-column hit gutter. Track presses center the thumb and jump before dragging; thumb presses retain their grab offset without an initial jump. +- Minimal difference: `components/scroll-view.ts` accepts an optional `scrollbarGutter` for always-visible bars, defaulting to one column and retaining at least one content column on narrow terminals. `tui-alt-screen.ts` restricts wide hit targets to that reserved gutter, factors drag mapping into `dragScrollbarTo`, and cancels a drag when an overlay receives the next mouse event. Auto bars retain a one-column target. `layout.ts` stays at the Pi baseline. +- Adaptation: the original contribution widened hits over two live content columns. The reserved gutter prevents those presses from swallowing content clicks or text selection. `chat-layout.ts` opts into the three-column gutter. +- Evidence: `test/unit/tui-scrollbar-interaction.test.ts` drives SGR press, motion, release and wheel events through VirtualTerminal, covering track jumps, thumb grabs, narrow terminals, content routing, selection, overlays and the actual fullscreen ChatLayout. +- Removal condition: the selected Pi baseline provides equivalent reserved-gutter track and drag interaction and MCode migrates to it. diff --git a/packages/tui/src/tui/engine/components/scroll-view.ts b/packages/tui/src/tui/engine/components/scroll-view.ts index b10421282..41f8444ad 100644 --- a/packages/tui/src/tui/engine/components/scroll-view.ts +++ b/packages/tui/src/tui/engine/components/scroll-view.ts @@ -9,6 +9,8 @@ export interface ScrollViewOptions { primary?: boolean; overscroll?: "chain" | "contain"; scrollbar?: ScrollViewScrollbar; + /** Columns reserved for an always-visible scrollbar, including its hit gutter. */ + scrollbarGutter?: number; scrollbarStyle?: (text: string) => string; scrollbarHideDelayMs?: number; } @@ -24,6 +26,7 @@ export class ScrollView extends Container { readonly primary: boolean; readonly overscroll: "chain" | "contain"; readonly scrollbarStyle: (text: string) => string; + private readonly scrollbarGutter: number; private currentScrollbar: ScrollViewScrollbar; private readonly scrollbarHideDelayMs: number; private currentScrollTop = 0; @@ -48,6 +51,9 @@ export class ScrollView extends Container { this.primary = options.primary ?? false; this.overscroll = options.overscroll ?? "chain"; this.currentScrollbar = options.scrollbar ?? "hidden"; + this.scrollbarGutter = Number.isFinite(options.scrollbarGutter) + ? Math.max(1, Math.floor(options.scrollbarGutter ?? 1)) + : 1; this.scrollbarStyle = options.scrollbarStyle ?? ((text) => `\x1b[100m${text}\x1b[49m`); this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000)); } @@ -84,7 +90,7 @@ export class ScrollView extends Container { } getContentWidth(width: number): number { - return this.scrollbar === "always" && width > 1 ? width - 1 : width; + return this.scrollbar === "always" && width > 1 ? Math.max(1, width - this.scrollbarGutter) : width; } private markScrollbarActivity(): void { @@ -207,7 +213,7 @@ export class ScrollView extends Container { override render(width: number): string[] { const contentWidth = this.getContentWidth(width); const lines = this.child.render(contentWidth); - return contentWidth === width ? lines : lines.map((line) => `${line} `); + return contentWidth === width ? lines : lines.map((line) => `${line}${" ".repeat(width - contentWidth)}`); } [LAYOUT_NODE](): ScrollLayoutNode { diff --git a/packages/tui/src/tui/engine/tui-alt-screen.ts b/packages/tui/src/tui/engine/tui-alt-screen.ts index e92b1b093..4fa374a5a 100644 --- a/packages/tui/src/tui/engine/tui-alt-screen.ts +++ b/packages/tui/src/tui/engine/tui-alt-screen.ts @@ -776,11 +776,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { const box = getScrollViewBox(this.currentLayout, scrollView); const geometry = box ? getScrollbarGeometry(box) : undefined; + // Only reserved gutter columns may capture presses beside the painted bar. + const hitColumns = box + ? scrollView.scrollbar === "always" + ? box.rect.width - scrollView.getContentWidth(box.rect.width) + : 1 + : 0; if ( geometry && - x === geometry.column && - y >= geometry.thumbTop && - y < geometry.thumbTop + geometry.thumbHeight + x >= geometry.column - (hitColumns - 1) && + x <= geometry.column && + y >= geometry.trackTop && + y < geometry.trackTop + geometry.trackHeight ) { return { scrollView, geometry }; } @@ -804,25 +811,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean { + if (this.hasOverlay()) { + this.stopScrollbarDrag(); + return false; + } if (this.scrollbarDrag) { if (event.release) { this.stopScrollbarDrag(); return true; } - const box = this.currentLayout - ? getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView) - : undefined; - const geometry = box ? getScrollbarGeometry(box) : undefined; - if (geometry) { - const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight; - const thumbOffset = Math.max( - 0, - Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset), - ); - const scrollTop = - maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop); - this.scrollbarDrag.scrollView.scrollTo(scrollTop); - } + this.dragScrollbarTo(event.y); return true; } @@ -839,13 +837,39 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.pressedUrl = undefined; this.selectionDragged = false; this.setScrollbarHover(target.scrollView); + const onThumb = + event.y >= target.geometry.thumbTop && + event.y < target.geometry.thumbTop + target.geometry.thumbHeight; this.scrollbarDrag = { scrollView: target.scrollView, - grabOffset: event.y - target.geometry.thumbTop, + grabOffset: onThumb + ? event.y - target.geometry.thumbTop + : Math.max(0, Math.floor(target.geometry.thumbHeight / 2)), }; + // A press on the empty track jumps the thumb so its center follows the + // pointer, then keeps dragging from there; a press on the thumb itself + // grabs it in place without moving. + if (!onThumb) this.dragScrollbarTo(event.y); return true; } + private dragScrollbarTo(y: number): void { + if (!this.scrollbarDrag || !this.currentLayout) return; + const box = getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView); + const geometry = box ? getScrollbarGeometry(box) : undefined; + if (!geometry) return; + const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight; + const thumbOffset = Math.max( + 0, + Math.min(maxThumbOffset, y - geometry.trackTop - this.scrollbarDrag.grabOffset), + ); + const scrollTop = + maxThumbOffset === 0 + ? 0 + : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop); + this.scrollbarDrag.scrollView.scrollTo(scrollTop); + } + private stopScrollbarDrag(): void { this.scrollbarDrag = undefined; } diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index 01abf46c0..e3e2a62e9 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -124,6 +124,8 @@ export class TuiChatLayout implements Component { follow: 'end', primary: true, overscroll: 'contain', + scrollbar: 'always', + scrollbarGutter: 3, }); this.fullscreenLayoutRoot = new VStack([ { diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts new file mode 100644 index 000000000..10d95b164 --- /dev/null +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -0,0 +1,305 @@ +import { stripVTControlCharacters } from "node:util"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ScrollView, + TuiAltScreen, + type Component, + type TuiMouseEvent, + visibleWidth, +} from "../../src/tui/engine/public.js"; +import { TuiChatLayout } from "../../src/tui/shell/chat-layout.js"; +import { VirtualTerminal } from "../pi-084-upstream/virtual-terminal.js"; + +const COLUMNS = 20; +const ROWS = 10; +const CONTENT_LINES = 60; +const screens: TuiAltScreen[] = []; +afterEach(() => { + for (const tui of screens.splice(0)) tui.stop(); +}); + +class ScrollableLines implements Component { + private readonly lines: string[] = []; + + constructor(count: number) { + for (let index = 0; index < count; index++) { + this.lines.push(`line-${String(index).padStart(2, "0")}`); + } + } + + render(): string[] { + return [...this.lines]; + } + + invalidate(): void {} +} + +function viewportText(terminal: VirtualTerminal): string { + return stripVTControlCharacters(terminal.getViewport().join("\n")); +} + +function sgrPress(x: number, y: number): string { + return `\x1b[<0;${x + 1};${y + 1}M`; +} + +function sgrMotion(x: number, y: number): string { + return `\x1b[<32;${x + 1};${y + 1}M`; +} + +function sgrRelease(x: number, y: number): string { + return `\x1b[<0;${x + 1};${y + 1}m`; +} + +async function createScrollbarScreen( + options: { + columns?: number; + gutter?: number; + content?: Component; + copySelection?: (text: string) => Promise; + } = {}, +): Promise<{ + terminal: VirtualTerminal; + tui: TuiAltScreen; + scrollView: ScrollView; +}> { + const terminal = new VirtualTerminal(options.columns ?? COLUMNS, ROWS); + const content = options.content ?? new ScrollableLines(CONTENT_LINES); + const scrollView = new ScrollView(content, { + primary: true, + overscroll: "contain", + scrollbar: "always", + scrollbarGutter: options.gutter ?? 3, + }); + const tui = new TuiAltScreen(terminal, false, undefined, { + copySelection: options.copySelection, + }); + screens.push(tui); + tui.setLayoutRoot(scrollView); + tui.start(); + await terminal.waitForRender(); + return { terminal, tui, scrollView }; +} + +describe("Fullscreen scrollbar interaction", () => { + it("shows the top of the content before any interaction", async () => { + const { terminal } = await createScrollbarScreen(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-59"); + }); + + it("jumps the thumb to a track press and keeps dragging from there", async () => { + const { terminal } = await createScrollbarScreen(); + // Press the empty track at the bottom of the right-edge scrollbar. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + expect(viewportText(terminal)).not.toContain("line-00"); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + }); + + it("grabs near-miss clicks within the widened hit span", async () => { + const { terminal } = await createScrollbarScreen(); + // Press two columns left of the painted one-column bar. + terminal.sendInput(sgrPress(COLUMNS - 3, ROWS - 1)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + expect(viewportText(terminal)).not.toContain("line-00"); + terminal.sendInput(sgrRelease(COLUMNS - 3, ROWS - 1)); + await terminal.waitForRender(); + }); + + it("keeps an on-thumb press in place and drags proportionally", async () => { + const { terminal } = await createScrollbarScreen(); + // Jump to the end first via a track press at the bottom, then release. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + // The thumb now spans the bottom rows; press it directly. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 2)); + await terminal.waitForRender(); + // An on-thumb press must not move content on its own. + expect(viewportText(terminal)).toContain("line-59"); + // Drag the thumb up the track. + terminal.sendInput(sgrMotion(COLUMNS - 1, Math.floor(ROWS / 2))); + await terminal.waitForRender(); + const text = viewportText(terminal); + expect(text).not.toContain("line-59"); + expect(text).not.toContain("line-00"); + terminal.sendInput(sgrRelease(COLUMNS - 1, Math.floor(ROWS / 2))); + await terminal.waitForRender(); + }); + + it("ignores presses outside the hit span", async () => { + const { terminal } = await createScrollbarScreen(); + // Jump to the end via a track press, then release. + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + // Press three columns left of the bar, outside the hit span. + terminal.sendInput(sgrPress(COLUMNS - 4, 0)); + await terminal.waitForRender(); + terminal.sendInput(sgrRelease(COLUMNS - 4, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + }); +}); + +describe("Scrollbar interaction boundaries", () => { + it.each([1, 3])( + "routes the last content columns with a %i-column gutter to content", + async (gutter) => { + const handleMouse = vi.fn((_event: TuiMouseEvent) => true); + const content = { + render: (width: number) => + Array.from({ length: CONTENT_LINES }, () => "x".repeat(width)), + invalidate() {}, + handleMouse, + }; + const { terminal, scrollView } = await createScrollbarScreen({ + gutter, + content, + }); + for (const x of [COLUMNS - gutter - 2, COLUMNS - gutter - 1]) { + terminal.sendInput(sgrPress(x, ROWS - 1)); + terminal.sendInput(sgrRelease(x, ROWS - 1)); + await terminal.waitForRender(); + expect(handleMouse).toHaveBeenCalledWith( + expect.objectContaining({ action: "press", x }), + ); + expect(scrollView.scrollTop).toBe(0); + } + }, + ); + + it.each([1, 3])( + "keeps text selection next to a %i-column gutter", + async (gutter) => { + const copySelection = vi.fn(async (_text: string) => true); + const content = { + render: (width: number) => + Array.from( + { length: CONTENT_LINES }, + () => "x".repeat(width - 2) + "YZ", + ), + invalidate() {}, + }; + const { terminal, scrollView } = await createScrollbarScreen({ + gutter, + content, + copySelection, + }); + terminal.sendInput(sgrPress(COLUMNS - gutter - 2, 4)); + terminal.sendInput(sgrMotion(COLUMNS - gutter - 1, 4)); + terminal.sendInput(sgrRelease(COLUMNS - gutter - 1, 4)); + await terminal.waitForRender(); + expect(copySelection).toHaveBeenCalledWith("YZ"); + expect(scrollView.scrollTop).toBe(0); + }, + ); + + it.each([1, 2, 3, 4, 8])( + "preserves content and bounded mouse targets at width %i", + async (columns) => { + const widths: number[] = []; + const handleMouse = vi.fn(() => true); + const content = { + render: (width: number) => { + widths.push(width); + return Array.from({ length: CONTENT_LINES }, () => "x".repeat(width)); + }, + invalidate() {}, + handleMouse, + }; + const { terminal, scrollView } = await createScrollbarScreen({ + columns, + content, + }); + expect(widths.every((width) => width === Math.max(1, columns - 3))).toBe( + true, + ); + expect( + terminal.getViewport().every((line) => visibleWidth(line) <= columns), + ).toBe(true); + terminal.sendInput(sgrPress(0, ROWS - 1)); + terminal.sendInput(sgrRelease(0, ROWS - 1)); + await terminal.waitForRender(); + expect(handleMouse).toHaveBeenCalled(); + expect(scrollView.scrollTop).toBe(0); + if (columns > 1) { + terminal.sendInput(sgrPress(columns - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(CONTENT_LINES - ROWS); + } + }, + ); + + it("does not capture a track press while an overlay is visible", async () => { + const { terminal, tui, scrollView } = await createScrollbarScreen(); + tui.showOverlay(new ScrollableLines(2), { width: 10 }); + await terminal.waitForRender(); + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(0); + }); + + it("cancels a drag when an overlay opens and does not resume it after dismissal", async () => { + const { terminal, tui, scrollView } = await createScrollbarScreen(); + terminal.sendInput(sgrPress(COLUMNS - 1, 0)); + const overlay = tui.showOverlay(new ScrollableLines(2), { width: 10 }); + await terminal.waitForRender(); + terminal.sendInput(sgrMotion(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(0); + overlay.hide(); + await terminal.waitForRender(); + terminal.sendInput(sgrMotion(COLUMNS - 1, ROWS - 1)); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(0); + }); + + it("routes wheel events normally and ends dragging on release", async () => { + const { terminal, scrollView } = await createScrollbarScreen(); + terminal.sendInput(`\x1b[<65;${COLUMNS};5M`); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBeGreaterThan(0); + terminal.sendInput(sgrPress(COLUMNS - 1, ROWS - 1)); + terminal.sendInput(sgrRelease(COLUMNS - 1, ROWS - 1)); + await terminal.waitForRender(); + terminal.sendInput(sgrMotion(COLUMNS - 1, 0)); + await terminal.waitForRender(); + expect(scrollView.scrollTop).toBe(CONTENT_LINES - ROWS); + }); + + it("enables a reserved gutter in the actual fullscreen chat layout", async () => { + const terminal = new VirtualTerminal(40, 15); + const empty = { render: () => [], invalidate() {} }; + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + transcript: new ScrollableLines(CONTENT_LINES), + welcome: empty, + interaction: { ...empty, isActive: () => false }, + activity: empty, + followUp: empty, + composer: { render: () => ["composer"], invalidate() {} }, + status: empty, + }); + const tui = new TuiAltScreen(terminal); + screens.push(tui); + tui.setLayoutRoot(layout.fullscreenLayoutRoot); + tui.start(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-59"); + terminal.sendInput(sgrPress(37, 0)); + terminal.sendInput(sgrRelease(37, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-59"); + expect(viewportText(terminal)).toContain("composer"); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 369186f7e..7abfd5456 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3243,6 +3243,7 @@ "packages/tui/test/unit/tui-provider-manager.test.ts", "packages/tui/test/unit/tui-provider-onboarding.test.ts", "packages/tui/test/unit/tui-runtime-adapter.test.ts", + "packages/tui/test/unit/tui-scrollbar-interaction.test.ts", "packages/tui/test/unit/tui-session-manager.test.ts", "packages/tui/test/unit/tui-shell-block.test.ts", "packages/tui/test/unit/tui-status-line-items.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 112304a71..443eaaf4c 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -77,6 +77,7 @@ "packages/tui/test/unit/tui-chat-controller.test.ts", "packages/tui/test/unit/tui-duration.test.ts", "packages/tui/test/unit/tui-engine-local-deltas.test.ts", + "packages/tui/test/unit/tui-scrollbar-interaction.test.ts", "packages/tui/test/unit/tui-image-preview.test.ts", "packages/tui/test/unit/tui-session-manager.test.ts", "packages/tui/test/unit/tui-shell-block.test.ts",