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; 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..7271c4c3a9 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -11,6 +11,7 @@ import { getOverrideConfigAtom, getSettingsKeyAtom, globalStore, + isDev, openLink, setTabIndicator, WOS, @@ -43,6 +44,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 { @@ -104,9 +106,23 @@ 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; + inSyncTransaction: boolean = false; + inRepaintTransaction: boolean = false; constructor( tabId: string, @@ -187,6 +203,44 @@ 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(); + 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; + }) + ); this.toDispose.push( this.terminal.onBell(() => { if (!this.loaded) { @@ -231,9 +285,8 @@ 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) => { + this.handleViewportScroll(viewportElem); }; viewportElem.addEventListener("scroll", scrollHandler); this.toDispose.push({ @@ -416,6 +469,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 +558,19 @@ export class TermWrap { return Date.now() - this.lastAtBottomTime <= 1000; } + handleViewportScroll(viewportElem: HTMLElement) { + const { scrollTop, scrollHeight, clientHeight } = viewportElem; + const atBottom = scrollTop + clientHeight >= scrollHeight - clientHeight * 0.5; + this.setAtBottom(atBottom); + const delta = this.viewportScrollTop - scrollTop; + if (isDev() && delta >= 500) { + console.log( + `[termwrap] large-scroll blockId=${this.blockId} delta=${Math.round(delta)}px scrollTop=${scrollTop} wasNearBottom=${atBottom}` + ); + } + this.viewportScrollTop = scrollTop; + } + handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; @@ -508,6 +581,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 + ); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); } dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); @@ -517,6 +598,7 @@ export class TermWrap { } if (atBottom) { setTimeout(() => { + console.log("[termwrap] resize scroll-to-bottom"); this.cachedAtBottomForResize = null; this.terminal.scrollToBottom(); this.setAtBottom(true); 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 ) diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 463be152bf..30ff1db737 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -126,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"`