Skip to content
Open
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
7 changes: 7 additions & 0 deletions frontend/app/store/global-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
});
const reinitVersion = atom(0);
const rateLimitInfoAtom = atom(null) as PrimitiveAtom<RateLimitInfo>;
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,
Expand All @@ -149,6 +155,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
allConnStatus: allConnStatusAtom,
reinitVersion,
waveAIRateLimitInfoAtom: rateLimitInfoAtom,
quickTerminalAtom,
} as GlobalAtomsType;
}

Expand Down
81 changes: 81 additions & 0 deletions frontend/app/store/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Block>(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) {
Expand Down Expand Up @@ -673,6 +698,60 @@ function recordTEvent(event: string, props?: TEventProps) {
RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true });
}

async function toggleQuickTerminal(): Promise<boolean> {
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);
Comment on lines +709 to +719
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Always clear quick-terminal state when dismiss fails.

If layoutModel.closeNode() or ObjectService.DeleteBlock() throws after Line 714, quickTerminalAtom.closing never gets reset. frontend/app/store/keymodel.ts Line 746 then swallows subsequent Escape presses, effectively bricking the quick terminal until reload. Move the reset into a finally block so teardown errors cannot leave the state stuck.

Suggested fix
     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);
+        try {
+            const quickTerminalNode = layoutModel.getNodeByBlockId(quickTermState.blockId);
+            if (quickTerminalNode != null) {
+                await layoutModel.closeNode(quickTerminalNode.id);
+            } else {
+                await ObjectService.DeleteBlock(quickTermState.blockId);
+            }
+        } finally {
+            globalStore.set(atoms.quickTerminalAtom, QuickTerminalInitialState);
+        }
         return true;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
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 });
try {
const quickTerminalNode = layoutModel.getNodeByBlockId(quickTermState.blockId);
if (quickTerminalNode != null) {
await layoutModel.closeNode(quickTerminalNode.id);
} else {
await ObjectService.DeleteBlock(quickTermState.blockId);
}
} finally {
globalStore.set(atoms.quickTerminalAtom, QuickTerminalInitialState);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/store/global.ts` around lines 711 - 721, The quick-terminal
state may remain stuck with closing=true if layoutModel.closeNode or
ObjectService.DeleteBlock throws, so wrap the teardown in a try/finally: after
reading quickTermState and setting globalStore.set(atoms.quickTerminalAtom, {
...quickTermState, closing: true }), perform the async closeNode/deleteBlock
inside try and in the finally always reset the atom to QuickTerminalInitialState
via globalStore.set(atoms.quickTerminalAtom, QuickTerminalInitialState); ensure
you reference quickTermState.blockId, layoutModel.getNodeByBlockId,
layoutModel.closeNode, ObjectService.DeleteBlock, and QuickTerminalInitialState
when making the change.

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,
Expand All @@ -683,6 +762,7 @@ export {
getAllBlockComponentModels,
getApi,
getBlockComponentModel,
getInheritedContextFromBlock,
getBlockMetaKeyAtom,
getBlockTermDurableAtom,
getTabMetaKeyAtom,
Expand Down Expand Up @@ -715,6 +795,7 @@ export {
setNodeFocus,
setPlatform,
subscribeToConnEvents,
toggleQuickTerminal,
unregisterBlockComponentModel,
useBlockAtom,
useBlockCache,
Expand Down
49 changes: 41 additions & 8 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -361,15 +367,12 @@ function getDefaultNewBlockDef(): BlockDef {
const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode != null) {
const blockAtom = WOS.getWaveObjectAtom<Block>(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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/view/term/osc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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, {
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/view/term/term-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
30 changes: 29 additions & 1 deletion frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -99,8 +101,10 @@ export class TermWrap {
lastUpdated: number;
promptMarkers: TermTypes.IMarker[] = [];
shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
currentCwdAtom: jotai.PrimitiveAtom<string | null>;
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
claudeCodeActiveAtom: jotai.PrimitiveAtom<boolean>;
contentHeightRows: number;
nodeModel: BlockNodeModel; // this can be null
hoveredLinkUri: string | null = null;
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
Expand All @@ -120,6 +124,7 @@ export class TermWrap {
lastMode2026ResetTs: number = 0;
inSyncTransaction: boolean = false;
inRepaintTransaction: boolean = false;
syncQuickTerminalHeight_debounced: () => void;

constructor(
tabId: string,
Expand All @@ -139,8 +144,10 @@ export class TermWrap {
this.lastUpdated = Date.now();
this.promptMarkers = [];
this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
this.currentCwdAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
this.claudeCodeActiveAtom = jotai.atom(false);
this.contentHeightRows = 0;
this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
this.terminal = new Terminal(options);
this.fitAddon = new FitAddon();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -508,6 +517,7 @@ export class TermWrap {
this.dataBytesProcessed += data.length;
}
this.lastUpdated = Date.now();
this.syncQuickTerminalHeight_debounced();
resolve();
});
return prtn;
Expand Down Expand Up @@ -575,13 +585,31 @@ 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;
this.resyncController("initial resize");
}
}

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;
Expand Down
Loading