Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -793,8 +793,16 @@ function App() {

return (
<div
// Frameless window (decorations: false, transparent: true): the pixels
// outside the rounded shape composite against the desktop, so an OUTER
// 1px hairline (`0 0 0 1px`) landed in the transparent slice around
// each corner and vanished against a dark desktop - only the top-left
// stayed visible because the sidebar/titlebar chrome painted a bright
// top edge inside it. `inset 0 0 0 1px` moves the hairline INSIDE the
// opaque `--canvas` fill so every corner gets an equal, visible edge
// in dark and light themes. See #60.
className="app-root h-screen w-screen flex flex-col overflow-hidden rounded-[10px]"
style={{ boxShadow: '0 0 0 1px var(--seam-strong)' }}
style={{ boxShadow: 'inset 0 0 0 1px var(--seam-strong)' }}
>
{/* Launch splash - logo reveal video + loading bar, main window only. */}
<AnimatePresence>
Expand Down
14 changes: 14 additions & 0 deletions src/components/NewTerminalModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,20 @@ export function NewTerminalModal() {
}

closeNewTerminalModal();

// Move focus into the newly-created terminal's xterm. Without this the
// browser returns focus to the sidebar's New Session button (the trigger
// that opened this modal), and a subsequent Enter press re-fires that
// button as a synthetic click, re-opening the modal instead of reaching
// the terminal. See #58. TerminalTabs also has a rAF focus effect keyed
// on activeTerminalId, but the effect runs BEFORE the terminal's canvas
// is fully mounted on first render - so mirror the focus here after two
// rAFs to make it stick.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
useTerminalStore.getState().terminals.get(newTerminalId)?.xterm?.focus();
});
});
} catch (err) {
setError(String(err));
reportInvokeFailure('create_terminal', err);
Expand Down
12 changes: 11 additions & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,17 @@ export function Sidebar() {
{/* Prominent primary action + settings */}
<div className="p-2 flex flex-col gap-1.5">
<button
onClick={() => openNewTerminalModal()}
onClick={(e) => {
// Blur the trigger BEFORE the modal opens - without this, the modal's
// focus trap releases back to this button on close, and if the user
// then presses Enter (expecting it to reach the newly-created
// terminal) the browser dispatches a synthetic click and re-opens
// the modal. Focus travels through the modal's own auto-focus and
// eventually into xterm; the blur just makes sure the button isn't
// the default target if some path skips that. See #58.
e.currentTarget.blur();
openNewTerminalModal();
}}
className="w-full h-10 rounded-xl bg-accent-primary text-white text-[13px] font-semibold flex items-center justify-center gap-2 shadow-[0_4px_12px_var(--accent-glow-md)] hover:bg-accent-secondary active:scale-[0.98] transition-[background-color,transform] duration-100"
>
<Plus size={15} strokeWidth={2.5} />
Expand Down
35 changes: 33 additions & 2 deletions src/components/TerminalTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useMemo, useCallback, useEffect } from 'react';
import { X, Grid3X3, SplitSquareHorizontal, RotateCw, GitBranch, File as FileIcon } from 'lucide-react';
import { useTerminalStore } from '../store/terminalStore';
import { useAppStore } from '../store/appStore';
import { toast } from '../store/toastStore';
import { reportInvokeFailure } from '../lib/errorReporter';
import { TerminalView } from './TerminalView';
import { TerminalGrid } from './TerminalGrid';
import { SplitView } from './SplitView';
Expand Down Expand Up @@ -42,7 +44,7 @@ function formatCost(usd: number): string {
* the whole area when active.
*/
export function TerminalTabs() {
const { terminals, activeTerminalId, scriptChildren, closeScript } = useTerminalStore();
const { terminals, activeTerminalId, scriptChildren, closeScript, closeTerminal } = useTerminalStore();
const { gridMode, toggleGridMode, gridTerminalIds, splitMode, splitTerminalIds, splitOrientation, splitRatio, setSplitOrientation, setSplitRatio, clearSplit, openFiles, activeFilePath, setActiveFilePath, closeFileTab, showFileTree, showTabActivity } = useAppStore();
const now = useNowTick();
const terminalStates = useTerminalStore((s) => s.terminalStates);
Expand All @@ -53,6 +55,19 @@ export function TerminalTabs() {
setActiveFilePath(path);
}, [setActiveFilePath]);

// Close the active session from the header (#59). Mirrors the sidebar
// SessionCards' `closeWithReport` so error handling + telemetry stay
// consistent with the existing close paths (per CLAUDE.md's frontend
// error-handling rules).
const closeActiveSession = useCallback(() => {
const id = activeTerminalId;
if (!id) return;
closeTerminal(id).catch((err) => {
toast.error('Close failed', 'Could not close the session.');
reportInvokeFailure('close_terminal', err);
});
}, [activeTerminalId, closeTerminal]);

// Script-child terminals are rendered below their parent and bottom-pane
// shells are rendered in BottomTerminalPane - neither belongs in the main
// content stack.
Expand Down Expand Up @@ -267,7 +282,7 @@ export function TerminalTabs() {
</>
)}

{/* Right cluster: project scripts + grid toggle */}
{/* Right cluster: project scripts + grid toggle + close active */}
<div className="ml-auto flex items-center gap-1 flex-shrink-0">
{showFileTree && activeTerminalId && !activeFilePath && (() => {
const inst = terminals.get(activeTerminalId);
Expand All @@ -292,6 +307,22 @@ export function TerminalTabs() {
<span className="hidden sm:inline">Grid</span>
</button>
</Tooltip>
{/* Close active session (#59). Only visible while a real terminal
is focused - hidden while browsing a file, and hidden entirely
when there is no active session. Mirrors the sidebar card X,
which also closes without a confirm. */}
{activeConfig && !activeFilePath && (
<Tooltip label="Close Session">
<button
onClick={closeActiveSession}
aria-label="Close Session"
aria-keyshortcuts="Control+W"
className="flex items-center justify-center h-7 w-7 rounded-lg text-[11.5px] font-medium hover:bg-fill-hover text-text-secondary hover:text-text-primary transition-colors"
>
<X size={13} strokeWidth={1.75} />
</button>
</Tooltip>
)}
</div>
</div>
)}
Expand Down
36 changes: 21 additions & 15 deletions src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { reportInvokeFailure } from '../lib/errorReporter';
import { classifyPasteInput } from '../lib/pasteWarning';
import { toBracketedPaste } from '../lib/bracketedPaste';
import { decideCtrlC } from '../lib/ctrlCAction';
import { matchesKeyCode } from '../lib/keymap';
import { resolveAppTheme, prefersLightScheme } from '../lib/appTheme';
import { isVisibilityHidden } from '../utils/dragDrop';
import { TerminalSearch } from './TerminalSearch';
Expand Down Expand Up @@ -298,25 +299,29 @@ export function TerminalView({ terminalId }: TerminalViewProps) {
container.addEventListener('mousedown', swallowRightButton, true);
container.addEventListener('mouseup', swallowRightButton, true);

// Handle Ctrl+C (copy) and Ctrl+V (paste) keyboard shortcuts
// Handle Ctrl+C (copy) and Ctrl+V (paste) keyboard shortcuts.
//
// Letter accelerators use `matchesKeyCode` (from lib/keymap.ts) so they
// still fire under non-Latin keyboard layouts (Hebrew/Cyrillic/Arabic/...).
// `KeyboardEvent.key` returns the LOCALIZED character produced by the
// physical key - on Hebrew, physical V yields `e.key === 'ה'`, so a
// `key === 'v'` comparison never matched, quietly breaking every Ctrl+letter
// shortcut for those users (see #57). `KeyboardEvent.code` reports the
// physical key position (`'KeyV'`) regardless of layout, which is what we
// want. CapsLock no longer needs a `.toLowerCase()` guard for the same
// reason: `e.code` is case-invariant.
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey;
// Normalize the key so CapsLock doesn't break these shortcuts: with
// CapsLock on, an unshifted letter arrives as uppercase (e.g. 'V'), which
// would otherwise miss the lowercase comparisons below and fall through to
// xterm's raw control byte (no paste/copy). Shift is still distinguished
// via e.shiftKey, so Ctrl+Shift+V stays handled by the global handler.
const key = e.key.toLowerCase();

// Ctrl+F: Toggle in-terminal search (Ctrl+Shift+F is reserved for the
// global file/content search - see useKeyboardShortcuts).
if (isCtrl && !e.shiftKey && key === 'f' && e.type === 'keydown') {
if (isCtrl && !e.shiftKey && matchesKeyCode(e, 'F') && e.type === 'keydown') {
e.preventDefault();
toggleSearch();
return false;
}

if (isCtrl && !e.shiftKey && key === 'c' && e.type === 'keydown') {
if (isCtrl && !e.shiftKey && matchesKeyCode(e, 'C') && e.type === 'keydown') {
const action = decideCtrlC({
hasSelection: terminal.hasSelection(),
copyOnSelect: useAppStore.getState().terminalCopyOnSelect,
Expand All @@ -343,9 +348,9 @@ export function TerminalView({ terminalId }: TerminalViewProps) {
// Ctrl+V control byte (0x16) to the program. Pasting
// is then done via right-click or Ctrl+Shift+V
// ("Paste as file"), which is unaffected here.
// Ctrl+Shift+V (e.key === 'V') is intentionally not matched - it's
// handled by the global shortcut handler.
if (isCtrl && !e.shiftKey && key === 'v') {
// Ctrl+Shift+V is intentionally not matched here - it's handled by the
// global shortcut handler.
if (isCtrl && !e.shiftKey && matchesKeyCode(e, 'V')) {
if (useAppStore.getState().terminalPasteShortcut === 'ctrl+v') {
return false;
}
Expand All @@ -362,7 +367,7 @@ export function TerminalView({ terminalId }: TerminalViewProps) {
// actual undo binding. Claude Code binds undo to Ctrl+_ (byte 0x1f), NOT
// Ctrl+Z - a raw 0x1a is SIGTSTP/suspend and does nothing useful in the
// prompt. So we send 0x1f. (Also prevents any browser-level undo.)
if (isCtrl && !e.shiftKey && key === 'z') {
if (isCtrl && !e.shiftKey && matchesKeyCode(e, 'Z')) {
if (e.type === 'keydown') {
e.preventDefault();
writeToTerminal(terminalId, '\x1f').catch((err) => {
Expand All @@ -383,8 +388,9 @@ export function TerminalView({ terminalId }: TerminalViewProps) {
// not cancel) from leaking a stray \x1b[Z into the PTY.
//
// Plain Tab is deliberately untouched - Claude Code needs it for
// autocomplete and mode switching.
if (isCtrl && key === 'tab') {
// autocomplete and mode switching. `e.key === 'Tab'` is safe across
// layouts (Tab isn't remapped by non-Latin layouts).
if (isCtrl && e.key === 'Tab') {
return false;
}

Expand Down
7 changes: 6 additions & 1 deletion src/components/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@ export function TitleBar() {

{branchMenuOpen && (
<div
className="absolute left-0 top-full mt-1 z-50 w-[260px] material-popover ct-pop-in rounded-lg overflow-hidden"
// Flat New UI popover (#61): opaque `--elevation-4` surface
// with a plain 1px `--seam-strong` hairline and a standard
// float shadow - no `backdrop-filter`, no `edge-light` rim.
// Matches the flat treatment CLAUDE.md documents for the
// IntelliJ 2026.1 language.
className="absolute left-0 top-full mt-1 z-50 w-[260px] bg-elevation-4 ring-1 ring-inset ring-seam-strong shadow-elevation-3 ct-pop-in rounded-lg overflow-hidden"
style={{ transformOrigin: 'top left' }}
>
<div className="p-2 border-b border-seam">
Expand Down
42 changes: 26 additions & 16 deletions src/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { captureClaudeInput } from '../lib/terminalInput';
import { reportInvokeFailure } from '../lib/errorReporter';
import { readClipboardText } from '../lib/clipboard';
import { cyclableTabIds, nextTabId } from '../lib/tabCycle';
import { matchesKeyCode } from '../lib/keymap';

/**
* Return true when the focused element is an editable surface that is NOT
Expand Down Expand Up @@ -63,7 +64,11 @@ export function useKeyboardShortcuts() {
// top-level document, which throws away all open terminals (they
// aren't persisted). If the preview panel is open, route to the
// preview reload instead.
if (e.key === 'F5' || (ctrl && !shift && (e.key === 'r' || e.key === 'R'))) {
//
// Letter accelerators use `matchesKeyCode` so they still fire on
// non-Latin layouts (Hebrew/Russian/Arabic/...), where `e.key` would
// hold the localized character instead of 'r'. See lib/keymap.ts.
if (e.key === 'F5' || (ctrl && !shift && matchesKeyCode(e, 'R'))) {
e.preventDefault();
const previewOpen = usePreviewStore.getState().globalOpen;
const activeId = activeIdRef.current;
Expand All @@ -73,27 +78,27 @@ export function useKeyboardShortcuts() {
return;
}

if (ctrl && shift && e.key === 'N') {
if (ctrl && shift && matchesKeyCode(e, 'N')) {
e.preventDefault();
useAppStore.getState().openNewTerminalModal();
}

// Command Palette: Ctrl+P
if (ctrl && e.key === 'p') {
if (ctrl && !shift && matchesKeyCode(e, 'P')) {
e.preventDefault();
useAppStore.getState().toggleCommandPalette();
}

// Snippets: Ctrl+Shift+S
if (ctrl && shift && e.key === 'S') {
if (ctrl && shift && matchesKeyCode(e, 'S')) {
e.preventDefault();
useAppStore.getState().openSnippetsModal();
}

// Prompt Editor: Ctrl+Shift+E - compose a prompt for the active terminal,
// seeded with whatever is already typed in its input line. Can be turned
// off in Settings (the status-bar pencil still works).
if (ctrl && shift && e.key === 'E' && useAppStore.getState().promptEditorShortcutEnabled) {
if (ctrl && shift && matchesKeyCode(e, 'E') && useAppStore.getState().promptEditorShortcutEnabled) {
e.preventDefault();
const activeId = activeIdRef.current;
const term = activeId ? terminalsRef.current.get(activeId)?.xterm : undefined;
Expand All @@ -102,7 +107,7 @@ export function useKeyboardShortcuts() {
}

// Paste as file: Ctrl+Shift+V
if (ctrl && shift && e.key === 'V') {
if (ctrl && shift && matchesKeyCode(e, 'V')) {
e.preventDefault();
const activeId = activeIdRef.current;
(async () => {
Expand All @@ -116,7 +121,7 @@ export function useKeyboardShortcuts() {
}

// Preview panel toggle: Ctrl+Alt+P (Ctrl+Shift+V and Ctrl+Shift+G are taken).
if (ctrl && e.altKey && !shift && (e.key === 'p' || e.key === 'P')) {
if (ctrl && e.altKey && !shift && matchesKeyCode(e, 'P')) {
e.preventDefault();
usePreviewStore.getState().toggleGlobal();
}
Expand All @@ -142,17 +147,17 @@ export function useKeyboardShortcuts() {
}

// Global file/content search (VS Code style): Ctrl+Shift+F
if (ctrl && shift && e.key === 'F') {
if (ctrl && shift && matchesKeyCode(e, 'F')) {
e.preventDefault();
useAppStore.getState().toggleGlobalSearch();
}

if (ctrl && e.key === 'b') {
if (ctrl && !shift && matchesKeyCode(e, 'B')) {
e.preventDefault();
useAppStore.getState().toggleSidebar();
}

if (ctrl && e.key === 'w') {
if (ctrl && !shift && matchesKeyCode(e, 'W')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
Expand All @@ -164,7 +169,7 @@ export function useKeyboardShortcuts() {
}

// Duplicate active terminal: Ctrl+Shift+D
if (ctrl && shift && e.key === 'D') {
if (ctrl && shift && matchesKeyCode(e, 'D')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
Expand Down Expand Up @@ -242,13 +247,13 @@ export function useKeyboardShortcuts() {
}

// Toggle Grid Mode: Ctrl+G
if (ctrl && e.key === 'g') {
if (ctrl && !shift && matchesKeyCode(e, 'G')) {
e.preventDefault();
useAppStore.getState().toggleGridMode();
}

// Push modal: Ctrl+Shift+K (IntelliJ parity)
if (ctrl && shift && e.key === 'K') {
if (ctrl && shift && matchesKeyCode(e, 'K')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (!activeId) {
Expand All @@ -270,7 +275,7 @@ export function useKeyboardShortcuts() {
}

// Worktree Modal: Ctrl+Shift+W
if (ctrl && shift && e.key === 'W') {
if (ctrl && shift && matchesKeyCode(e, 'W')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
Expand All @@ -286,7 +291,7 @@ export function useKeyboardShortcuts() {
}

// Add current terminal to grid: Ctrl+Shift+G
if (ctrl && shift && e.key === 'G') {
if (ctrl && shift && matchesKeyCode(e, 'G')) {
e.preventDefault();
const activeId = activeIdRef.current;
if (activeId) {
Expand Down Expand Up @@ -319,7 +324,12 @@ export function useKeyboardShortcuts() {
// Terminal font zoom. Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0.
// Skip when the user is in a non-terminal editable surface so that
// e.g. Ctrl+- in a Settings input still selects characters natively.
if (ctrl && !shift && (e.key === '=' || e.key === '-' || e.key === '0')) {
//
// Digit 0 uses `matchesKeyCode` so Ctrl+0 zooms-reset even under a layout
// that remaps the digit row. `=` and `-` stay on `e.key` because their
// physical location varies across layouts (US Equal vs. AZERTY), but
// `e.key` reports the intended character reliably.
if (ctrl && !shift && (e.key === '=' || e.key === '-' || matchesKeyCode(e, '0'))) {
if (isFocusInNonTerminalEditable()) return;
e.preventDefault();
const { terminalFontSize, setTerminalFontSize } = useAppStore.getState();
Expand Down
Loading