Skip to content

Commit 7b5baad

Browse files
committed
feat: add quick terminal float window with double-ESC trigger
- Implement quick terminal float window feature triggered by double-ESC - Auto-inherit cwd and connection context from focused block - Adaptive height: starts at 10% window, grows with content up to 50% - Position strategy: uses source block width or falls back to layout width - Add quickTerminalAtom for state management (visible/blockId/opening/closing) - ESC key handling: single ESC dismisses,透传 vdom mode escape to global - OSC 7 enhancement: track current working directory to currentCwdAtom - Add ephemeral 'quick-terminal' node type with dynamic height calculation Closes wavetermdev#3194
1 parent 9f41b57 commit 7b5baad

9 files changed

Lines changed: 293 additions & 18 deletions

File tree

frontend/app/store/global-atoms.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
126126
});
127127
const reinitVersion = atom(0);
128128
const rateLimitInfoAtom = atom(null) as PrimitiveAtom<RateLimitInfo>;
129+
const quickTerminalAtom = atom({
130+
visible: false,
131+
blockId: null as string | null,
132+
opening: false,
133+
closing: false,
134+
}) as PrimitiveAtom<{ visible: boolean; blockId: string | null; opening: boolean; closing: boolean }>;
129135
atoms = {
130136
// initialized in wave.ts (will not be null inside of application)
131137
builderId: builderIdAtom,
@@ -149,6 +155,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
149155
allConnStatus: allConnStatusAtom,
150156
reinitVersion,
151157
waveAIRateLimitInfoAtom: rateLimitInfoAtom,
158+
quickTerminalAtom,
152159
} as GlobalAtomsType;
153160
}
154161

frontend/app/store/global.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import * as WOS from "./wos";
3838
import { getFileSubject, waveEventSubscribeSingle } from "./wps";
3939

4040
let globalPrimaryTabStartup: boolean = false;
41+
const QuickTerminalInitialState = { visible: false, blockId: null as string | null, opening: false, closing: false };
4142

4243
function initGlobal(initOpts: GlobalInitOptions) {
4344
globalPrimaryTabStartup = initOpts.primaryTabStartup ?? false;
@@ -570,6 +571,32 @@ function getFocusedBlockId(): string {
570571
return focusedLayoutNode?.data?.blockId;
571572
}
572573

574+
function getInheritedContextFromBlock(blockId: string | null): { cwd: string | null; connection: string | null } {
575+
if (blockId == null) {
576+
return { cwd: null, connection: null };
577+
}
578+
579+
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", blockId));
580+
const blockData = globalStore.get(blockAtom);
581+
const blockComponentModel = getBlockComponentModel(blockId);
582+
const liveCwdAtom = (blockComponentModel?.viewModel as any)?.termRef?.current?.currentCwdAtom as
583+
| PrimitiveAtom<string | null>
584+
| undefined;
585+
const liveCwd = liveCwdAtom ? globalStore.get(liveCwdAtom) : null;
586+
const cwd = typeof liveCwd === "string" ? liveCwd : typeof blockData?.meta?.["cmd:cwd"] === "string" ? blockData.meta["cmd:cwd"] : null;
587+
588+
let connection = typeof blockData?.meta?.connection === "string" ? blockData.meta.connection : null;
589+
const shellProcFullStatusAtom = (blockComponentModel?.viewModel as any)?.shellProcFullStatus as
590+
| PrimitiveAtom<BlockControllerRuntimeStatus>
591+
| undefined;
592+
const runtimeStatus = shellProcFullStatusAtom ? globalStore.get(shellProcFullStatusAtom) : null;
593+
if (typeof runtimeStatus?.shellprocconnname === "string") {
594+
connection = runtimeStatus.shellprocconnname;
595+
}
596+
597+
return { cwd, connection };
598+
}
599+
573600
// pass null to refocus the currently focused block
574601
function refocusNode(blockId: string) {
575602
if (blockId == null) {
@@ -673,6 +700,60 @@ function recordTEvent(event: string, props?: TEventProps) {
673700
RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true });
674701
}
675702

703+
async function toggleQuickTerminal(): Promise<boolean> {
704+
const layoutModel = getLayoutModelForStaticTab();
705+
const quickTermState = globalStore.get(atoms.quickTerminalAtom);
706+
707+
if (quickTermState.opening || quickTermState.closing) {
708+
return true;
709+
}
710+
711+
if (quickTermState.visible && quickTermState.blockId) {
712+
// Dismiss: close the ephemeral node
713+
// Set closing flag to prevent race condition with double-ESC
714+
globalStore.set(atoms.quickTerminalAtom, { ...quickTermState, closing: true });
715+
const quickTerminalNode = layoutModel.getNodeByBlockId(quickTermState.blockId);
716+
if (quickTerminalNode != null) {
717+
await layoutModel.closeNode(quickTerminalNode.id);
718+
} else {
719+
await ObjectService.DeleteBlock(quickTermState.blockId);
720+
}
721+
globalStore.set(atoms.quickTerminalAtom, QuickTerminalInitialState);
722+
return true;
723+
}
724+
725+
// Summon: inherit connection info and current working directory from the focused block when possible.
726+
const focusedBlockId = getFocusedBlockId();
727+
const { cwd, connection } = getInheritedContextFromBlock(focusedBlockId);
728+
729+
// Create ephemeral terminal block with custom quick terminal sizing
730+
const blockDef: BlockDef = {
731+
meta: {
732+
view: "term",
733+
controller: "shell",
734+
...(connection != null && { connection }),
735+
...(cwd != null && { "cmd:cwd": cwd }),
736+
},
737+
};
738+
739+
globalStore.set(atoms.quickTerminalAtom, { ...QuickTerminalInitialState, opening: true });
740+
741+
let blockId: string | null = null;
742+
try {
743+
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
744+
blockId = await ObjectService.CreateBlock(blockDef, rtOpts);
745+
layoutModel.newQuickTerminalNode(blockId, focusedBlockId);
746+
globalStore.set(atoms.quickTerminalAtom, { visible: true, blockId, opening: false, closing: false });
747+
return true;
748+
} catch (error) {
749+
globalStore.set(atoms.quickTerminalAtom, QuickTerminalInitialState);
750+
if (blockId != null) {
751+
fireAndForget(() => ObjectService.DeleteBlock(blockId));
752+
}
753+
throw error;
754+
}
755+
}
756+
676757
export {
677758
atoms,
678759
createBlock,
@@ -683,6 +764,7 @@ export {
683764
getAllBlockComponentModels,
684765
getApi,
685766
getBlockComponentModel,
767+
getInheritedContextFromBlock,
686768
getBlockMetaKeyAtom,
687769
getBlockTermDurableAtom,
688770
getTabMetaKeyAtom,
@@ -715,6 +797,7 @@ export {
715797
setNodeFocus,
716798
setPlatform,
717799
subscribeToConnEvents,
800+
toggleQuickTerminal,
718801
unregisterBlockComponentModel,
719802
useBlockAtom,
720803
useBlockCache,

frontend/app/store/keymodel.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import {
1313
getApi,
1414
getBlockComponentModel,
1515
getFocusedBlockId,
16+
getInheritedContextFromBlock,
1617
getSettingsKeyAtom,
1718
globalStore,
1819
recordTEvent,
1920
refocusNode,
2021
replaceBlock,
22+
toggleQuickTerminal,
2123
WOS,
2224
} from "@/app/store/global";
2325
import { getActiveTabModel } from "@/app/store/tab-model";
@@ -42,6 +44,10 @@ let globalKeybindingsDisabled = false;
4244
let activeChord: string | null = null;
4345
let chordTimeout: NodeJS.Timeout = null;
4446

47+
// Quick terminal double-ESC tracking
48+
let lastEscapeTime: number = 0;
49+
const QUICK_TERM_DOUBLE_ESC_TIMEOUT = 300; // milliseconds
50+
4551
function resetChord() {
4652
activeChord = null;
4753
if (chordTimeout) {
@@ -361,15 +367,12 @@ function getDefaultNewBlockDef(): BlockDef {
361367
const layoutModel = getLayoutModelForStaticTab();
362368
const focusedNode = globalStore.get(layoutModel.focusedNode);
363369
if (focusedNode != null) {
364-
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId));
365-
const blockData = globalStore.get(blockAtom);
366-
if (blockData?.meta?.view == "term") {
367-
if (blockData?.meta?.["cmd:cwd"] != null) {
368-
termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"];
369-
}
370+
const { cwd, connection } = getInheritedContextFromBlock(focusedNode.data?.blockId);
371+
if (cwd != null) {
372+
termBlockDef.meta["cmd:cwd"] = cwd;
370373
}
371-
if (blockData?.meta?.connection != null) {
372-
termBlockDef.meta.connection = blockData.meta.connection;
374+
if (connection != null) {
375+
termBlockDef.meta.connection = connection;
373376
}
374377
}
375378
return termBlockDef;
@@ -726,6 +729,36 @@ function registerGlobalKeys() {
726729
}
727730
globalKeyMap.set("Cmd:f", activateSearch);
728731
globalKeyMap.set("Escape", () => {
732+
const now = Date.now();
733+
const quickTermState = globalStore.get(atoms.quickTerminalAtom);
734+
735+
// Handle quick terminal toggle on double-ESC
736+
if (quickTermState.visible) {
737+
// If quick terminal is open, single ESC dismisses it
738+
// Skip if already closing to prevent double-close
739+
if (!quickTermState.closing) {
740+
fireAndForget(() => toggleQuickTerminal());
741+
}
742+
lastEscapeTime = 0; // Reset to prevent stale double-ESC detection
743+
return true;
744+
}
745+
746+
if (quickTermState.opening || quickTermState.closing) {
747+
lastEscapeTime = 0;
748+
return true;
749+
}
750+
751+
// Check for double-ESC to summon quick terminal
752+
if (now - lastEscapeTime < QUICK_TERM_DOUBLE_ESC_TIMEOUT) {
753+
// Double ESC detected - summon quick terminal
754+
fireAndForget(() => toggleQuickTerminal());
755+
lastEscapeTime = 0; // Reset after handling
756+
return true;
757+
}
758+
759+
lastEscapeTime = now;
760+
761+
// Existing ESC behavior (modals, search)
729762
if (modalsModel.hasOpenModals()) {
730763
modalsModel.popModal();
731764
return true;

frontend/app/view/term/osc-handlers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export function handleOsc52Command(data: string, blockId: string, loaded: boolea
218218

219219
// for xterm handlers, we return true always because we "own" OSC 7.
220220
// even if it is invalid we dont want to propagate to other handlers
221-
export function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean {
221+
export function handleOsc7Command(data: string, blockId: string, loaded: boolean, termWrap: TermWrap): boolean {
222222
if (!loaded) {
223223
return true;
224224
}
@@ -261,6 +261,8 @@ export function handleOsc7Command(data: string, blockId: string, loaded: boolean
261261
return true;
262262
}
263263

264+
globalStore.set(termWrap.currentCwdAtom, pathPart);
265+
264266
setTimeout(() => {
265267
fireAndForget(async () => {
266268
await RpcApi.SetMetaCommand(TabRpcClient, {

frontend/app/view/term/term-model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,10 @@ export class TermViewModel implements ViewModel {
671671
}
672672
const blockData = globalStore.get(this.blockAtom);
673673
if (blockData.meta?.["term:mode"] == "vdom") {
674+
// Don't consume Escape key - let it propagate to global handler for quick terminal close
675+
if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
676+
return false;
677+
}
674678
const vdomModel = this.getVDomModel();
675679
return vdomModel?.keyDownHandler(waveEvent);
676680
}

frontend/app/view/term/termwrap.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getFileSubject } from "@/app/store/wps";
77
import { RpcApi } from "@/app/store/wshclientapi";
88
import { TabRpcClient } from "@/app/store/wshrpcutil";
99
import {
10+
atoms,
1011
fetchWaveFile,
1112
getApi,
1213
getOverrideConfigAtom,
@@ -16,6 +17,7 @@ import {
1617
openLink,
1718
WOS,
1819
} from "@/store/global";
20+
import { getLayoutModelForStaticTab } from "@/layout/index";
1921
import * as services from "@/store/services";
2022
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
2123
import { base64ToArray, fireAndForget } from "@/util/util";
@@ -99,8 +101,10 @@ export class TermWrap {
99101
lastUpdated: number;
100102
promptMarkers: TermTypes.IMarker[] = [];
101103
shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
104+
currentCwdAtom: jotai.PrimitiveAtom<string | null>;
102105
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
103106
claudeCodeActiveAtom: jotai.PrimitiveAtom<boolean>;
107+
contentHeightRows: number;
104108
nodeModel: BlockNodeModel; // this can be null
105109
hoveredLinkUri: string | null = null;
106110
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
@@ -120,6 +124,7 @@ export class TermWrap {
120124
lastMode2026ResetTs: number = 0;
121125
inSyncTransaction: boolean = false;
122126
inRepaintTransaction: boolean = false;
127+
syncQuickTerminalHeight_debounced: () => void;
123128

124129
constructor(
125130
tabId: string,
@@ -139,8 +144,10 @@ export class TermWrap {
139144
this.lastUpdated = Date.now();
140145
this.promptMarkers = [];
141146
this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
147+
this.currentCwdAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
142148
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
143149
this.claudeCodeActiveAtom = jotai.atom(false);
150+
this.contentHeightRows = 0;
144151
this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
145152
this.terminal = new Terminal(options);
146153
this.fitAddon = new FitAddon();
@@ -182,7 +189,7 @@ export class TermWrap {
182189
// Register OSC handlers
183190
this.terminal.parser.registerOscHandler(7, (data: string) => {
184191
try {
185-
return handleOsc7Command(data, this.blockId, this.loaded);
192+
return handleOsc7Command(data, this.blockId, this.loaded, this);
186193
} catch (e) {
187194
console.error("[termwrap] osc 7 handler error", this.blockId, e);
188195
return false;
@@ -280,6 +287,7 @@ export class TermWrap {
280287
this.mainFileSubject = null;
281288
this.heldData = [];
282289
this.handleResize_debounced = debounce(50, this.handleResize.bind(this));
290+
this.syncQuickTerminalHeight_debounced = debounce(16, this.syncQuickTerminalHeight.bind(this));
283291
this.terminal.open(this.connectElem);
284292

285293
const dragoverHandler = (e: DragEvent) => {
@@ -475,6 +483,7 @@ export class TermWrap {
475483
if (msg.fileop == "truncate") {
476484
this.terminal.clear();
477485
this.heldData = [];
486+
this.syncQuickTerminalHeight_debounced();
478487
} else if (msg.fileop == "append") {
479488
const decodedData = base64ToArray(msg.data64);
480489
if (this.loaded) {
@@ -508,6 +517,7 @@ export class TermWrap {
508517
this.dataBytesProcessed += data.length;
509518
}
510519
this.lastUpdated = Date.now();
520+
this.syncQuickTerminalHeight_debounced();
511521
resolve();
512522
});
513523
return prtn;
@@ -575,13 +585,31 @@ export class TermWrap {
575585
);
576586
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize });
577587
}
588+
this.syncQuickTerminalHeight_debounced();
578589
dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized);
579590
if (!this.hasResized) {
580591
this.hasResized = true;
581592
this.resyncController("initial resize");
582593
}
583594
}
584595

596+
private getContentHeightRows(): number {
597+
return Math.max(1, this.terminal.buffer.active.baseY + this.terminal.buffer.active.cursorY + 1);
598+
}
599+
600+
private syncQuickTerminalHeight() {
601+
const nextRows = this.getContentHeightRows();
602+
this.contentHeightRows = nextRows;
603+
604+
const quickTermState = globalStore.get(atoms.quickTerminalAtom);
605+
if (quickTermState.blockId !== this.blockId) {
606+
return;
607+
}
608+
609+
const layoutModel = getLayoutModelForStaticTab();
610+
layoutModel?.updateTree(false);
611+
}
612+
585613
processAndCacheData() {
586614
if (this.dataBytesProcessed < MinDataProcessedForCache) {
587615
return;

0 commit comments

Comments
 (0)