diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 753a53adec..e4c068bf78 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -234,7 +234,7 @@ export class WaveTabView extends WebContentsView { } } -let MaxCacheSize = 10; +let MaxCacheSize = 30; const wcvCache = new Map(); export function setMaxTabCacheSize(size: number) { @@ -268,11 +268,15 @@ function tryEvictEntry(waveTabId: string): boolean { console.log("[error] WaveWindow not found for WaveTabView", tabView.waveTabId); tabView.destroy(); return true; - } else { - // will trigger a destroy on the tabview - ww.removeTabView(tabView.waveTabId, false); + } + if (!ww.allLoadedTabViews.has(tabView.waveTabId)) { + // tab is no longer tracked by its window (e.g. after workspace switch cleared allLoadedTabViews) + tabView.destroy(); return true; } + // will trigger a destroy on the tabview + ww.removeTabView(tabView.waveTabId, false); + return true; } function checkAndEvictCache(): void { @@ -303,7 +307,7 @@ export function clearTabCache() { // returns [tabview, initialized] export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: string): Promise<[WaveTabView, boolean]> { let tabView = getWaveTabView(tabId); - if (tabView) { + if (tabView && !tabView.isDestroyed) { return [tabView, true]; } const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); diff --git a/emain/emain-window.ts b/emain/emain-window.ts index e3bfa87751..d4d64e2d4f 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -605,9 +605,15 @@ export class WaveBrowserWindow extends BaseWindow { if (!newWs) { return; } - console.log("processActionQueue switchworkspace newWs", newWs); - this.removeAllChildViews(); - console.log("destroyed all tabs", this.waveWindowId); + console.log("processActionQueue switchworkspace newWs", newWs, "keep-alive tabs:", this.allLoadedTabViews.size); + // Keep BrowserViews alive (don't destroy) — position off-screen instead. + // They stay in contentView and wcvCache, and will be positioned on-screen + // when switching back to this workspace (setTabViewIntoWindow → finalizePositioning). + const curBounds = this.getContentBounds(); + for (const tabView of this.allLoadedTabViews.values()) { + tabView.isActiveTab = false; + tabView.positionTabOffScreen(curBounds); + } this.workspaceId = entry.workspaceId; this.allLoadedTabViews = new Map(); tabId = newWs.activetabid; diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index e9c70a35df..36c1ba6480 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -20,6 +20,7 @@ import { ContextMenuModel } from "@/store/contextmenu"; import { atoms, createBlock, getSettingsPrefixAtom, refocusNode } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; import { getElemAsStr } from "@/util/focusutil"; +import { termLog } from "@/util/termlog"; import * as keyutil from "@/util/keyutil"; import { PLATFORM } from "@/util/platformutil"; import * as util from "@/util/util"; @@ -245,16 +246,16 @@ const MacOSFirstClickHandler = () => { const blockId = getBlockIdFromTarget(e.target); if (blockId != null) { setTimeout(() => { - console.log("macos first-click, focusing block", blockId); + termLog("[block]", "macos first-click, focusing block", blockId); refocusNode(blockId); }, 10); } else if (isAIPanelTarget(e.target)) { setTimeout(() => { - console.log("macos first-click, focusing AI panel"); + termLog("[focus]", "macos first-click, focusing AI panel"); FocusManager.getInstance().setWaveAIFocused(true); }, 10); } - console.log("macos first-click detected, canceled", timeDiff + "ms"); + termLog("[block]", "macos first-click detected, canceled", timeDiff + "ms"); return; } cancelNextClick = false; diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index f199dc5e9c..4def4276b7 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -17,6 +17,7 @@ import { counterInc } from "@/store/counters"; import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from "@/store/global"; import { makeORef } from "@/store/wos"; import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; +import { termLog } from "@/util/termlog"; import { isBlank, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { useAtomValue } from "jotai"; @@ -165,9 +166,9 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const handleChildFocus = useCallback( (event: React.FocusEvent) => { - console.log("setFocusedChild", nodeModel.blockId, getElemAsStr(event.target)); + termLog("[block]", "handleChildFocus", nodeModel.blockId, getElemAsStr(event.target)); if (!isFocused) { - console.log("focusedChild focus", nodeModel.blockId); + termLog("[block]", "handleChildFocus: set isFocused", nodeModel.blockId); nodeModel.focusNode(); } }, diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 290e6cc8a3..2424c7533a 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -277,6 +277,9 @@ const BlockFrame_Header = ({ )} +
+ {nodeModel.blockId.substring(0, 8)} +
); diff --git a/frontend/app/store/focusManager.ts b/frontend/app/store/focusManager.ts index cc00c0de4d..30af3d825a 100644 --- a/frontend/app/store/focusManager.ts +++ b/frontend/app/store/focusManager.ts @@ -7,6 +7,7 @@ import { getBlockComponentModel, getBlockMetaKeyAtom } from "@/app/store/global" import { globalStore } from "@/app/store/jotaiStore"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { termLog } from "@/util/termlog"; import { fireAndForget } from "@/util/util"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { focusedBlockId } from "@/util/focusutil"; @@ -59,6 +60,7 @@ export class FocusManager { if (!force && isAlreadyFocused) { return; } + termLog("[focus]", "setWaveAIFocused"); globalStore.set(this.focusType, "waveai"); this.refocusNode(); } @@ -68,6 +70,7 @@ export class FocusManager { if (!force && ftype == "node") { return; } + termLog("[focus]", "setBlockFocus"); globalStore.set(this.focusType, "node"); this.refocusNode(); } @@ -81,10 +84,12 @@ export class FocusManager { } requestNodeFocus(): void { + termLog("[focus]", "requestNodeFocus"); globalStore.set(this.focusType, "node"); } requestWaveAIFocus(): void { + termLog("[focus]", "requestWaveAIFocus"); globalStore.set(this.focusType, "waveai"); } @@ -95,6 +100,7 @@ export class FocusManager { refocusNode() { const ftype = globalStore.get(this.focusType); if (ftype == "waveai") { + termLog("[focus]", "refocusNode: waveai"); WaveAIModel.getInstance().focusInput(); return; } @@ -103,8 +109,9 @@ export class FocusManager { if (lnode == null || lnode.data?.blockId == null) { return; } - layoutModel.focusNode(lnode.id); const blockId = lnode.data.blockId; + termLog("[focus]", "refocusNode", blockId); + layoutModel.focusNode(lnode.id); const bcm = getBlockComponentModel(blockId); const ok = bcm?.viewModel?.giveFocus?.(); if (!ok) { diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 8096a6162f..9922e51431 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -17,6 +17,7 @@ import { import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { setPlatform } from "@/util/platformutil"; +import { termLog } from "@/util/termlog"; import { base64ToString, deepCompareReturnPrev, @@ -511,6 +512,7 @@ async function fetchWaveFile( } function setNodeFocus(nodeId: string) { + termLog("[block]", "setNodeFocus", nodeId); const layoutModel = getLayoutModelForStaticTab(); layoutModel.focusNode(nodeId); } @@ -613,6 +615,7 @@ function refocusNode(blockId: string) { return; } } + termLog("[block]", "refocusNode", blockId); const layoutModel = getLayoutModelForStaticTab(); const layoutNodeId = layoutModel.getNodeByBlockId(blockId); if (layoutNodeId?.id == null) { @@ -693,10 +696,12 @@ function getConnStatusAtom(conn: string): PrimitiveAtom { } function createTab() { + termLog("[tab]", "createTab"); getApi().createTab(); } function setActiveTab(tabId: string) { + termLog("[tab]", "setActiveTab", tabId); getApi().setActiveTab(tabId); } diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 4d6fed9189..10f026958f 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -25,6 +25,7 @@ import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; import { isWindows } from "@/util/platformutil"; +import { termLog } from "@/util/termlog"; import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { fireAndForget } from "@/util/util"; import * as jotai from "jotai"; @@ -141,7 +142,7 @@ function simpleCloseStaticTab() { } }) .catch((e) => { - console.log("error closing tab", e); + termLog("[tab]", "error closing tab", e); }); } @@ -230,6 +231,7 @@ function genericClose() { } function switchBlockByBlockNum(index: number) { + termLog("[block]", "switchBlockByBlockNum", index); const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { return; @@ -241,6 +243,7 @@ function switchBlockByBlockNum(index: number) { } function switchBlockInDirection(direction: NavigateDirection) { + termLog("[block]", "switchBlockInDirection", direction); const layoutModel = getLayoutModelForStaticTab(); const focusType = FocusManager.getInstance().getFocusType(); @@ -282,7 +285,7 @@ function getAllTabs(ws: Workspace): string[] { } function switchTabAbs(index: number) { - console.log("switchTabAbs", index); + termLog("[tab]", "switchTabAbs", index); const ws = globalStore.get(atoms.workspace); const newTabIdx = index - 1; const tabids = getAllTabs(ws); @@ -294,7 +297,7 @@ function switchTabAbs(index: number) { } function switchTab(offset: number) { - console.log("switchTab", offset); + termLog("[tab]", "switchTab", offset); const ws = globalStore.get(atoms.workspace); const curTabId = globalStore.get(atoms.staticTabId); let tabIdx = -1; @@ -331,7 +334,7 @@ function globalRefocus() { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { - // focus a node + termLog("[block]", "globalRefocus: no focused node, focus first"); layoutModel.focusFirstNode(); return; } @@ -339,6 +342,7 @@ function globalRefocus() { if (blockId == null) { return; } + termLog("[block]", "globalRefocus", blockId); refocusNode(blockId); } diff --git a/frontend/app/store/tabrpcclient.ts b/frontend/app/store/tabrpcclient.ts index d6bfef56e1..2c7a4e290c 100644 --- a/frontend/app/store/tabrpcclient.ts +++ b/frontend/app/store/tabrpcclient.ts @@ -6,6 +6,7 @@ import { getApi, getBlockComponentModel, getConnStatusAtom, globalStore, WOS } f import type { TermViewModel } from "@/app/view/term/term-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { getLayoutModelForStaticTab } from "@/layout/index"; +import { termLog } from "@/util/termlog"; import { base64ToArrayBuffer } from "@/util/util"; import { RpcResponseHelper, WshClient } from "./wshclient"; import { RpcApi } from "./wshclientapi"; @@ -93,6 +94,7 @@ export class TabClient extends WshClient { } async handle_setblockfocus(rh: RpcResponseHelper, blockId: string): Promise { + termLog("[block]", "handle_setblockfocus (RPC)", blockId); const layoutModel = getLayoutModelForStaticTab(); if (!layoutModel) { throw new Error("Layout model not found"); diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index b660f17f2e..18b5da9e8c 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -10,6 +10,7 @@ import { ExpandableMenuItemRightElement, } from "@/element/expandablemenu"; import { Popover, PopoverButton, PopoverContent } from "@/element/popover"; +import { termLog } from "@/util/termlog"; import { fireAndForget, makeIconClass, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { atom, PrimitiveAtom, useAtomValue, useSetAtom } from "jotai"; @@ -224,6 +225,7 @@ const WorkspaceSwitcherItem = ({ entryAtom }: { entryAtom: PrimitiveAtom { + termLog("[workspace]", "switchWorkspace", workspace.oid, workspace.name); env.electron.switchWorkspace(workspace.oid); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); }} diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 4999628f54..44b47b1db9 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -10,6 +10,7 @@ import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; +import { termLog } from "@/util/termlog"; import { TermClaudeIcon, TerminalView } from "@/app/view/term/term"; import { TermWshClient } from "@/app/view/term/term-wsh"; import { VDomModel } from "@/app/view/vdom/vdom-model"; @@ -508,6 +509,9 @@ export class TermViewModel implements ViewModel { sendDataToController(data: string) { const b64data = stringToBase64(data); + if (data.startsWith("\x1b[<") || data.startsWith("\x1b[?") || data === "\x1b[I" || data === "\x1b[O") { + termLog("[sendData]", this.blockId, data.slice(0, 60).replace(/\x1b/g, "\\e")); + } RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 8d3bafac09..e8c75f6037 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -14,6 +14,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import type { TermViewModel } from "@/app/view/term/term-model"; import { atoms, getOverrideConfigAtom, getSettingsPrefixAtom, WOS } from "@/store/global"; +import { termLog } from "@/util/termlog"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import { ISearchOptions } from "@xterm/addon-search"; @@ -51,7 +52,7 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => React.useEffect(() => { if (!model.termRef.current?.hasResized) { - console.log("[TermResyncHandler] hasResized=false, skipping resync", blockId); + termLog("[resync]", blockId, "TermResyncHandler skipped (hasResized=false)"); setLastConnStatus(connStatus); return; } @@ -59,11 +60,11 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => const wasConnected = lastConnStatus?.status == "connected"; const curConnName = connStatus?.connection; const lastConnName = lastConnStatus?.connection; - console.log("[TermResyncHandler] check", blockId, "cur:", connStatus?.status, "last:", lastConnStatus?.status, "conn:", curConnName); + termLog("[resync]", blockId, "check cur:", connStatus?.status, "last:", lastConnStatus?.status, "conn:", curConnName); if (isConnected == wasConnected && curConnName == lastConnName) { return; } - console.log("[TermResyncHandler] triggering resync", blockId); + termLog("[resync]", blockId, "triggered (conn status changed)"); model.termRef.current?.resyncController("resync handler"); setLastConnStatus(connStatus); }, [connStatus]); @@ -299,10 +300,16 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const termCursorStyle = normalizeCursorStyle(globalStore.get(getOverrideConfigAtom(blockId, "term:cursor"))); const termCursorBlink = globalStore.get(getOverrideConfigAtom(blockId, "term:cursorblink")) ?? false; const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused); + const connectElem = connectElemRef.current; + const createRect = connectElem?.getBoundingClientRect?.(); + termLog("[init]", blockId, "creating TermWrap, containerRect:", + createRect ? `${createRect.width.toFixed(0)}x${createRect.height.toFixed(0)}` : "null", + "parentRect:", connectElem?.parentElement?.getBoundingClientRect?.() ? + `${connectElem.parentElement.getBoundingClientRect().width.toFixed(0)}x${connectElem.parentElement.getBoundingClientRect().height.toFixed(0)}` : "null"); const termWrap = new TermWrap( tabModel.tabId, blockId, - connectElemRef.current, + connectElem, { theme: termTheme, fontSize: termFontSize, @@ -329,7 +336,13 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => (window as any).term = termWrap; model.termRef.current = termWrap; setTermWrapInst(termWrap); - const rszObs = new ResizeObserver(() => { + const rszObs = new ResizeObserver((entries) => { + const entry = entries[0]; + const cr = entry?.contentRect; + if (cr) { + termLog("[resize]", blockId, "ResizeObserver fired, contentRect:", + `${cr.width.toFixed(0)}x${cr.height.toFixed(0)}`); + } termWrap.handleResize_debounced(); }); rszObs.observe(connectElemRef.current); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d49e05de5b..1b1103f6a2 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -27,9 +27,9 @@ import { WebglAddon } from "@xterm/addon-webgl"; import { Subscription } from "rxjs"; import * as TermTypes from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; -import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "throttle-debounce"; +import { termLog } from "@/util/termlog"; import { handleOsc16162Command, handleOsc52Command, @@ -46,23 +46,11 @@ import { trimTerminalSelection, } from "./termutil"; -const dlog = debug("wave:termwrap"); - const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; export const SupportsImageInput = true; const MaxRepaintTransactionMs = 2000; -const AltScreenEnterSeq = "\x1b[?1049h"; -const AltScreenExitSeq = "\x1b[?1049l"; -const AppCursorKeysEnterSeq = "\x1b[?1h"; -const AppCursorKeysExitSeq = "\x1b[?1l"; -const CursorShowSeq = "\x1b[?25h"; -const ClearScreenSeq = "\x1b[2J"; -const HomeAndClearScreenSeq = "\x1b[H\x1b[2J"; -const MousePrivateModeRe = /\x1b\[\?([0-9;]+)([hl])/g; -const MouseTrackingModes = new Set(["9", "1000", "1002", "1003"]); -const MouseModeParams = new Set([...MouseTrackingModes, "1005", "1006", "1015"]); // detect webgl support function detectWebGLSupport(): boolean { @@ -85,174 +73,7 @@ type TermWrapOptions = { nodeModel?: BlockNodeModel; }; -type MouseRestoreState = { - activeParams: Set; -}; - -type MouseRestoreData = { - data: string | Uint8Array; - mouseState: MouseRestoreState; -}; - -function hasMouseTrackingMode(mouseState: MouseRestoreState): boolean { - for (const param of mouseState.activeParams) { - if (MouseTrackingModes.has(param)) { - return true; - } - } - return false; -} - -function makeMouseRestoreSeq(mouseState: MouseRestoreState): string | null { - if (!hasMouseTrackingMode(mouseState)) { - return null; - } - return `\x1b[?${Array.from(mouseState.activeParams).join(";")}h`; -} -function processMouseRestoreParams(mouseState: MouseRestoreState, params: string[], final: string): void { - for (const param of params) { - if (!MouseModeParams.has(param)) { - continue; - } - if (final === "h") { - if (MouseTrackingModes.has(param)) { - for (const trackingParam of MouseTrackingModes) { - mouseState.activeParams.delete(trackingParam); - } - } - mouseState.activeParams.add(param); - continue; - } - mouseState.activeParams.delete(param); - } -} - -function prepareMouseRestoreData(data: string | Uint8Array, mouseState: MouseRestoreState): MouseRestoreData { - const dataStr = data instanceof Uint8Array ? new TextDecoder().decode(data) : data; - let changed = false; - // Mouse modes change how user input is encoded, so restore records them - // separately and only reapplies them when the final buffer is still alternate. - const restoreData = dataStr.replace(MousePrivateModeRe, (seq, rawParams: string, final: string) => { - const params = rawParams.split(";"); - const mouseParams = params.filter((param) => MouseModeParams.has(param)); - if (mouseParams.length === 0) { - return seq; - } - processMouseRestoreParams(mouseState, mouseParams, final); - changed = true; - const remainingParams = params.filter((param) => !MouseModeParams.has(param)); - if (remainingParams.length === 0) { - return ""; - } - return `\x1b[?${remainingParams.join(";")}${final}`; - }); - if (!changed) { - return { data, mouseState }; - } - if (data instanceof Uint8Array) { - return { data: new TextEncoder().encode(restoreData), mouseState }; - } - return { data: restoreData, mouseState }; -} - -// Some remote full-screen programs clear the main screen without emitting -// smcup/rmcup. Keep that temporary drawing in xterm's alternate buffer. -class SyntheticAltScreenTracker { - pendingEnter: boolean = false; - active: boolean = false; - pendingExit: boolean = false; - - process(data: string): string | null { - let changed = false; - let rtn = ""; - - for (let idx = 0; idx < data.length; ) { - const handledSeq = this.matchAndAppendSeq(data, idx); - if (handledSeq != null) { - rtn += handledSeq.data; - idx += handledSeq.seqLen; - changed ||= handledSeq.changed; - continue; - } - rtn += data[idx]; - idx++; - } - - const exitSeq = this.flushPendingExit(); - if (exitSeq != null) { - rtn += exitSeq; - changed = true; - } - return changed ? rtn : null; - } - - matchAndAppendSeq(data: string, idx: number): { data: string; seqLen: number; changed: boolean } | null { - if (data.startsWith(AltScreenEnterSeq, idx)) { - this.reset(); - return { data: AltScreenEnterSeq, seqLen: AltScreenEnterSeq.length, changed: false }; - } - if (data.startsWith(AltScreenExitSeq, idx)) { - this.reset(); - return { data: AltScreenExitSeq, seqLen: AltScreenExitSeq.length, changed: false }; - } - if (data.startsWith(AppCursorKeysEnterSeq, idx)) { - if (!this.active) { - this.pendingEnter = true; - } - return { data: AppCursorKeysEnterSeq, seqLen: AppCursorKeysEnterSeq.length, changed: false }; - } - if (data.startsWith(AppCursorKeysExitSeq, idx)) { - let changed = false; - if (this.active) { - this.pendingExit = true; - changed = true; - } - this.pendingEnter = false; - return { data: AppCursorKeysExitSeq, seqLen: AppCursorKeysExitSeq.length, changed }; - } - if (data.startsWith(CursorShowSeq, idx)) { - const exitSeq = this.flushPendingExit(); - return { - data: exitSeq == null ? CursorShowSeq : CursorShowSeq + exitSeq, - seqLen: CursorShowSeq.length, - changed: exitSeq != null, - }; - } - if (data.startsWith(HomeAndClearScreenSeq, idx)) { - return this.maybeEnterAltScreen(HomeAndClearScreenSeq); - } - if (data.startsWith(ClearScreenSeq, idx)) { - return this.maybeEnterAltScreen(ClearScreenSeq); - } - return null; - } - - maybeEnterAltScreen(seq: string): { data: string; seqLen: number; changed: boolean } { - if (!this.pendingEnter || this.active) { - return { data: seq, seqLen: seq.length, changed: false }; - } - this.active = true; - this.pendingEnter = false; - this.pendingExit = false; - return { data: AltScreenEnterSeq + seq, seqLen: seq.length, changed: true }; - } - - flushPendingExit(): string | null { - if (!this.pendingExit) { - return null; - } - this.active = false; - this.pendingExit = false; - return AltScreenExitSeq; - } - - reset() { - this.pendingEnter = false; - this.active = false; - this.pendingExit = false; - } -} export class TermWrap { tabId: string; @@ -304,7 +125,6 @@ export class TermWrap { lastMode2026ResetTs: number = 0; inSyncTransaction: boolean = false; inRepaintTransaction: boolean = false; - syntheticAltScreenTracker: SyntheticAltScreenTracker = new SyntheticAltScreenTracker(); suppressTerminalData: boolean = false; constructor( @@ -469,6 +289,9 @@ export class TermWrap { this.heldData = []; this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); + const initRect = this.connectElem.getBoundingClientRect(); + termLog("[init]", this.blockId, "terminal.open done, size:", `${this.terminal.rows}x${this.terminal.cols}`, + "containerRect:", `${initRect.width.toFixed(0)}x${initRect.height.toFixed(0)}`); const dragoverHandler = (e: DragEvent) => { e.preventDefault(); @@ -519,6 +342,9 @@ export class TermWrap { if (!zoneId || this.zoneId === zoneId) { return; } + termLog("[zone]", this.blockId, "switchZone:", this.zoneId, "->", zoneId, + "zoneLoadVersion:", this.zoneLoadVersion, + "termSize:", `${this.terminal.cols}x${this.terminal.rows}`); this._mainFileSub?.unsubscribe(); this._mainFileSub = null; this.mainFileSubject?.release(); @@ -528,20 +354,25 @@ export class TermWrap { this.ptyOffset = 0; this.dataBytesProcessed = 0; this.heldData = []; - this.syntheticAltScreenTracker.reset(); this.terminal.reset(); this.mainFileSubject = getFileSubject(this.getZoneId(), TermFileName); this._mainFileSub = this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); await this.loadInitialTerminalData(); + await this.syncControllerTermSize(); this.terminal.scrollToBottom(); + termLog("[zone]", this.blockId, "switchZone done, zone:", this.getZoneId(), + "termSize:", `${this.terminal.cols}x${this.terminal.rows}`); } async attachToDaemon(jobId: string): Promise { + termLog("[zone]", this.blockId, "attachToDaemon:", jobId, + "termSize:", `${this.terminal.cols}x${this.terminal.rows}`); await this.switchZone(jobId); - await this.syncControllerTermSize(); + termLog("[zone]", this.blockId, "attachToDaemon: done"); } async detachFromDaemon(): Promise { + termLog("[zone]", this.blockId, "detachFromDaemon"); await this.switchZone(this.blockId); } @@ -597,6 +428,7 @@ export class TermWrap { } async initTerminal() { + const initStartTs = Date.now(); const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); const trimTrailingWhitespaceAtom = getSettingsKeyAtom("term:trimtrailingwhitespace"); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); @@ -652,8 +484,13 @@ export class TermWrap { try { await this.loadInitialTerminalData(); + await this.syncControllerTermSize(); } finally { this.loaded = true; + termLog("[init]", this.blockId, "initTerminal complete", + `${Date.now() - initStartTs}ms`, + "zone:", this.getZoneId(), + "termSize:", `${this.terminal.cols}x${this.terminal.rows}`); } this.runProcessIdleTimeout(); } @@ -688,6 +525,16 @@ export class TermWrap { return; } + if (data === "\x1b[I" || data === "\x1b[O") { + termLog("[focus]", this.blockId, "focus event to pty:", data === "\x1b[I" ? "FOCUS_IN" : "FOCUS_OUT"); + } + + const sgrMouseRe = /^\x1b\[<\d+;\d+;\d+[Mm]$/; + if (sgrMouseRe.test(data)) { + if (this.terminal.buffer.active.type !== "alternate") { + return; + } + } this.sendDataHandler?.(data); this.multiInputCallback?.(data); } @@ -720,7 +567,6 @@ export class TermWrap { dataBytesProcessed?: number ): Promise { const rawDataLen = dataBytesProcessed ?? data.length; - const writeData = setPtyOffset == null ? this.maybeAddSyntheticAltScreen(data) : data; 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 }); @@ -739,7 +585,7 @@ export class TermWrap { if (suppressTerminalData) { this.suppressTerminalData = true; } - this.terminal.write(writeData, () => { + this.terminal.write(data, () => { try { if (setPtyOffset != null) { this.ptyOffset = setPtyOffset; @@ -758,24 +604,11 @@ export class TermWrap { return prtn; } - maybeAddSyntheticAltScreen(data: string | Uint8Array): string | Uint8Array { - const dataStr = data instanceof Uint8Array ? new TextDecoder().decode(data) : data; - const syntheticData = this.syntheticAltScreenTracker.process(dataStr); - if (syntheticData == null) { - return data; - } - if (data instanceof Uint8Array) { - return new TextEncoder().encode(syntheticData); - } - return syntheticData; - } - async loadInitialTerminalData(): Promise { const startTs = Date.now(); const zoneId = this.getZoneId(); const zoneLoadVersion = this.zoneLoadVersion; const { data: cacheData, fileInfo: cacheFile } = await fetchWaveFile(zoneId, TermCacheFileName); - const mouseRestoreState: MouseRestoreState = { activeParams: new Set() }; let ptyOffset = 0; if (zoneId !== this.getZoneId() || zoneLoadVersion !== this.zoneLoadVersion) { return; @@ -790,12 +623,12 @@ export class TermWrap { fileTermSize != null && (fileTermSize.rows != curTermSize.rows || fileTermSize.cols != curTermSize.cols) ) { - console.log("terminal restore size mismatch, temp resize", fileTermSize, curTermSize); + termLog("[init]", this.blockId, "terminal restore size mismatch, temp resize", + `${fileTermSize.cols}x${fileTermSize.rows}`, "->", `${curTermSize.cols}x${curTermSize.rows}`); this.terminal.resize(fileTermSize.cols, fileTermSize.rows); didResize = true; } - const restoreData = prepareMouseRestoreData(cacheData, mouseRestoreState); - await this.doTerminalWrite(restoreData.data, ptyOffset, true, cacheData.length); + await this.doTerminalWrite(cacheData, ptyOffset, true, cacheData.length); if (didResize) { this.terminal.resize(curTermSize.cols, curTermSize.rows); } @@ -805,18 +638,15 @@ export class TermWrap { if (zoneId !== this.getZoneId() || zoneLoadVersion !== this.zoneLoadVersion) { return; } - console.log( - `terminal loaded cachefile:${cacheData?.byteLength ?? 0} main:${mainData?.byteLength ?? 0} bytes, ${Date.now() - startTs}ms` - ); + termLog("[init]", this.blockId, + `loaded cache:${cacheData?.byteLength ?? 0} main:${mainData?.byteLength ?? 0} bytes, ${Date.now() - startTs}ms`); if (mainFile != null) { - const restoreData = prepareMouseRestoreData(mainData, mouseRestoreState); - await this.doTerminalWrite(restoreData.data, null, true, mainData.length); + await this.doTerminalWrite(mainData, null, true, mainData.length); } - await this.restoreMouseTrackingMode(mouseRestoreState); } async resyncController(reason: string) { - console.log("[termwrap] resync controller", this.blockId, reason); + termLog("[resync]", this.blockId, reason, `${this.terminal.cols}x${this.terminal.rows}`); const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } }; try { await RpcApi.ControllerResyncCommand(TabRpcClient, { @@ -825,7 +655,7 @@ export class TermWrap { rtopts: rtOpts, }); } catch (e) { - console.log(`error controller resync (${reason})`, this.blockId, e); + termLog("[resync]", this.blockId, "error", reason, e); } } @@ -845,13 +675,15 @@ export class TermWrap { async syncControllerTermSize() { if (!this.shouldSyncControllerTermSize()) { + termLog("[resize]", this.blockId, "syncControllerTermSize skipped (shouldSyncControllerTermSize=false)"); return; } const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; + termLog("[resize]", this.blockId, "syncControllerTermSize", `${termSize.cols}x${termSize.rows}`); try { await RpcApi.SetTerminalSizeCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); } catch (e) { - console.log("error syncing terminal size", this.blockId, e); + termLog("[resize]", this.blockId, "error syncing terminal size", e); } } @@ -861,8 +693,7 @@ export class TermWrap { this.fitAddon.fit(); const didResize = oldRows !== this.terminal.rows || oldCols !== this.terminal.cols; if (didResize) { - console.log( - "[termwrap] resize", + termLog("[fit]", this.blockId, `${oldRows}x${oldCols}`, "->", `${this.terminal.rows}x${this.terminal.cols}` @@ -876,28 +707,14 @@ export class TermWrap { await this.syncControllerTermSize(); } - async restoreMouseTrackingMode(mouseState: MouseRestoreState) { - // TODO: This intentionally avoids restoring mouse input in the main buffer. - // A main-buffer TUI such as tmux could need it; use stronger foreground - // process/session state if we need to distinguish that from a shell prompt. - if (this.terminal.buffer.active.type !== "alternate") { - return; - } - const restoreSeq = makeMouseRestoreSeq(mouseState); - if (restoreSeq == null) { - return; - } - await this.doTerminalWrite(restoreSeq, this.ptyOffset, true); - } - handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; const didResize = this.fitTerminalToContainer(); if (didResize) { + termLog("[resize]", this.blockId, "handleResize:", `${oldRows}x${oldCols}`, "->", `${this.terminal.rows}x${this.terminal.cols}`); fireAndForget(() => this.syncControllerTermSize()); } - dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); if (!this.hasResized) { this.hasResized = true; this.resyncController("initial resize"); @@ -910,7 +727,10 @@ export class TermWrap { } const serializedOutput = this.serializeAddon.serialize(); const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; - console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize); + termLog("[cache]", this.blockId, "idle timeout", + "dataBytesProcessed:", this.dataBytesProcessed, + "serialized:", serializedOutput.length, + "termSize:", `${termSize.cols}x${termSize.rows}`); fireAndForget(() => services.BlockService.SaveTerminalState(this.getZoneId(), serializedOutput, "full", this.ptyOffset, termSize) ); diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 0741df9bcb..88def9bc42 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -5,6 +5,7 @@ import { FocusManager } from "@/app/store/focusManager"; import { getSettingsKeyAtom } from "@/app/store/global"; import { BlockService } from "@/app/store/services"; import * as WOS from "@/app/store/wos"; +import { termLog } from "@/util/termlog"; import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; import { Atom, atom, Getter, PrimitiveAtom, Setter } from "jotai"; import { splitAtom } from "jotai/utils"; @@ -1116,10 +1117,13 @@ export class LayoutModel { */ switchNodeFocusInDirection(direction: NavigateDirection, inWaveAI: boolean): NavigationResult { const curNodeId = this.focusedNodeId; + termLog("[block]", "switchNodeFocusInDirection", direction, "curNode:", curNodeId); // If no node is focused, set focus to the first leaf. if (!curNodeId) { - this.focusNode(this.getter(this.leafOrder)[0].nodeid); + const firstNodeId = this.getter(this.leafOrder)[0].nodeid; + termLog("[block]", "switchNodeFocusInDirection: no focused node, focusing first", firstNodeId); + this.focusNode(firstNodeId); return { success: true }; } @@ -1220,6 +1224,7 @@ export class LayoutModel { return; } const leaf = leafOrder[newLeafIdx]; + termLog("[block]", "switchNodeFocusByBlockNum", newBlockNum, "->", leaf.nodeid); this.focusNode(leaf.nodeid); } @@ -1229,13 +1234,14 @@ export class LayoutModel { */ focusNode(nodeId: string) { if (this.focusedNodeId === nodeId) return; + termLog("[block]", "focusNode", nodeId); let layoutNode = findNode(this.treeState?.rootNode, nodeId); if (!layoutNode) { const ephemeralNode = this.getter(this.ephemeralNode); if (ephemeralNode?.id === nodeId) { layoutNode = ephemeralNode; } else { - console.error("unable to focus node, cannot find it in tree", nodeId); + termLog("[block]", "focusNode error: cannot find node in tree", nodeId); return; } } diff --git a/frontend/util/termlog.ts b/frontend/util/termlog.ts new file mode 100644 index 0000000000..a185ba4eb4 --- /dev/null +++ b/frontend/util/termlog.ts @@ -0,0 +1,33 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +function pad(n: number, len = 2): string { + return String(n).padStart(len, "0"); +} + +function formatTimestamp(d: Date = new Date()): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +function safeStr(a: any): string { + if (typeof a === "string") return a; + if (a instanceof Error) return a.stack || a.message; + try { + return JSON.stringify(a); + } catch { + return String(a); + } +} + +function termLog(...args: any[]): void { + const line = `${formatTimestamp()} [term] ${args.map(safeStr).join(" ")}`; + try { + const api = (window as any).api; + api?.sendLog?.(line); + } catch (_) { + // sendLog not available (preview/mock) — fall through + } + console.log(line); +} + +export { termLog, formatTimestamp }; diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 002d2e1885..4db3592a5d 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -33,6 +33,9 @@ var WorkspaceColors = [...]string{ "#FFE900", // Yellow } +const MaxWorkspaces = 10 +const MaxTabsPerWorkspace = 10 + var WorkspaceIcons = [...]string{ "custom@wave-logo-solid", "triangle", @@ -51,6 +54,13 @@ var WorkspaceIcons = [...]string{ } func CreateWorkspace(ctx context.Context, name string, icon string, color string, applyDefaults bool, isInitialLaunch bool) (*waveobj.Workspace, error) { + workspaces, err := wstore.DBGetAllObjsByType[*waveobj.Workspace](ctx, waveobj.OType_Workspace) + if err != nil { + return nil, fmt.Errorf("error checking workspace count: %w", err) + } + if len(workspaces) >= MaxWorkspaces { + return nil, fmt.Errorf("cannot create workspace: maximum of %d workspaces reached", MaxWorkspaces) + } ws := &waveobj.Workspace{ OID: uuid.NewString(), TabIds: []string{}, @@ -58,7 +68,7 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string Icon: "", Color: "", } - err := wstore.DBInsert(ctx, ws) + err = wstore.DBInsert(ctx, ws) if err != nil { return nil, fmt.Errorf("error inserting workspace: %w", err) } @@ -281,6 +291,9 @@ func createTabObj(ctx context.Context, workspaceId string, name string, meta wav if err != nil { return nil, fmt.Errorf("workspace %s not found: %w", workspaceId, err) } + if len(ws.TabIds) >= MaxTabsPerWorkspace { + return nil, fmt.Errorf("cannot create tab: maximum of %d tabs per workspace reached", MaxTabsPerWorkspace) + } layoutStateId := uuid.NewString() tab := &waveobj.Tab{ OID: uuid.NewString(),