diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index 01fe12800e..e97e08dc35 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -126,6 +126,12 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { }); const reinitVersion = atom(0); const rateLimitInfoAtom = atom(null) as PrimitiveAtom; + const quickTerminalAtom = atom({ + visible: false, + blockId: null as string | null, + opening: false, + closing: false, + }) as PrimitiveAtom<{ visible: boolean; blockId: string | null; opening: boolean; closing: boolean }>; atoms = { // initialized in wave.ts (will not be null inside of application) builderId: builderIdAtom, @@ -149,6 +155,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { allConnStatus: allConnStatusAtom, reinitVersion, waveAIRateLimitInfoAtom: rateLimitInfoAtom, + quickTerminalAtom, } as GlobalAtomsType; } diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index acc0f4d518..a8f790331b 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -3,6 +3,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import type { TermViewModel } from "@/app/view/term/term-model"; import { getLayoutModelForStaticTab, LayoutTreeActionType, @@ -38,6 +39,7 @@ import * as WOS from "./wos"; import { getFileSubject, waveEventSubscribeSingle } from "./wps"; let globalPrimaryTabStartup: boolean = false; +const QuickTerminalInitialState = { visible: false, blockId: null as string | null, opening: false, closing: false }; function initGlobal(initOpts: GlobalInitOptions) { globalPrimaryTabStartup = initOpts.primaryTabStartup ?? false; @@ -570,6 +572,29 @@ function getFocusedBlockId(): string { return focusedLayoutNode?.data?.blockId; } +function getInheritedContextFromBlock(blockId: string | null): { cwd: string | null; connection: string | null } { + if (blockId == null) { + return { cwd: null, connection: null }; + } + + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = globalStore.get(blockAtom); + const blockComponentModel = getBlockComponentModel(blockId); + const termViewModel = blockComponentModel?.viewModel as TermViewModel | undefined; + const liveCwdAtom = termViewModel?.termRef?.current?.currentCwdAtom; + const liveCwd = liveCwdAtom ? globalStore.get(liveCwdAtom) : null; + const cwd = typeof liveCwd === "string" ? liveCwd : typeof blockData?.meta?.["cmd:cwd"] === "string" ? blockData.meta["cmd:cwd"] : null; + + let connection = typeof blockData?.meta?.connection === "string" ? blockData.meta.connection : null; + const shellProcFullStatusAtom = termViewModel?.shellProcFullStatus; + const runtimeStatus = shellProcFullStatusAtom ? globalStore.get(shellProcFullStatusAtom) : null; + if (typeof runtimeStatus?.shellprocconnname === "string") { + connection = runtimeStatus.shellprocconnname; + } + + return { cwd, connection }; +} + // pass null to refocus the currently focused block function refocusNode(blockId: string) { if (blockId == null) { @@ -673,6 +698,60 @@ function recordTEvent(event: string, props?: TEventProps) { RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true }); } +async function toggleQuickTerminal(): Promise { + const layoutModel = getLayoutModelForStaticTab(); + const quickTermState = globalStore.get(atoms.quickTerminalAtom); + + if (quickTermState.opening || quickTermState.closing) { + return true; + } + + if (quickTermState.visible && quickTermState.blockId) { + // Dismiss: close the ephemeral node + // Set closing flag to prevent race condition with double-ESC + globalStore.set(atoms.quickTerminalAtom, { ...quickTermState, closing: true }); + const quickTerminalNode = layoutModel.getNodeByBlockId(quickTermState.blockId); + if (quickTerminalNode != null) { + await layoutModel.closeNode(quickTerminalNode.id); + } else { + await ObjectService.DeleteBlock(quickTermState.blockId); + } + globalStore.set(atoms.quickTerminalAtom, QuickTerminalInitialState); + return true; + } + + // Summon: inherit connection info and current working directory from the focused block when possible. + const focusedBlockId = getFocusedBlockId(); + const { cwd, connection } = getInheritedContextFromBlock(focusedBlockId); + + // Create ephemeral terminal block with custom quick terminal sizing + const blockDef: BlockDef = { + meta: { + view: "term", + controller: "shell", + ...(connection != null && { connection }), + ...(cwd != null && { "cmd:cwd": cwd }), + }, + }; + + globalStore.set(atoms.quickTerminalAtom, { ...QuickTerminalInitialState, opening: true }); + + let blockId: string | null = null; + try { + const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; + blockId = await ObjectService.CreateBlock(blockDef, rtOpts); + layoutModel.newQuickTerminalNode(blockId, focusedBlockId); + globalStore.set(atoms.quickTerminalAtom, { visible: true, blockId, opening: false, closing: false }); + return true; + } catch (error) { + globalStore.set(atoms.quickTerminalAtom, QuickTerminalInitialState); + if (blockId != null) { + fireAndForget(() => ObjectService.DeleteBlock(blockId)); + } + throw error; + } +} + export { atoms, createBlock, @@ -683,6 +762,7 @@ export { getAllBlockComponentModels, getApi, getBlockComponentModel, + getInheritedContextFromBlock, getBlockMetaKeyAtom, getBlockTermDurableAtom, getTabMetaKeyAtom, @@ -715,6 +795,7 @@ export { setNodeFocus, setPlatform, subscribeToConnEvents, + toggleQuickTerminal, unregisterBlockComponentModel, useBlockAtom, useBlockCache, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 3df35f9ba3..89f084d941 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -13,11 +13,13 @@ import { getApi, getBlockComponentModel, getFocusedBlockId, + getInheritedContextFromBlock, getSettingsKeyAtom, globalStore, recordTEvent, refocusNode, replaceBlock, + toggleQuickTerminal, WOS, } from "@/app/store/global"; import { getActiveTabModel } from "@/app/store/tab-model"; @@ -42,6 +44,10 @@ let globalKeybindingsDisabled = false; let activeChord: string | null = null; let chordTimeout: NodeJS.Timeout = null; +// Quick terminal double-ESC tracking +let lastEscapeTime: number = 0; +const QUICK_TERM_DOUBLE_ESC_TIMEOUT = 300; // milliseconds + function resetChord() { activeChord = null; if (chordTimeout) { @@ -361,15 +367,12 @@ function getDefaultNewBlockDef(): BlockDef { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { - const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedNode.data?.blockId)); - const blockData = globalStore.get(blockAtom); - if (blockData?.meta?.view == "term") { - if (blockData?.meta?.["cmd:cwd"] != null) { - termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"]; - } + const { cwd, connection } = getInheritedContextFromBlock(focusedNode.data?.blockId); + if (cwd != null) { + termBlockDef.meta["cmd:cwd"] = cwd; } - if (blockData?.meta?.connection != null) { - termBlockDef.meta.connection = blockData.meta.connection; + if (connection != null) { + termBlockDef.meta.connection = connection; } } return termBlockDef; @@ -726,6 +729,36 @@ function registerGlobalKeys() { } globalKeyMap.set("Cmd:f", activateSearch); globalKeyMap.set("Escape", () => { + const now = Date.now(); + const quickTermState = globalStore.get(atoms.quickTerminalAtom); + + // Handle quick terminal toggle on double-ESC + if (quickTermState.visible) { + // If quick terminal is open, single ESC dismisses it + // Skip if already closing to prevent double-close + if (!quickTermState.closing) { + fireAndForget(() => toggleQuickTerminal()); + } + lastEscapeTime = 0; // Reset to prevent stale double-ESC detection + return true; + } + + if (quickTermState.opening || quickTermState.closing) { + lastEscapeTime = 0; + return true; + } + + // Check for double-ESC to summon quick terminal + if (now - lastEscapeTime < QUICK_TERM_DOUBLE_ESC_TIMEOUT) { + // Double ESC detected - summon quick terminal + fireAndForget(() => toggleQuickTerminal()); + lastEscapeTime = 0; // Reset after handling + return true; + } + + lastEscapeTime = now; + + // Existing ESC behavior (modals, search) if (modalsModel.hasOpenModals()) { modalsModel.popModal(); return true; diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index 7fe7dcd4ee..c483390feb 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -218,7 +218,7 @@ export function handleOsc52Command(data: string, blockId: string, loaded: boolea // for xterm handlers, we return true always because we "own" OSC 7. // even if it is invalid we dont want to propagate to other handlers -export function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean { +export function handleOsc7Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean { if (!loaded) { return true; } @@ -261,6 +261,8 @@ export function handleOsc7Command(data: string, blockId: string, loaded: boolean return true; } + globalStore.set(termWrap.currentCwdAtom, pathPart); + setTimeout(() => { fireAndForget(async () => { await RpcApi.SetMetaCommand(TabRpcClient, { diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index bf77ef9535..e639eaa215 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -671,6 +671,10 @@ export class TermViewModel implements ViewModel { } const blockData = globalStore.get(this.blockAtom); if (blockData.meta?.["term:mode"] == "vdom") { + // Don't consume Escape key - let it propagate to global handler for quick terminal close + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + return false; + } const vdomModel = this.getVDomModel(); return vdomModel?.keyDownHandler(waveEvent); } diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index e1b129b72d..fa8051ef15 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -7,6 +7,7 @@ import { getFileSubject } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { + atoms, fetchWaveFile, getApi, getOverrideConfigAtom, @@ -16,6 +17,7 @@ import { openLink, WOS, } from "@/store/global"; +import { getLayoutModelForStaticTab } from "@/layout/index"; import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { base64ToArray, fireAndForget } from "@/util/util"; @@ -99,8 +101,10 @@ export class TermWrap { lastUpdated: number; promptMarkers: TermTypes.IMarker[] = []; shellIntegrationStatusAtom: jotai.PrimitiveAtom; + currentCwdAtom: jotai.PrimitiveAtom; lastCommandAtom: jotai.PrimitiveAtom; claudeCodeActiveAtom: jotai.PrimitiveAtom; + contentHeightRows: number; nodeModel: BlockNodeModel; // this can be null hoveredLinkUri: string | null = null; onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void; @@ -120,6 +124,7 @@ export class TermWrap { lastMode2026ResetTs: number = 0; inSyncTransaction: boolean = false; inRepaintTransaction: boolean = false; + syncQuickTerminalHeight_debounced: () => void; constructor( tabId: string, @@ -139,8 +144,10 @@ export class TermWrap { this.lastUpdated = Date.now(); this.promptMarkers = []; this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.currentCwdAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.claudeCodeActiveAtom = jotai.atom(false); + this.contentHeightRows = 0; this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); @@ -182,7 +189,7 @@ export class TermWrap { // Register OSC handlers this.terminal.parser.registerOscHandler(7, (data: string) => { try { - return handleOsc7Command(data, this.blockId, this.loaded); + return handleOsc7Command(data, this.blockId, this.loaded, this); } catch (e) { console.error("[termwrap] osc 7 handler error", this.blockId, e); return false; @@ -280,6 +287,7 @@ export class TermWrap { this.mainFileSubject = null; this.heldData = []; this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); + this.syncQuickTerminalHeight_debounced = debounce(16, this.syncQuickTerminalHeight.bind(this)); this.terminal.open(this.connectElem); const dragoverHandler = (e: DragEvent) => { @@ -475,6 +483,7 @@ export class TermWrap { if (msg.fileop == "truncate") { this.terminal.clear(); this.heldData = []; + this.syncQuickTerminalHeight_debounced(); } else if (msg.fileop == "append") { const decodedData = base64ToArray(msg.data64); if (this.loaded) { @@ -508,6 +517,7 @@ export class TermWrap { this.dataBytesProcessed += data.length; } this.lastUpdated = Date.now(); + this.syncQuickTerminalHeight_debounced(); resolve(); }); return prtn; @@ -575,6 +585,7 @@ export class TermWrap { ); RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); } + this.syncQuickTerminalHeight_debounced(); dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); if (!this.hasResized) { this.hasResized = true; @@ -582,6 +593,23 @@ export class TermWrap { } } + private getContentHeightRows(): number { + return Math.max(1, this.terminal.buffer.active.baseY + this.terminal.buffer.active.cursorY + 1); + } + + private syncQuickTerminalHeight() { + const nextRows = this.getContentHeightRows(); + this.contentHeightRows = nextRows; + + const quickTermState = globalStore.get(atoms.quickTerminalAtom); + if (quickTermState.blockId !== this.blockId) { + return; + } + + const layoutModel = getLayoutModelForStaticTab(); + layoutModel?.updateTree(false); + } + processAndCacheData() { if (this.dataBytesProcessed < MinDataProcessedForCache) { return; diff --git a/frontend/layout/lib/layoutModel.ts b/frontend/layout/lib/layoutModel.ts index 0741df9bcb..ab569c40e6 100644 --- a/frontend/layout/lib/layoutModel.ts +++ b/frontend/layout/lib/layoutModel.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { FocusManager } from "@/app/store/focusManager"; -import { getSettingsKeyAtom } from "@/app/store/global"; +import { getBlockComponentModel, getSettingsKeyAtom } from "@/app/store/global"; import { BlockService } from "@/app/store/services"; import * as WOS from "@/app/store/wos"; import { atomWithThrottle, boundNumber, fireAndForget } from "@/util/util"; @@ -72,6 +72,16 @@ interface ResizeContext { const DefaultGapSizePx = 3; const MinNodeSizePx = 40; const DefaultAnimationTimeS = 0.15; +const QuickTerminalMinWidthPx = 160; +const QuickTerminalFallbackCols = 80; +const QuickTerminalFallbackCharWidthPx = 8; +const QuickTerminalInitialHeightPct = 0.1; +const QuickTerminalMaxHeightPct = 0.5; +const QuickTerminalMinRows = 2; +const QuickTerminalRowHeightMultiplier = 1.35; +const QuickTerminalVerticalChromePx = 8; +const QuickTerminalHorizontalInsetPx = 12; +const QuickTerminalEphemeralType = "quick-terminal" as const; export class LayoutModel { /** @@ -753,13 +763,17 @@ export class LayoutModel { // Process ephemeral node, if present. const ephemeralNode = this.getter(this.ephemeralNode); if (ephemeralNode) { - this.updateEphemeralNodeProps( - ephemeralNode, - newAdditionalProps, - newLeafs, - magnifiedNodeSize, - boundingRect - ); + if (ephemeralNode.data?.ephemeralType === QuickTerminalEphemeralType) { + this.updateQuickTerminalNodeProps(ephemeralNode, newAdditionalProps, newLeafs, boundingRect); + } else { + this.updateEphemeralNodeProps( + ephemeralNode, + newAdditionalProps, + newLeafs, + magnifiedNodeSize, + boundingRect + ); + } } this.treeState.leafOrder = getLeafOrder(newLeafs, newAdditionalProps); @@ -1335,6 +1349,102 @@ export class LayoutModel { this.focusNode(ephemeralNode.id); } + newQuickTerminalNode(blockId: string, sourceBlockId?: string | null) { + if (this.getter(this.ephemeralNode)) { + this.closeNode(this.getter(this.ephemeralNode).id); + } + + const ephemeralNode = newLayoutNode(undefined, undefined, undefined, { + blockId, + ephemeralType: QuickTerminalEphemeralType, + quickTerminalSourceBlockId: sourceBlockId, + }); + this.setter(this.ephemeralNode, ephemeralNode); + + const addlProps = this.getter(this.additionalProps); + const leafs = this.getter(this.leafs); + const boundingRect = this.getBoundingRect(); + + this.updateQuickTerminalNodeProps(ephemeralNode, addlProps, leafs, boundingRect); + + this.setter(this.additionalProps, addlProps); + this.focusNode(ephemeralNode.id); + } + + updateQuickTerminalNodeProps( + node: LayoutNode, + addlPropsMap: Record, + leafs: LayoutNode[], + boundingRect: Dimensions + ) { + // Quick terminal: width is slightly inset from the source block (or fallback), starts at 10% of the current + // window, then grows with terminal content up to 50%. + // always opens from the top edge of the layout container. + const termFontSize = this.getter(getSettingsKeyAtom("term:fontsize")) ?? 12; + const minHeightPx = Math.ceil( + termFontSize * QuickTerminalRowHeightMultiplier * QuickTerminalMinRows + QuickTerminalVerticalChromePx + ); + + // Determine width and left: prefer the source block bounds when available. + // If the source block is effectively fullscreen, don't inset it further. + // If no source block is available, fall back to the current layout width instead of a narrow fixed column size. + const layoutInsetPx = Math.min( + QuickTerminalHorizontalInsetPx, + Math.max(0, (boundingRect.width - QuickTerminalMinWidthPx) / 2) + ); + let width = boundingRect.width - 2 * layoutInsetPx; + let left = layoutInsetPx; + const sourceBlockId = node.data?.quickTerminalSourceBlockId; + if (sourceBlockId) { + const sourceNode = this.getNodeByBlockId(sourceBlockId); + if (sourceNode) { + const sourceRect = this.getNodeRectById(sourceNode.id); + if (sourceRect) { + if (sourceRect.width >= QuickTerminalMinWidthPx) { + const sourceMatchesLayoutWidth = + sourceRect.width >= boundingRect.width - 2 * QuickTerminalHorizontalInsetPx; + const sourceInsetPx = sourceMatchesLayoutWidth + ? 0 + : Math.min( + QuickTerminalHorizontalInsetPx, + Math.max(0, (sourceRect.width - QuickTerminalMinWidthPx) / 2) + ); + width = sourceRect.width - 2 * sourceInsetPx; + left = sourceRect.left + sourceInsetPx; + } + } + } + } + + const quickTerminalRows = + getBlockComponentModel(node.data?.blockId)?.viewModel?.termRef?.current?.contentHeightRows ?? 0; + const initialHeightPx = Math.floor(boundingRect.height * QuickTerminalInitialHeightPct); + const maxHeightPx = Math.floor(boundingRect.height * QuickTerminalMaxHeightPct); + const contentHeightPx = + quickTerminalRows > 0 + ? Math.ceil(quickTerminalRows * termFontSize * QuickTerminalRowHeightMultiplier) + + QuickTerminalVerticalChromePx + : 0; + let height = Math.max(initialHeightPx, minHeightPx, contentHeightPx); + height = Math.min(height, maxHeightPx); + height = Math.min(height, boundingRect.height); + left = Math.max(0, Math.min(left, boundingRect.width - width)); + + const transform = setTransform( + { + top: 0, + left: left, + width: width, + height: height, + }, + true, + true, + "var(--zindex-layout-ephemeral-node)" + ); + addlPropsMap[node.id] = { treeKey: "-1", transform }; + leafs.push(node); + } + addEphemeralNodeToLayout() { const ephemeralNode = this.getter(this.ephemeralNode); this.setter(this.ephemeralNode, undefined); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 123b9d3144..a68ebef406 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -187,6 +187,7 @@ function makeMockGlobalAtoms( allConnStatus: atom([] as ConnStatus[]), reinitVersion: atom(0) as any, waveAIRateLimitInfoAtom: atom(null) as any, + quickTerminalAtom: atom({ visible: false, blockId: null, opening: false, closing: false }) as any, }; if (!atomOverrides) { return defaults; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 06157e2566..1a1e510b22 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -29,6 +29,12 @@ declare global { allConnStatus: jotai.Atom; reinitVersion: jotai.PrimitiveAtom; waveAIRateLimitInfoAtom: jotai.PrimitiveAtom; + quickTerminalAtom: jotai.PrimitiveAtom<{ + visible: boolean; + blockId: string | null; + opening: boolean; + closing: boolean; + }>; }; type ThrottledValueAtom = jotai.WritableAtom], void>; @@ -50,6 +56,8 @@ declare global { type TabLayoutData = { blockId: string; + ephemeralType?: "quick-terminal"; + quickTerminalSourceBlockId?: string | null; }; type GlobalInitOptions = {