Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 8 additions & 2 deletions packages/tui/src/tui/engine/components/scroll-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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));
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
60 changes: 42 additions & 18 deletions packages/tui/src/tui/engine/tui-alt-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/tui/shell/chat-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export class TuiChatLayout implements Component {
follow: 'end',
primary: true,
overscroll: 'contain',
scrollbar: 'always',
scrollbarGutter: 3,
});
this.fullscreenLayoutRoot = new VStack([
{
Expand Down
Loading
Loading