Skip to content

feat: add quick terminal float window with double-ESC trigger#3205

Open
PureLo wants to merge 1 commit intowavetermdev:mainfrom
PureLo:main
Open

feat: add quick terminal float window with double-ESC trigger#3205
PureLo wants to merge 1 commit intowavetermdev:mainfrom
PureLo:main

Conversation

@PureLo
Copy link
Copy Markdown

@PureLo PureLo commented Apr 10, 2026

Summary

Add Quick Terminal float window feature — a lightweight ephemeral terminal summoned via
double-ESC, designed for quick commands without disrupting your workflow.

Motivation

Users frequently need a temporary terminal to run a quick command while working in the main
terminal. Creating a new block or tab is disruptive. Quick Terminal provides instant access via a
familiar hotkey, with automatic context inheritance.

Key Use Case: Non-Disruptive Commands During Claude Code Sessions

When using Claude Code in the main terminal, you often need to run auxiliary commands (e.g., git status, ls, checking env vars) without:

  • Switching tabs or windows
  • Losing your conversation context
  • Waiting for Claude Code to complete its thinking

Quick Terminal lets you press double-ESC, run your command, then ESC to return — all without
interrupting Claude Code's state.

Changes

Core Feature: Quick Terminal Float Window

Component File Description
State frontend/app/store/global-atoms.ts New quickTerminalAtom for managing float window
state
Core Logic frontend/app/store/global.ts toggleQuickTerminal(),
getInheritedContextFromBlock()
Keybindings frontend/app/store/keymodel.ts Double-ESC detection (300ms threshold), context
inheritance
OSC Handler frontend/app/view/term/osc-handlers.ts Track cwd via OSC 7 to currentCwdAtom
ESC Passthrough frontend/app/view/term/term-model.ts VDOM mode ESC key passthrough to global
handler
Terminal Sync frontend/app/view/term/termwrap.ts currentCwdAtom, contentHeightRows,
syncQuickTerminalHeight()
Layout frontend/layout/lib/layoutModel.ts newQuickTerminalNode(),
updateQuickTerminalNodeProps()
Mock frontend/preview/mock/mockwaveenv.ts Test mock for quickTerminalAtom
Types frontend/types/custom.d.ts ephemeralType, quickTerminalSourceBlockId

Behavior

  • Summon: Press ESC twice within 300ms to open quick terminal
  • Dismiss: Single ESC while quick terminal is visible closes it
  • Context Inheritance: Automatically inherits cwd and connection from focused block
  • Adaptive Height: Starts at 10% window height, grows with content up to 50% max
  • Smart Positioning: Uses source block width with horizontal inset; falls back to layout width

Technical Details

  • State Machine: quickTerminalAtom manages 4 states: visible, blockId, opening,
    closing
  • Race Prevention: Opening/closing flags prevent double-toggle on rapid ESC presses
  • Ephemeral Design: Quick terminal is a layout ephemeral node, auto-deleted on close
  • Height Sync: Debounced 16ms sync via syncQuickTerminalHeight_debounced()

Test Plan

  • Double-ESC summons quick terminal from any context
  • Single ESC dismisses quick terminal when open
  • Quick terminal inherits cwd from focused terminal block
  • Quick terminal inherits connection from focused terminal block
  • Height adapts as content grows beyond initial 10%
  • Quick terminal closes cleanly without orphan blocks
  • VDOM mode (Claude Code) ESC keys still work correctly
  • Claude Code session remains uninterrupted when using quick terminal

Files Changed

frontend/app/store/global-atoms.ts | 7 ++
frontend/app/store/global.ts | 83 ++++++++++++++
frontend/app/store/keymodel.ts | 49 +++++++++---
frontend/app/view/term/osc-handlers.ts | 4 +-
frontend/app/view/term/term-model.ts | 4 ++
frontend/app/view/term/termwrap.ts | 30 ++++++-
frontend/layout/lib/layoutModel.ts |125 ++++++++++++++++++++++++++---
frontend/preview/mock/mockwaveenv.ts | 1 +
frontend/types/custom.d.ts | 8 +++
9 files changed, 293 insertions(+), 18 deletions(-)

- Implement quick terminal float window triggered by double-ESC (300ms threshold)
- Auto-inherit cwd and connection context from focused block
- Adaptive height: starts at 10% window, grows with content up to 50% max
- Position: uses source block width with horizontal inset, falls back to layout width
- Add quickTerminalAtom for state management (visible/blockId/opening/closing)
- ESC key: single ESC dismisses quick terminal, passthrough vdom mode escape to global
- OSC 7: track current working directory to currentCwdAtom for context inheritance
- Add ephemeral 'quick-terminal' node type with dynamic height calculation
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 10, 2026

Walkthrough

This pull request introduces a quick terminal feature by adding global state management, a toggle function that creates/dismisses an ephemeral terminal panel, double-ESC key binding support, and layout integration. The implementation spans new atoms (quickTerminalAtom), store logic for managing terminal creation with inherited context (CWD, connection), height synchronization for dynamic sizing, and a new ephemeral layout node type ("quick-terminal") with geometric positioning anchored to the top edge and dimensions derived from content and source block bounds.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add quick terminal float window with double-ESC trigger' directly and accurately summarizes the main feature addition across all changed files.
Description check ✅ Passed The description comprehensively relates to the changeset, explaining the motivation, key use cases, detailed changes across all affected files, behavior specifications, and test plans.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 10, 2026

CLA assistant check
All committers have signed the CLA.

@PureLo
Copy link
Copy Markdown
Author

PureLo commented Apr 10, 2026

image

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/app/store/keymodel.ts (1)

731-769: ⚠️ Potential issue | 🟠 Major

Don't arm double-ESC on UI-consumed Escape.

lastEscapeTime is set before the modal/search branches run, so an Escape that only closes a modal or dismisses search becomes the first half of a “double-ESC”. The next normal Escape within 300ms will summon the quick terminal unexpectedly. Only update the timer when the key is otherwise unhandled, and clear it on the modal/search paths.

Suggested fix
     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;
         }

+        if (modalsModel.hasOpenModals()) {
+            lastEscapeTime = 0;
+            modalsModel.popModal();
+            return true;
+        }
+        if (deactivateSearch()) {
+            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;
-        }
-        if (deactivateSearch()) {
-            return true;
-        }
         return false;
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/store/keymodel.ts` around lines 731 - 769, The Escape handler
currently sets lastEscapeTime too early, so UI-consumed Escapes (modal close via
modalsModel.popModal or search dismiss via deactivateSearch) arm the double-ESC
timer; change the flow in the globalKeyMap.set("Escape", ...) handler so
lastEscapeTime is only set when the key was not otherwise handled: 1) remove the
early lastEscapeTime = now assignment; 2) when a quick terminal is
visible/closing/opening keep/reset lastEscapeTime as already done; 3) when you
handle UI actions (modalsModel.popModal() or deactivateSearch()) explicitly
clear lastEscapeTime (set to 0) and return true; and 4) only after those checks,
if no UI handling occurred, set lastEscapeTime = now and then run the double-ESC
detection against QUICK_TERM_DOUBLE_ESC_TIMEOUT to summon toggleQuickTerminal().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/store/global.ts`:
- Around line 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.

In `@frontend/layout/lib/layoutModel.ts`:
- Around line 76-77: Remove the two unused constants QuickTerminalFallbackCols
and QuickTerminalFallbackCharWidthPx from layoutModel.ts to eliminate dead code;
locate the const declarations for QuickTerminalFallbackCols and
QuickTerminalFallbackCharWidthPx in layoutModel.ts (or wherever they appear) and
delete those lines, then run the project type-check/build and tests to confirm
nothing else references them and commit the change.

---

Outside diff comments:
In `@frontend/app/store/keymodel.ts`:
- Around line 731-769: The Escape handler currently sets lastEscapeTime too
early, so UI-consumed Escapes (modal close via modalsModel.popModal or search
dismiss via deactivateSearch) arm the double-ESC timer; change the flow in the
globalKeyMap.set("Escape", ...) handler so lastEscapeTime is only set when the
key was not otherwise handled: 1) remove the early lastEscapeTime = now
assignment; 2) when a quick terminal is visible/closing/opening keep/reset
lastEscapeTime as already done; 3) when you handle UI actions
(modalsModel.popModal() or deactivateSearch()) explicitly clear lastEscapeTime
(set to 0) and return true; and 4) only after those checks, if no UI handling
occurred, set lastEscapeTime = now and then run the double-ESC detection against
QUICK_TERM_DOUBLE_ESC_TIMEOUT to summon toggleQuickTerminal().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bf5e1fbe-9e98-40da-b13b-00ebcdd469f3

📥 Commits

Reviewing files that changed from the base of the PR and between 9f41b57 and bc069d5.

📒 Files selected for processing (9)
  • frontend/app/store/global-atoms.ts
  • frontend/app/store/global.ts
  • frontend/app/store/keymodel.ts
  • frontend/app/view/term/osc-handlers.ts
  • frontend/app/view/term/term-model.ts
  • frontend/app/view/term/termwrap.ts
  • frontend/layout/lib/layoutModel.ts
  • frontend/preview/mock/mockwaveenv.ts
  • frontend/types/custom.d.ts

Comment on lines +711 to +721
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);
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.

Comment on lines +76 to +77
const QuickTerminalFallbackCols = 80;
const QuickTerminalFallbackCharWidthPx = 8;
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for usage of these constants across the codebase
rg -n "QuickTerminalFallbackCols|QuickTerminalFallbackCharWidthPx" --type=ts

Repository: wavetermdev/waveterm

Length of output: 221


Remove unused constants to avoid dead code.

QuickTerminalFallbackCols and QuickTerminalFallbackCharWidthPx are defined but never used anywhere in the codebase. Remove them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/layout/lib/layoutModel.ts` around lines 76 - 77, Remove the two
unused constants QuickTerminalFallbackCols and QuickTerminalFallbackCharWidthPx
from layoutModel.ts to eliminate dead code; locate the const declarations for
QuickTerminalFallbackCols and QuickTerminalFallbackCharWidthPx in layoutModel.ts
(or wherever they appear) and delete those lines, then run the project
type-check/build and tests to confirm nothing else references them and commit
the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants