From 4a0376e1ce59cfebf9cc403eb4addd8ca2dc75cd Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 09:28:36 -0800 Subject: [PATCH 1/8] scroll bug instrumentation --- frontend/app/store/keymodel.ts | 1 - frontend/app/view/term/termwrap.ts | 57 ++++++++++++++++++-- frontend/types/gotypes.d.ts | 3 ++ pkg/telemetry/telemetrydata/telemetrydata.go | 8 ++- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 94ed8bbdcc..aa25448a0a 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -419,7 +419,6 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { } const nativeEvent = (waveEvent as any).nativeEvent; if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) { - console.log("lastHandledEvent return false"); return false; } lastHandledEvent = nativeEvent; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 5c700cde28..730bd28398 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -11,7 +11,9 @@ import { getOverrideConfigAtom, getSettingsKeyAtom, globalStore, + isDev, openLink, + recordTEvent, setTabIndicator, WOS, } from "@/store/global"; @@ -107,6 +109,9 @@ export class TermWrap { lastAtBottomTime: number = Date.now(); lastScrollAtBottom: boolean = true; cachedAtBottomForResize: boolean | null = null; + viewportScrollTop: number = 0; + recentWrites: { idx: number; data: string; ts: number }[] = []; + recentWritesCounter: number = 0; constructor( tabId: string, @@ -231,9 +236,14 @@ export class TermWrap { }); const viewportElem = this.connectElem.querySelector(".xterm-viewport") as HTMLElement; if (viewportElem) { - const scrollHandler = () => { - const atBottom = viewportElem.scrollTop + viewportElem.clientHeight >= viewportElem.scrollHeight - 20; - this.setAtBottom(atBottom); + const scrollHandler = (e: any) => { + const scrolledUp = viewportElem.scrollTop < this.viewportScrollTop; + const stack = new Error().stack ?? ""; + const frameCount = stack.split("\n").length - 1; + if (frameCount > 3) { + console.trace("[termwrap]", "scroll-up", viewportElem.scrollTop, e); + } + this.handleViewportScroll(viewportElem); }; viewportElem.addEventListener("scroll", scrollHandler); this.toDispose.push({ @@ -416,6 +426,13 @@ export class TermWrap { } doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise { + if (isDev() && this.loaded) { + const dataStr = data instanceof Uint8Array ? new TextDecoder().decode(data) : data; + this.recentWrites.push({ idx: this.recentWritesCounter++, ts: Date.now(), data: dataStr }); + if (this.recentWrites.length > 50) { + this.recentWrites.shift(); + } + } let resolve: () => void = null; let prtn = new Promise((presolve, _) => { resolve = presolve; @@ -498,6 +515,40 @@ export class TermWrap { return Date.now() - this.lastAtBottomTime <= 1000; } + handleViewportScroll(viewportElem: HTMLElement) { + const { scrollTop, scrollHeight, clientHeight } = viewportElem; + const atBottom = scrollTop + clientHeight >= scrollHeight - 20; + this.setAtBottom(atBottom); + const hasScrollback = scrollHeight > clientHeight + 20; + const delta = this.viewportScrollTop - scrollTop; + const wasNearBottom = this.viewportScrollTop >= scrollHeight - clientHeight - 100; + const shouldSendTelemetry = scrollTop === 0 && hasScrollback && delta >= 1000; + if ((isDev() && delta >= 500) || shouldSendTelemetry) { + const lastCmd = globalStore.get(this.lastCommandAtom) ?? ""; + let termCmd = ""; + if (/^claude\b/.test(lastCmd)) { + termCmd = "claude"; + } else if (/^opencode\b/.test(lastCmd)) { + termCmd = "opencode"; + } + if (isDev() && delta >= 500) { + console.log( + `[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${wasNearBottom} termCmd=${termCmd || "(none)"} sendTelemetry=${shouldSendTelemetry}` + ); + console.log("[termwrap] recentWrites (last 50):", this.recentWrites); + console.trace("[termwrap] large-scroll callstack"); + } + if (shouldSendTelemetry) { + recordTEvent("debug:termscrolltop", { + "debug:scrollpx": Math.round(delta), + "debug:scrollfrombot": wasNearBottom, + "debug:termcmd": termCmd || undefined, + }); + } + } + this.viewportScrollTop = scrollTop; + } + handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 96f0ca2660..ab169ca93e 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1457,6 +1457,9 @@ declare global { "action:initiator"?: "keyboard" | "mouse"; "action:type"?: string; "debug:panictype"?: string; + "debug:scrollpx"?: number; + "debug:scrollfrombot"?: boolean; + "debug:termcmd"?: string; "block:view"?: string; "block:controller"?: string; "ai:backendtype"?: string; diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 463be152bf..dbc5a175e3 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -32,7 +32,8 @@ var ValidEventNames = map[string]bool{ "wsh:run": true, - "debug:panic": true, + "debug:panic": true, + "debug:termscrolltop": true, "conn:connect": true, "conn:connecterror": true, @@ -115,7 +116,10 @@ type TEventProps struct { ActionInitiator string `json:"action:initiator,omitempty" tstype:"\"keyboard\" | \"mouse\""` ActionType string `json:"action:type,omitempty"` - PanicType string `json:"debug:panictype,omitempty"` + PanicType string `json:"debug:panictype,omitempty"` + DebugScrollPx int `json:"debug:scrollpx,omitempty"` + DebugScrollFromBot bool `json:"debug:scrollfrombot,omitempty"` + DebugTermCmd string `json:"debug:termcmd,omitempty"` BlockView string `json:"block:view,omitempty"` BlockController string `json:"block:controller,omitempty"` From 5c984e5b0b099658545182e052dba6b0be7213a6 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 12:26:13 -0800 Subject: [PATCH 2/8] bump circular file size for terminal to 2m (from 256k) --- pkg/blockcontroller/blockcontroller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 524a66c10b..b5af735cc8 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -41,7 +41,7 @@ const ( ) const ( - DefaultTermMaxFileSize = 256 * 1024 + DefaultTermMaxFileSize = 2 * 1024 * 1024 DefaultHtmlMaxFileSize = 256 * 1024 MaxInitScriptSize = 50 * 1024 ) From 53875fbe60c7b4ba53a14a028f5fe31a4cc1959e Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 12:27:06 -0800 Subject: [PATCH 3/8] in dev... show the window even if startup fails for debugging --- emain/emain-window.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 576488579a..07c0c08a6c 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -16,12 +16,14 @@ import { setWasInFg, } from "./emain-activity"; import { log } from "./emain-log"; -import { getElectronAppBasePath, unamePlatform } from "./emain-platform"; +import { getElectronAppBasePath, isDev, unamePlatform } from "./emain-platform"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; import { delay, ensureBoundsAreVisible, waveKeyToElectronKey } from "./emain-util"; import { ElectronWshClient } from "./emain-wsh"; import { updater } from "./updater"; +const DevInitTimeoutMs = 5000; + export type WindowOpts = { unamePlatform: NodeJS.Platform; isPrimaryStartupWindow?: boolean; @@ -389,7 +391,7 @@ export class WaveBrowserWindow extends BaseWindow { private async initializeTab(tabView: WaveTabView, primaryStartupTab: boolean) { const clientId = await getClientId(); - await tabView.initPromise; + await this.awaitWithDevTimeout(tabView.initPromise, "initPromise", tabView.waveTabId); this.contentView.addChildView(tabView); const initOpts: WaveInitOpts = { tabId: tabView.waveTabId, @@ -410,10 +412,36 @@ export class WaveBrowserWindow extends BaseWindow { primaryStartupTab ? "(primary startup)" : "" ); tabView.webContents.send("wave-init", initOpts); - await tabView.waveReadyPromise; + await this.awaitWithDevTimeout(tabView.waveReadyPromise, "waveReadyPromise", tabView.waveTabId); console.log("wave-ready init time", Date.now() - startTime + "ms"); } + private async awaitWithDevTimeout(promise: Promise, name: string, tabId: string): Promise { + if (!isDev) { + return promise; + } + let timeoutHandle: ReturnType = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + console.log( + `[dev] ${name} timed out after ${DevInitTimeoutMs}ms for tab ${tabId}, showing window for devtools` + ); + if (!this.isDestroyed() && !this.isVisible()) { + this.show(); + } + if (this.activeTabView?.webContents && !this.activeTabView.webContents.isDevToolsOpened()) { + this.activeTabView.webContents.openDevTools(); + } + reject(new Error(`[dev] ${name} timed out after ${DevInitTimeoutMs}ms`)); + }, DevInitTimeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutHandle); + } + } + private async setTabViewIntoWindow(tabView: WaveTabView, tabInitialized: boolean, primaryStartupTab = false) { if (this.activeTabView == tabView) { return; From 93fe02a03bebde0647ccb663a11656d3192fbcdf Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 12:27:53 -0800 Subject: [PATCH 4/8] trap CSI 3 J --- frontend/app/view/term/termwrap.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 730bd28398..e701872d2b 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -112,6 +112,7 @@ export class TermWrap { viewportScrollTop: number = 0; recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; + lastClearScrollbackTs: number = 0; constructor( tabId: string, @@ -192,6 +193,14 @@ export class TermWrap { this.terminal.parser.registerOscHandler(16162, (data: string) => { return handleOsc16162Command(data, this.blockId, this.loaded, this); }); + this.toDispose.push( + this.terminal.parser.registerCsiHandler({ final: "J" }, (params) => { + if (params[0] === 3) { + this.lastClearScrollbackTs = Date.now(); + } + return false; + }) + ); this.toDispose.push( this.terminal.onBell(() => { if (!this.loaded) { @@ -559,6 +568,7 @@ export class TermWrap { this.fitAddon.fit(); if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) { const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; + console.log("[termwrap] resize", `${oldRows}x${oldCols}`, "->", `${this.terminal.rows}x${this.terminal.cols}`, "atBottom:", atBottom); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); } dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); @@ -568,6 +578,7 @@ export class TermWrap { } if (atBottom) { setTimeout(() => { + console.log("[termwrap] resize scroll-to-bottom"); this.cachedAtBottomForResize = null; this.terminal.scrollToBottom(); this.setAtBottom(true); From 7899e9995e4066d765c29bfd7f0ee0158f3e7566 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 14:36:39 -0800 Subject: [PATCH 5/8] create a CC "repaint" transaction --- frontend/app/view/term/termwrap.ts | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index e701872d2b..4dd80a55da 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -45,6 +45,7 @@ const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; export const SupportsImageInput = true; const IMEDedupWindowMs = 20; +const MaxRepaintTransactionMs = 2000; // detect webgl support function detectWebGLSupport(): boolean { @@ -113,6 +114,10 @@ export class TermWrap { recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; lastClearScrollbackTs: number = 0; + lastMode2026SetTs: number = 0; + lastMode2026ResetTs: number = 0; + inSyncTransaction: boolean = false; + inRepaintTransaction: boolean = false; constructor( tabId: string, @@ -197,6 +202,36 @@ export class TermWrap { this.terminal.parser.registerCsiHandler({ final: "J" }, (params) => { if (params[0] === 3) { this.lastClearScrollbackTs = Date.now(); + if (this.inSyncTransaction) { + console.log("[termwrap] repaint transaction starting"); + this.inRepaintTransaction = true; + } + } + return false; + }) + ); + this.toDispose.push( + this.terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => { + if (params[0] === 2026) { + this.lastMode2026SetTs = Date.now(); + this.inSyncTransaction = true; + } + return false; + }) + ); + this.toDispose.push( + this.terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => { + if (params[0] === 2026) { + this.lastMode2026ResetTs = Date.now(); + this.inSyncTransaction = false; + const wasRepaint = this.inRepaintTransaction; + this.inRepaintTransaction = false; + if (wasRepaint && Date.now() - this.lastClearScrollbackTs <= MaxRepaintTransactionMs) { + setTimeout(() => { + console.log("[termwrap] repaint transaction complete, scrolling to bottom"); + this.terminal.scrollToBottom(); + }, 20); + } } return false; }) @@ -568,7 +603,14 @@ export class TermWrap { this.fitAddon.fit(); if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) { const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; - console.log("[termwrap] resize", `${oldRows}x${oldCols}`, "->", `${this.terminal.rows}x${this.terminal.cols}`, "atBottom:", atBottom); + console.log( + "[termwrap] resize", + `${oldRows}x${oldCols}`, + "->", + `${this.terminal.rows}x${this.terminal.cols}`, + "atBottom:", + atBottom + ); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); } dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); From 5fd2b43ec1ee8d61a6191ecdce10430b59cc7f52 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 14:50:29 -0800 Subject: [PATCH 6/8] remove telemetry, loosen up the "atBottom" check (50% of viewport) --- frontend/app/view/term/termwrap.ts | 32 +++----------------- frontend/types/gotypes.d.ts | 3 -- pkg/telemetry/telemetrydata/telemetrydata.go | 18 +++++------ 3 files changed, 12 insertions(+), 41 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 4dd80a55da..78acbfdeea 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -13,7 +13,6 @@ import { globalStore, isDev, openLink, - recordTEvent, setTabIndicator, WOS, } from "@/store/global"; @@ -561,34 +560,13 @@ export class TermWrap { handleViewportScroll(viewportElem: HTMLElement) { const { scrollTop, scrollHeight, clientHeight } = viewportElem; - const atBottom = scrollTop + clientHeight >= scrollHeight - 20; + const atBottom = scrollTop + clientHeight >= scrollHeight - clientHeight * 0.5; this.setAtBottom(atBottom); - const hasScrollback = scrollHeight > clientHeight + 20; const delta = this.viewportScrollTop - scrollTop; - const wasNearBottom = this.viewportScrollTop >= scrollHeight - clientHeight - 100; - const shouldSendTelemetry = scrollTop === 0 && hasScrollback && delta >= 1000; - if ((isDev() && delta >= 500) || shouldSendTelemetry) { - const lastCmd = globalStore.get(this.lastCommandAtom) ?? ""; - let termCmd = ""; - if (/^claude\b/.test(lastCmd)) { - termCmd = "claude"; - } else if (/^opencode\b/.test(lastCmd)) { - termCmd = "opencode"; - } - if (isDev() && delta >= 500) { - console.log( - `[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${wasNearBottom} termCmd=${termCmd || "(none)"} sendTelemetry=${shouldSendTelemetry}` - ); - console.log("[termwrap] recentWrites (last 50):", this.recentWrites); - console.trace("[termwrap] large-scroll callstack"); - } - if (shouldSendTelemetry) { - recordTEvent("debug:termscrolltop", { - "debug:scrollpx": Math.round(delta), - "debug:scrollfrombot": wasNearBottom, - "debug:termcmd": termCmd || undefined, - }); - } + if (isDev() && delta >= 500) { + console.log( + `[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${atBottom}` + ); } this.viewportScrollTop = scrollTop; } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index ccc6eca051..313f8dbdec 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1469,9 +1469,6 @@ declare global { "action:initiator"?: "keyboard" | "mouse"; "action:type"?: string; "debug:panictype"?: string; - "debug:scrollpx"?: number; - "debug:scrollfrombot"?: boolean; - "debug:termcmd"?: string; "block:view"?: string; "block:controller"?: string; "ai:backendtype"?: string; diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index dbc5a175e3..30ff1db737 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -32,8 +32,7 @@ var ValidEventNames = map[string]bool{ "wsh:run": true, - "debug:panic": true, - "debug:termscrolltop": true, + "debug:panic": true, "conn:connect": true, "conn:connecterror": true, @@ -116,10 +115,7 @@ type TEventProps struct { ActionInitiator string `json:"action:initiator,omitempty" tstype:"\"keyboard\" | \"mouse\""` ActionType string `json:"action:type,omitempty"` - PanicType string `json:"debug:panictype,omitempty"` - DebugScrollPx int `json:"debug:scrollpx,omitempty"` - DebugScrollFromBot bool `json:"debug:scrollfrombot,omitempty"` - DebugTermCmd string `json:"debug:termcmd,omitempty"` + PanicType string `json:"debug:panictype,omitempty"` BlockView string `json:"block:view,omitempty"` BlockController string `json:"block:controller,omitempty"` @@ -130,11 +126,11 @@ type TEventProps struct { WshCmd string `json:"wsh:cmd,omitempty"` WshHadError bool `json:"wsh:haderror,omitempty"` - ConnType string `json:"conn:conntype,omitempty"` - ConnWshErrorCode string `json:"conn:wsherrorcode,omitempty"` - ConnErrorCode string `json:"conn:errorcode,omitempty"` - ConnSubErrorCode string `json:"conn:suberrorcode,omitempty"` - ConnContextError bool `json:"conn:contexterror,omitempty"` + ConnType string `json:"conn:conntype,omitempty"` + ConnWshErrorCode string `json:"conn:wsherrorcode,omitempty"` + ConnErrorCode string `json:"conn:errorcode,omitempty"` + ConnSubErrorCode string `json:"conn:suberrorcode,omitempty"` + ConnContextError bool `json:"conn:contexterror,omitempty"` OnboardingFeature string `json:"onboarding:feature,omitempty" tstype:"\"waveai\" | \"durable\" | \"magnify\" | \"wsh\""` OnboardingVersion string `json:"onboarding:version,omitempty"` From 87d70fdbeeada0414eb38c06f1767ffc4394bc46 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 14:57:55 -0800 Subject: [PATCH 7/8] remove extra console.log --- frontend/app/view/term/termwrap.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 78acbfdeea..205f4780fe 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -280,12 +280,6 @@ export class TermWrap { const viewportElem = this.connectElem.querySelector(".xterm-viewport") as HTMLElement; if (viewportElem) { const scrollHandler = (e: any) => { - const scrolledUp = viewportElem.scrollTop < this.viewportScrollTop; - const stack = new Error().stack ?? ""; - const frameCount = stack.split("\n").length - 1; - if (frameCount > 3) { - console.trace("[termwrap]", "scroll-up", viewportElem.scrollTop, e); - } this.handleViewportScroll(viewportElem); }; viewportElem.addEventListener("scroll", scrollHandler); From 18f48e8c04544428259034087f4ca0e16c2accbb Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 27 Feb 2026 14:59:35 -0800 Subject: [PATCH 8/8] comment the new fields --- frontend/app/view/term/termwrap.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 205f4780fe..7271c4c3a9 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -106,12 +106,18 @@ export class TermWrap { // xterm.js paste() method triggers onData event, which can cause duplicate sends lastPasteData: string = ""; lastPasteTime: number = 0; + + // for scrollToBottom support during a resize lastAtBottomTime: number = Date.now(); lastScrollAtBottom: boolean = true; cachedAtBottomForResize: boolean | null = null; viewportScrollTop: number = 0; + + // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; + + // for repaint transaction scrolling behavior lastClearScrollbackTs: number = 0; lastMode2026SetTs: number = 0; lastMode2026ResetTs: number = 0;