Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
83 changes: 83 additions & 0 deletions frontend/app/store/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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 +571,32 @@ 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 liveCwdAtom = (blockComponentModel?.viewModel as any)?.termRef?.current?.currentCwdAtom as
| PrimitiveAtom<string | null>
| undefined;
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 = (blockComponentModel?.viewModel as any)?.shellProcFullStatus as
| PrimitiveAtom<BlockControllerRuntimeStatus>
| undefined;
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 +700,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 +764,7 @@ export {
getAllBlockComponentModels,
getApi,
getBlockComponentModel,
getInheritedContextFromBlock,
getBlockMetaKeyAtom,
getBlockTermDurableAtom,
getTabMetaKeyAtom,
Expand Down Expand Up @@ -715,6 +797,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