feat(FN-2612): merge fusion/fn-2612 (auto-resolved)

- test(FN-2612): complete Step 4 — cover shortcut helpers and panel interactions
- feat(FN-2612): complete Step 3 — style shortcut panel controls
- feat(FN-2612): complete Step 2 — add terminal shortcut panel UI
- feat(FN-2612): complete Step 1 — add control sequence helpers
This commit is contained in:
Fusion
2026-04-26 14:09:34 -07:00
committed by gsxdsm
parent 03a48ae9bb
commit e4d4a0165b
3 changed files with 328 additions and 3 deletions

View File

@@ -621,6 +621,61 @@
}
}
.terminal-shortcut-panel {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
max-height: calc(var(--space-2xl) + var(--space-xl) + var(--space-lg));
overflow-y: auto;
}
.terminal-shortcut-modifier-row {
display: flex;
align-items: center;
gap: var(--space-xs);
width: 100%;
margin-bottom: var(--space-xs);
}
.terminal-shortcut-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: calc(var(--space-xl) + var(--space-xs));
min-height: calc(var(--space-xl) + var(--space-xs));
padding: 0 var(--space-xs);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.terminal-shortcut-btn:hover {
background: var(--card-hover);
}
.terminal-shortcut-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.terminal-shortcut-btn--modifier {
min-width: calc(var(--space-xl) + var(--space-md) + var(--space-xs));
}
.terminal-shortcut-btn--modifier.is-active {
background: var(--in-progress);
color: var(--card);
border-color: var(--in-progress);
}
.terminal-status-bar {
display: flex;
align-items: center;
@@ -877,6 +932,17 @@
font-size: 11px;
}
.terminal-shortcut-panel {
max-height: calc(var(--space-2xl) + var(--space-2xl) + var(--space-2xl) + var(--space-xl));
}
.terminal-shortcut-btn,
.terminal-shortcut-btn--modifier {
min-width: calc(var(--space-xl) + var(--space-md));
min-height: calc(var(--space-xl) + var(--space-md));
font-size: 11px;
}
.terminal-font-size-btn {
min-width: calc(var(--space-xl) + var(--space-md));
min-height: calc(var(--space-xl) + var(--space-md));

View File

@@ -1,7 +1,15 @@
import "./TerminalModal.css";
import { useState, useEffect, useRef, useCallback } from "react";
import { getErrorMessage } from "@fusion/core";
import { X, Trash2, Terminal as TerminalIcon, RefreshCw, Minus, Plus } from "lucide-react";
import {
X,
Trash2,
Terminal as TerminalIcon,
RefreshCw,
Minus,
Plus,
Keyboard,
} from "lucide-react";
import { useTerminal } from "../hooks/useTerminal";
import { useTerminalSessions } from "../hooks/useTerminalSessions";
import "@xterm/xterm/css/xterm.css";
@@ -18,6 +26,48 @@ const DEFAULT_FONT_SIZE = 14;
const MIN_TERMINAL_FONT_SIZE = 8;
const MAX_TERMINAL_FONT_SIZE = 32;
export function ctrlChar(key: string): string {
if (!key) {
return "";
}
const normalized = key.slice(0, 1).toUpperCase();
if (normalized === "[") {
return "\x1b";
}
if (normalized >= "A" && normalized <= "Z") {
return String.fromCharCode(normalized.charCodeAt(0) - 64);
}
return key;
}
export function altChar(key: string): string {
return `\x1b${key}`;
}
interface ShortcutKey {
label: string;
key: string;
description?: string;
}
export const SHORTCUT_KEYS: ShortcutKey[] = [
{ label: "C", key: "c", description: "SigInt" },
{ label: "D", key: "d", description: "EOF" },
{ label: "Z", key: "z", description: "Suspend" },
{ label: "L", key: "l", description: "Clear" },
{ label: "R", key: "r", description: "Reverse search" },
{ label: "A", key: "a", description: "Home" },
{ label: "E", key: "e", description: "End" },
{ label: "U", key: "u", description: "Kill line" },
{ label: "K", key: "k", description: "Kill to EOL" },
{ label: "W", key: "w", description: "Del word" },
{ label: ".", key: ".", description: "Last argument" },
];
function clampTerminalFontSize(value: number): number {
return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value));
}
@@ -183,6 +233,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
const [fontSize, setFontSize] = useState<number>(() => readInitialTerminalFontSize());
const [showShortcuts, setShowShortcuts] = useState(false);
const [stickyModifier, setStickyModifier] = useState<null | "ctrl" | "alt">(null);
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -618,6 +670,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
hasInitialCommandRun.current = false;
setError(null);
setExitCode(null);
setShowShortcuts(false);
setStickyModifier(null);
}, [isOpen]);
// Subscribe to terminal data.
@@ -906,6 +960,37 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
refitTerminal();
}, [refitTerminal]);
const toggleModifier = useCallback((modifier: "ctrl" | "alt") => {
setStickyModifier((current) => (current === modifier ? null : modifier));
}, []);
const sendShortcutKey = useCallback(
(key: string) => {
if (stickyModifier === "ctrl") {
sendInput(ctrlChar(key));
setStickyModifier(null);
return;
}
if (stickyModifier === "alt") {
sendInput(altChar(key));
setStickyModifier(null);
return;
}
sendInput(key);
},
[sendInput, stickyModifier],
);
const sendLiteralShortcut = useCallback(
(value: string) => {
sendInput(value);
setStickyModifier(null);
},
[sendInput],
);
if (!isOpen) return null;
const getStatusIndicator = () => {
@@ -1035,6 +1120,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
<Trash2 size={14} />
<span className="terminal-action-label">Clear</span>
</button>
<button
className="terminal-clear-btn"
onClick={() => setShowShortcuts((current) => !current)}
data-testid="terminal-shortcut-toggle"
title="Shortcuts"
aria-pressed={showShortcuts}
>
<Keyboard size={14} />
<span className="terminal-action-label">Shortcuts</span>
</button>
<button
className="terminal-close"
onClick={onClose}
@@ -1129,6 +1224,60 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
/>
</div>
{showShortcuts && (
<div className="terminal-shortcut-panel" data-testid="terminal-shortcut-panel">
<div className="terminal-shortcut-modifier-row">
<button
type="button"
className={`terminal-shortcut-btn terminal-shortcut-btn--modifier ${
stickyModifier === "ctrl" ? "is-active" : ""
}`}
data-testid="terminal-modifier-ctrl"
onClick={() => toggleModifier("ctrl")}
aria-pressed={stickyModifier === "ctrl"}
>
Ctrl
</button>
<button
type="button"
className={`terminal-shortcut-btn terminal-shortcut-btn--modifier ${
stickyModifier === "alt" ? "is-active" : ""
}`}
data-testid="terminal-modifier-alt"
onClick={() => toggleModifier("alt")}
aria-pressed={stickyModifier === "alt"}
>
Alt
</button>
<button
type="button"
className="terminal-shortcut-btn"
onClick={() => sendLiteralShortcut("\x1b")}
>
ESC
</button>
<button
type="button"
className="terminal-shortcut-btn"
onClick={() => sendLiteralShortcut("\t")}
>
Tab
</button>
</div>
{SHORTCUT_KEYS.map((shortcut) => (
<button
key={shortcut.label}
type="button"
className="terminal-shortcut-btn"
onClick={() => sendShortcutKey(shortcut.key)}
title={shortcut.description}
>
{shortcut.label}
</button>
))}
</div>
)}
{/* Connection status bar */}
<div className="terminal-status-bar" data-testid="terminal-status-bar">
<span className={`terminal-connection-status ${connectionStatus}`}>
@@ -1166,7 +1315,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
</button>
</span>
<span className="terminal-shortcuts">
Ctrl++/- zoom Ctrl+L clear Esc close
Ctrl++/- zoom Shortcuts panel Esc close
</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TerminalModal, _resetInitialViewportHeight } from "../TerminalModal";
import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar } from "../TerminalModal";
import * as useTerminalModule from "../../hooks/useTerminal";
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
import * as apiModule from "../../api";
@@ -64,6 +64,18 @@ const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
const TERMINAL_FONT_SIZE_KEY = "kb-terminal-font-size";
describe("ctrlChar/altChar helpers", () => {
it("maps Ctrl+C/D/Z/L and Alt sequences correctly", () => {
expect(ctrlChar("c")).toBe("\x03");
expect(ctrlChar("d")).toBe("\x04");
expect(ctrlChar("z")).toBe("\x1a");
expect(ctrlChar("l")).toBe("\x0c");
expect(altChar("c")).toBe("\x1bc");
expect(altChar("[")).toBe("\x1b[");
});
});
// Default tab state
const defaultTab = {
id: "tab-1",
@@ -519,6 +531,104 @@ describe("TerminalModal", () => {
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
});
describe("shortcut panel", () => {
it("is hidden by default and toggles from header action", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.queryByTestId("terminal-shortcut-panel")).toBeNull();
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
expect(screen.getByTestId("terminal-shortcut-panel")).toBeTruthy();
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
expect(screen.queryByTestId("terminal-shortcut-panel")).toBeNull();
});
it("supports sticky modifier toggle semantics", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
const ctrlBtn = screen.getByTestId("terminal-modifier-ctrl");
const altBtn = screen.getByTestId("terminal-modifier-alt");
fireEvent.click(ctrlBtn);
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(ctrlBtn);
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(ctrlBtn);
fireEvent.click(altBtn);
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
expect(altBtn.getAttribute("aria-pressed")).toBe("true");
});
it("sends modified and literal keys, then clears sticky modifier", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
const ctrlBtn = screen.getByTestId("terminal-modifier-ctrl");
const altBtn = screen.getByTestId("terminal-modifier-alt");
fireEvent.click(ctrlBtn);
fireEvent.click(screen.getByRole("button", { name: "C" }));
expect(mockSendInput).toHaveBeenCalledWith("\x03");
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(altBtn);
fireEvent.click(screen.getByRole("button", { name: "D" }));
expect(mockSendInput).toHaveBeenCalledWith("\x1bd");
expect(altBtn.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(screen.getByRole("button", { name: "Z" }));
expect(mockSendInput).toHaveBeenCalledWith("z");
fireEvent.click(screen.getByRole("button", { name: "ESC" }));
expect(mockSendInput).toHaveBeenCalledWith("\x1b");
fireEvent.click(screen.getByRole("button", { name: "Tab" }));
expect(mockSendInput).toHaveBeenCalledWith("\t");
});
it("renders shortcut controls on mobile viewport", async () => {
const previousInnerWidth = window.innerWidth;
const previousOntouchstart = window.ontouchstart;
Object.defineProperty(window, "innerWidth", {
value: 375,
writable: true,
configurable: true,
});
Object.defineProperty(window, "ontouchstart", {
value: null,
writable: true,
configurable: true,
});
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
expect(screen.getByTestId("terminal-shortcut-panel")).toBeTruthy();
expect(screen.getByTestId("terminal-modifier-ctrl")).toBeTruthy();
expect(screen.getByTestId("terminal-modifier-alt")).toBeTruthy();
} finally {
Object.defineProperty(window, "innerWidth", {
value: previousInnerWidth,
writable: true,
configurable: true,
});
Object.defineProperty(window, "ontouchstart", {
value: previousOntouchstart,
writable: true,
configurable: true,
});
}
});
});
describe("font size controls", () => {
it("renders controls in the status bar with default font-size value", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);