Skip to content

Commit ab0065a

Browse files
feat(tui): add an interactive fullscreen transcript scrollbar (#224)
Adapt drowzeys/minimax-code commit 0b24a77 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 <drowzeys@users.noreply.github.com>
1 parent ab67683 commit ab0065a

8 files changed

Lines changed: 379 additions & 24 deletions

File tree

‎packages/tui/src/tui/engine/LOCAL_CHANGES.json‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
"reason": "Keep strict TypeScript fixes and product Markdown hooks, plus Pi's post-baseline wrapped-table style restoration. 链接标签使用独立样式上下文,防止外层正文或标题颜色覆盖链接颜色。 稳定 Markdown 块缓存",
4848
"behaviorImpact": "MCode keeps its compact code presentation without reimplementing Pi Markdown or leaking wrapped link styles into table borders and adjacent cells. 链接保留嵌套强调与行内代码,并在结束后恢复外层样式。 保留两个尾部块并对引用回退全文。"
4949
},
50+
{
51+
"path": "components/scroll-view.ts",
52+
"currentSha256": "6f82fb1f426fdf81ca7058e6a1ea4a94c9ddd8f0e15d69c9020f9c66ca5411dd",
53+
"changeIds": ["L035"],
54+
"reason": "Allow hosts to reserve a wider always-visible scrollbar gutter while retaining at least one content column.",
55+
"behaviorImpact": "The fullscreen transcript reserves three columns; other scroll views retain the default one-column gutter."
56+
},
5057
{
5158
"path": "components/settings-list.ts",
5259
"currentSha256": "959f7db23190e0e526ca84c3032443d7d15248d891809f28c837cfa958692b5c",
@@ -148,11 +155,11 @@
148155
},
149156
{
150157
"path": "tui-alt-screen.ts",
151-
"currentSha256": "dd8acaa441bab2e674e6cba946dcb130b97e51098adf158bda1f73bd027d7a4b",
152-
"changeIds": ["L005", "L011", "L019", "L020", "L025", "L031"],
158+
"currentSha256": "b1e6b332e976c5224f77ce82ee850d35c601ad18c3719d9c824b7c9db1245a78",
159+
"changeIds": ["L005", "L011", "L019", "L020", "L025", "L031", "L035"],
153160
"upstreamCommit": "4e494929998d6bc4fccf75e0a233f727db4b70ee,1ac6128e66bd44668b58db773c2a76c29163ade5",
154-
"reason": "Keep strict TypeScript fixes and layout-first mouse dispatch, join path and kebab-case word segments, and expose configurable fullscreen selection copying helpers. 活动搜索索引与命中复用 独立面板声明键盘分页归属。",
155-
"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 分页交给焦点面板;默认仍由外层视口处理。"
161+
"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.",
162+
"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."
156163
},
157164
{
158165
"path": "tui-main-screen.ts",

‎packages/tui/src/tui/engine/LOCAL_CHANGES.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,12 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
106106
- Change: strip leading zone prefixes before comparing screen lines, so initial frames, differential redraws, and history caches stay clean. Fullscreen behavior is unchanged.
107107
- Evidence: `test/unit/tui-engine-local-deltas.test.ts` checks initial and differential writes.
108108
- Removal condition: the selected Pi baseline supplies equivalent normal-mode filtering.
109+
110+
## L035: Fullscreen scrollbar track interaction
111+
112+
- 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).
113+
- 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.
114+
- 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.
115+
- 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.
116+
- 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.
117+
- Removal condition: the selected Pi baseline provides equivalent reserved-gutter track and drag interaction and MCode migrates to it.

‎packages/tui/src/tui/engine/components/scroll-view.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface ScrollViewOptions {
99
primary?: boolean;
1010
overscroll?: "chain" | "contain";
1111
scrollbar?: ScrollViewScrollbar;
12+
/** Columns reserved for an always-visible scrollbar, including its hit gutter. */
13+
scrollbarGutter?: number;
1214
scrollbarStyle?: (text: string) => string;
1315
scrollbarHideDelayMs?: number;
1416
}
@@ -24,6 +26,7 @@ export class ScrollView extends Container {
2426
readonly primary: boolean;
2527
readonly overscroll: "chain" | "contain";
2628
readonly scrollbarStyle: (text: string) => string;
29+
private readonly scrollbarGutter: number;
2730
private currentScrollbar: ScrollViewScrollbar;
2831
private readonly scrollbarHideDelayMs: number;
2932
private currentScrollTop = 0;
@@ -48,6 +51,9 @@ export class ScrollView extends Container {
4851
this.primary = options.primary ?? false;
4952
this.overscroll = options.overscroll ?? "chain";
5053
this.currentScrollbar = options.scrollbar ?? "hidden";
54+
this.scrollbarGutter = Number.isFinite(options.scrollbarGutter)
55+
? Math.max(1, Math.floor(options.scrollbarGutter ?? 1))
56+
: 1;
5157
this.scrollbarStyle = options.scrollbarStyle ?? ((text) => `\x1b[100m${text}\x1b[49m`);
5258
this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000));
5359
}
@@ -84,7 +90,7 @@ export class ScrollView extends Container {
8490
}
8591

8692
getContentWidth(width: number): number {
87-
return this.scrollbar === "always" && width > 1 ? width - 1 : width;
93+
return this.scrollbar === "always" && width > 1 ? Math.max(1, width - this.scrollbarGutter) : width;
8894
}
8995

9096
private markScrollbarActivity(): void {
@@ -207,7 +213,7 @@ export class ScrollView extends Container {
207213
override render(width: number): string[] {
208214
const contentWidth = this.getContentWidth(width);
209215
const lines = this.child.render(contentWidth);
210-
return contentWidth === width ? lines : lines.map((line) => `${line} `);
216+
return contentWidth === width ? lines : lines.map((line) => `${line}${" ".repeat(width - contentWidth)}`);
211217
}
212218

213219
[LAYOUT_NODE](): ScrollLayoutNode {

‎packages/tui/src/tui/engine/tui-alt-screen.ts‎

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -776,11 +776,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
776776
for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) {
777777
const box = getScrollViewBox(this.currentLayout, scrollView);
778778
const geometry = box ? getScrollbarGeometry(box) : undefined;
779+
// Only reserved gutter columns may capture presses beside the painted bar.
780+
const hitColumns = box
781+
? scrollView.scrollbar === "always"
782+
? box.rect.width - scrollView.getContentWidth(box.rect.width)
783+
: 1
784+
: 0;
779785
if (
780786
geometry &&
781-
x === geometry.column &&
782-
y >= geometry.thumbTop &&
783-
y < geometry.thumbTop + geometry.thumbHeight
787+
x >= geometry.column - (hitColumns - 1) &&
788+
x <= geometry.column &&
789+
y >= geometry.trackTop &&
790+
y < geometry.trackTop + geometry.trackHeight
784791
) {
785792
return { scrollView, geometry };
786793
}
@@ -804,25 +811,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
804811
}
805812

806813
private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean {
814+
if (this.hasOverlay()) {
815+
this.stopScrollbarDrag();
816+
return false;
817+
}
807818
if (this.scrollbarDrag) {
808819
if (event.release) {
809820
this.stopScrollbarDrag();
810821
return true;
811822
}
812-
const box = this.currentLayout
813-
? getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView)
814-
: undefined;
815-
const geometry = box ? getScrollbarGeometry(box) : undefined;
816-
if (geometry) {
817-
const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
818-
const thumbOffset = Math.max(
819-
0,
820-
Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset),
821-
);
822-
const scrollTop =
823-
maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
824-
this.scrollbarDrag.scrollView.scrollTo(scrollTop);
825-
}
823+
this.dragScrollbarTo(event.y);
826824
return true;
827825
}
828826

@@ -839,13 +837,39 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
839837
this.pressedUrl = undefined;
840838
this.selectionDragged = false;
841839
this.setScrollbarHover(target.scrollView);
840+
const onThumb =
841+
event.y >= target.geometry.thumbTop &&
842+
event.y < target.geometry.thumbTop + target.geometry.thumbHeight;
842843
this.scrollbarDrag = {
843844
scrollView: target.scrollView,
844-
grabOffset: event.y - target.geometry.thumbTop,
845+
grabOffset: onThumb
846+
? event.y - target.geometry.thumbTop
847+
: Math.max(0, Math.floor(target.geometry.thumbHeight / 2)),
845848
};
849+
// A press on the empty track jumps the thumb so its center follows the
850+
// pointer, then keeps dragging from there; a press on the thumb itself
851+
// grabs it in place without moving.
852+
if (!onThumb) this.dragScrollbarTo(event.y);
846853
return true;
847854
}
848855

856+
private dragScrollbarTo(y: number): void {
857+
if (!this.scrollbarDrag || !this.currentLayout) return;
858+
const box = getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView);
859+
const geometry = box ? getScrollbarGeometry(box) : undefined;
860+
if (!geometry) return;
861+
const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
862+
const thumbOffset = Math.max(
863+
0,
864+
Math.min(maxThumbOffset, y - geometry.trackTop - this.scrollbarDrag.grabOffset),
865+
);
866+
const scrollTop =
867+
maxThumbOffset === 0
868+
? 0
869+
: Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
870+
this.scrollbarDrag.scrollView.scrollTo(scrollTop);
871+
}
872+
849873
private stopScrollbarDrag(): void {
850874
this.scrollbarDrag = undefined;
851875
}

‎packages/tui/src/tui/shell/chat-layout.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export class TuiChatLayout implements Component {
124124
follow: 'end',
125125
primary: true,
126126
overscroll: 'contain',
127+
scrollbar: 'always',
128+
scrollbarGutter: 3,
127129
});
128130
this.fullscreenLayoutRoot = new VStack([
129131
{

0 commit comments

Comments
 (0)