Files
fusion/packages/dashboard/app/components/TerminalModal.css
gsxdsm b450dd493d FN-7282: fix mobile terminal workspace menu
Keep the terminal workspace picker visible and usable on narrow mobile screens.

- Portal the workspace listbox to the viewport and position it from the trigger with visual viewport bounds.
- Constrain the menu width and height with scroll-safe mobile CSS while preserving workspace selection actions.
- Cover mobile rendering, loading/error picker behavior, and document the viewport-safe menu expectation.

Files changed:
 .../fn-7282-mobile-terminal-worktree-menu.md       |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/TerminalModal.css     |  21 +++--
 .../dashboard/app/components/TerminalModal.tsx     | 101 ++++++++++++++++++++-
 .../components/__tests__/TerminalModal.test.tsx    |  72 ++++++++++++++-
 5 files changed, 186 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7282

Fusion-Task-Lineage: ca6f7b2b-2d04-41c3-b2eb-d3195e3a1ff5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-30 09:13:48 -07:00

1538 lines
41 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
FNXC:Terminal 2026-06-13-20:02:
The symbols-only Nerd Font must stay unicode-range-scoped to Nerd Font codepoints only; otherwise mobile DOM/canvas xterm can measure ASCII cells against the symbols font after font-display: swap and render commands like `pnpm build` with oversized inter-character spacing.
FNXC:Terminal 2026-06-17-18:12:
FN-6603 found that unicode-range scoping is not enough when the symbols face is first: iOS canvas measurement can still use that first face for xterm cell metrics while DOM glyph fallback draws ASCII from a later monospace font. Keep text fonts first in terminalPreferences and this symbols face as a fallback so powerline glyphs remain available without corrupting ASCII cell width.
FNXC:Terminal 2026-06-20-18:04:
FN-6811 recurrence #6 tightened ownership of this scoped symbols face: every terminal surface that exposes the DOM glyph fallback must define the same ASCII-excluding @font-face in its own CSS chunk, and tests must check each CSS owner independently rather than relying on combined app CSS.
*/
@font-face {
font-family: "Fusion Terminal Nerd Font Symbols";
src: url("/fonts/SymbolsNerdFontMono-Regular.ttf") format("truetype");
font-display: swap;
unicode-range: U+23FB-23FE, U+2665, U+26A1, U+2B58, U+E000-E00A, U+E0A0-E0D7, U+E200-E2A9, U+E300-E3E3, U+E5FA-E6B7, U+E700-E8EF, U+EA60-EC1E, U+ED00-F2FF, U+F300-F533, U+F0001-F1AF0;
}
/* === Terminal Modal === */
/* Center vertically (override the overlay's default top alignment). */
.modal-overlay:has(.terminal-modal) {
align-items: center;
padding-top: 0;
padding-bottom: 0;
}
/*
FNXC:Terminal 2026-06-21-22:32:
FN-6887 turns desktop/tablet terminal into a bottom-docked panel above the footer. Keep mobile on the fullscreen modal path through the max-width media override below.
FNXC:Terminal 2026-06-22-00:00:
The docked/floating terminal must not blur or dim the page behind it, and the page must stay interactive. The base .modal-overlay applies backdrop-filter: blur(4px); override it to none here. background is already transparent and pointer-events:none lets clicks pass through to the page behind (the terminal panel itself re-enables pointer-events).
*/
/*
FNXC:Terminal 2026-06-22-00:45:
The override MUST out-specify the base `.modal-overlay` (which sets a dimmed background + blur). Both are single-class selectors, so if styles.css loads after this file the dim/blur wins and the page still fades. Qualify with `.modal-overlay` (two classes) so the docked/floating terminal reliably keeps a transparent, non-blurring, click-through backdrop regardless of stylesheet order.
*/
/*
FNXC:Terminal 2026-06-23-04:30:
The terminal must NEVER dim or blur the page behind it, in ANY mode. The base .modal-overlay applies a dimmed background + blur; the docked/floating overrides killed it, but a terminal that is NEITHER (the mobile/default sheet) still fell back to the dim. This base terminal-overlay rule removes the dim+blur for every terminal state.
FNXC:Terminal 2026-06-23-21:28:
Theme-level modal backdrop rules can load after component CSS and reapply dim/blur, especially in glass themes. Use a higher-specificity terminal overlay selector and clear both standard and WebKit backdrop filters so docked and modal terminal surfaces never darken the app behind them.
*/
.modal-overlay.terminal-modal-overlay.terminal-modal-overlay {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.modal-overlay.terminal-modal-overlay--docked.terminal-modal-overlay--docked,
.modal-overlay.terminal-modal-overlay--floating.terminal-modal-overlay--floating {
align-items: stretch;
justify-content: flex-end;
padding: 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
pointer-events: none;
}
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Only the FLOATING terminal joins the shared cross-type floating stack. Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context; the floating panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the right-dock pop-out, the floating New Task dialog, and FloatingWindow. Docked mode keeps the base overlay stacking (full-width bottom panel) and is intentionally excluded.
*/
.modal-overlay.terminal-modal-overlay--floating {
z-index: auto;
}
.modal.terminal-modal {
/* Initial dimensions are applied only when no persisted size has been
restored — see :not([style*=...]) selectors below. */
min-width: 480px;
min-height: 320px;
max-width: calc(100vw - (var(--space-xl) * 2));
max-height: calc(100dvh - 40px);
background: var(--card);
display: flex;
flex-direction: column;
overflow: hidden;
resize: both;
}
.modal.terminal-modal:not([style*="width"]) {
width: min(1800px, calc(100vw - (var(--space-xl) * 2)));
}
.modal.terminal-modal:not([style*="height"]) {
height: min(85vh, calc(100dvh - 40px));
}
.modal.terminal-modal.terminal-modal--docked {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--icb-bottom-offset, 0px) + var(--executor-footer-height, calc(var(--space-xl) + var(--space-md))));
width: 100vw;
min-width: 0;
height: var(--terminal-docked-height);
min-height: calc(var(--space-xl) * 10);
max-width: none;
max-height: calc(100dvh - (var(--space-xl) * 4));
resize: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
pointer-events: auto;
box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
/*
FNXC:Terminal 2026-06-22-01:30:
Larger grab target for the docked terminal top resize handle: it straddles the panel's top edge (extends above it) and is taller, so it is easy to grab on desktop and touch. touch-action:none keeps the drag from being hijacked by scroll/gestures so the resize stays smooth.
*/
.terminal-docked-resize-handle {
position: absolute;
top: calc(var(--space-sm) * -1);
left: 0;
right: 0;
height: calc(var(--space-md) + var(--space-sm));
cursor: ns-resize;
touch-action: none;
z-index: 2;
}
.terminal-docked-resize-handle::before {
content: "";
position: absolute;
left: 50%;
top: calc(var(--space-xs) / 2);
width: calc(var(--space-xl) * 2);
height: calc(var(--space-xs) / 2);
transform: translateX(-50%);
border-radius: var(--radius-pill);
background: var(--border);
}
.modal.terminal-modal.terminal-modal--floating {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
left: var(--terminal-float-x);
top: var(--terminal-float-y);
width: var(--terminal-float-width);
height: var(--terminal-float-height);
min-width: calc(var(--space-xl) * 20);
min-height: calc(var(--space-xl) * 13.333);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
/*
FNXC:FloatingWindow 2026-06-23-23:32:
Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across Terminal, New Task, Right Dock, and shared FloatingWindow panels.
*/
box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
/*
FNXC:Terminal 2026-06-22-19:50:
The floating-mode header is the move grip. `touch-action: none` is required so a touch-drag on it is delivered as a continuous pointermove stream (paired with setPointerCapture on the captured element) instead of being hijacked by the browser into page scroll/pan. Without it the floating drag stutters on touch — same fix the right-dock pop-out drag handle uses. cursor: grab/grabbing signals the move affordance on desktop.
*/
.terminal-header--draggable {
cursor: grab;
user-select: none;
touch-action: none;
min-height: 48px;
}
.terminal-header--draggable:active {
cursor: grabbing;
}
.terminal-floating-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.terminal-floating-resize-handle--n,
.terminal-floating-resize-handle--s {
left: var(--space-sm);
right: var(--space-sm);
height: var(--space-sm);
cursor: ns-resize;
}
.terminal-floating-resize-handle--n { top: 0; }
.terminal-floating-resize-handle--s { bottom: 0; }
.terminal-floating-resize-handle--e,
.terminal-floating-resize-handle--w {
top: var(--space-sm);
bottom: var(--space-sm);
width: var(--space-sm);
cursor: ew-resize;
}
.terminal-floating-resize-handle--e { right: 0; }
.terminal-floating-resize-handle--w { left: 0; }
.terminal-floating-resize-handle--ne,
.terminal-floating-resize-handle--nw,
.terminal-floating-resize-handle--se,
.terminal-floating-resize-handle--sw {
width: var(--space-lg);
height: var(--space-lg);
}
.terminal-floating-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.terminal-floating-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.terminal-floating-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.terminal-floating-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: var(--surface);
padding: 0;
gap: 0;
}
.terminal-tabs {
display: flex;
flex: 1;
min-width: 0;
overflow-x: auto;
gap: 0;
padding: 0 4px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.terminal-tabs::-webkit-scrollbar {
height: 4px;
}
.terminal-tabs::-webkit-scrollbar-track {
background: transparent;
}
.terminal-tabs::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.terminal-tab {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--transition-fast), border-color var(--transition-fast);
position: relative;
min-height: 44px;
max-width: min(260px, 42vw);
min-width: 0;
flex: 0 1 auto;
}
.terminal-tab:hover {
color: var(--text);
background: var(--card-hover);
}
.terminal-tab--active {
color: var(--text);
border-bottom-color: var(--in-progress);
}
.terminal-tab--active:hover {
border-bottom-color: var(--in-progress);
}
.terminal-tab-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--in-progress);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.terminal-tab-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 12px;
}
.terminal-tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
margin-left: 4px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.terminal-tab:hover .terminal-tab-close,
.terminal-tab--active .terminal-tab-close {
opacity: 1;
}
.terminal-tab-close:hover {
background: var(--card-hover);
color: var(--text);
}
.terminal-tab--new {
color: var(--text-muted);
background: none;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
min-width: 32px;
justify-content: center;
padding: var(--space-xs) var(--space-sm);
}
.terminal-tab--new:hover {
color: var(--text);
background: var(--card-hover);
border-color: var(--text-muted);
}
.terminal-tab--empty {
color: var(--text-muted);
cursor: default;
font-style: italic;
}
.terminal-tab--empty:hover {
background: none;
color: var(--text-muted);
}
/*
FNXC:TerminalWorkspaces 2026-06-29-00:00:
Terminal worktree selection mirrors the file-browser workspace model while staying header-sized for docked, floating, and mobile terminals. Render the picker only when task workspace entries exist so failures or empty worktree lists never leave an inert button shell next to the always-fast + terminal affordance.
FNXC:TerminalWorkspaces 2026-06-29-00:00:
Terminal headers must survive many tabs, long task titles, floating narrow widths, and mobile touch controls. Bound tab/picker labels and make menus scroll within the viewport so close/reconnect/keyboard controls and the xterm viewport remain reachable.
FNXC:TerminalWorkspaces 2026-06-30-00:00:
The worktree listbox must escape the mobile terminal header's clipped overflow. It is portaled to the viewport, positioned from the trigger, and constrained with tokenized gutters so narrow phones can reach and scroll every workspace option without hiding the fast + terminal action.
*/
.terminal-workspace-picker {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin-left: var(--space-xs);
border-left: 1px solid var(--border);
padding-left: var(--space-xs);
z-index: 3;
}
.terminal-workspace-picker-trigger,
.terminal-workspace-picker-open {
display: flex;
align-items: center;
justify-content: center;
min-height: 34px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast);
}
.terminal-workspace-picker-trigger:hover,
.terminal-workspace-picker-open:hover:not(:disabled) {
color: var(--text);
background: var(--card-hover);
border-color: var(--text-muted);
}
.terminal-workspace-picker-trigger {
gap: var(--space-xs);
width: clamp(112px, 16vw, 220px);
max-width: 220px;
min-width: 0;
padding: 0 var(--space-sm);
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.terminal-workspace-picker-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 500;
}
.terminal-workspace-picker-chevron {
transition: transform var(--transition-fast);
}
.terminal-workspace-picker-chevron.open {
transform: rotate(180deg);
}
.terminal-workspace-picker-open {
width: 34px;
margin-left: -1px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.terminal-workspace-picker-open:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.terminal-workspace-picker-menu {
--terminal-workspace-menu-width: calc(var(--space-xl) * 14.167);
--terminal-workspace-menu-min-width: calc(var(--space-xl) * 9.167);
--terminal-workspace-menu-height: calc(var(--space-xl) * 15);
position: fixed;
top: calc(var(--space-xl) * 2);
left: var(--space-md);
width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-md) * 2)));
max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-md) * 2)));
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
box-shadow: var(--shadow-lg);
z-index: 5000;
}
.terminal-workspace-picker-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
width: 100%;
padding: var(--space-sm);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.terminal-workspace-picker-option:hover:not(:disabled),
.terminal-workspace-picker-option.active {
background: var(--card-hover);
}
.terminal-workspace-picker-option:disabled,
.terminal-workspace-picker-option.disabled {
cursor: not-allowed;
color: var(--text-muted);
opacity: 0.7;
}
.terminal-workspace-picker-option-main {
display: flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
}
.terminal-workspace-picker-option-main span,
.terminal-workspace-picker-option-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-workspace-picker-option-meta {
color: var(--text-muted);
font-size: 11px;
max-width: 180px;
}
.terminal-workspace-picker-group-label {
padding: var(--space-sm) var(--space-sm) var(--space-xs);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.terminal-close {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
}
.terminal-close:hover {
color: var(--text);
background: var(--card-hover);
}
/*
FNXC:Terminal 2026-06-23-22:12:
The terminal header pop-out/dock affordance is an icon-only utility control. It should not carry button background or border chrome; keep hover/focus feedback only through color/focus ring so it reads like the surrounding header utility icons.
*/
.terminal-clear-btn.terminal-clear-btn--icon[data-testid="terminal-popout-toggle"] {
background: transparent;
border: none;
box-shadow: none;
}
.terminal-clear-btn.terminal-clear-btn--icon[data-testid="terminal-popout-toggle"]:hover {
background: transparent;
border: none;
color: var(--text);
}
.terminal-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.terminal-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.terminal-empty-state p {
margin: 0;
line-height: 1.5;
}
.terminal-empty-state p:first-child {
font-size: 16px;
font-weight: 500;
color: var(--text);
}
.terminal-empty-state p:last-child {
font-size: 13px;
}
.terminal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.terminal-task-info {
display: flex;
align-items: center;
gap: var(--space-md);
min-width: 0;
}
.terminal-task-id {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
flex-shrink: 0;
}
.terminal-task-title {
font-size: 13px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.terminal-clear-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.terminal-clear-btn:hover {
color: var(--text);
border-color: var(--text-muted);
background: var(--card-hover);
}
.terminal-clear-btn--shortcut {
margin-left: var(--space-xs);
}
/* FNXC:Terminal 2026-06-23-00:15: Icon-only pop-out/dock toggle — no visible text label, so render as a square icon button while keeping the same hover/border treatment as the other header controls. */
.terminal-clear-btn--icon {
padding: 6px;
justify-content: center;
}
.terminal-log-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.terminal-log-container .agent-log-viewer {
flex: 1;
max-height: none;
background: var(--card);
border-radius: 0;
}
/* === Interactive Terminal Styles === */
.terminal-title {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 0 16px;
font-size: 14px;
font-weight: 500;
color: var(--text);
flex: 0 1 auto;
min-width: 0;
}
.terminal-actions {
display: flex;
align-items: center;
gap: 0;
flex: 0 0 auto;
}
.terminal-output {
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
background: var(--bg);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
}
.terminal-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: 40px 20px;
text-align: center;
color: var(--text-muted);
height: 100%;
}
.terminal-welcome-icon {
color: var(--in-progress);
opacity: 0.8;
}
.terminal-welcome h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.terminal-welcome p {
margin: 0;
max-width: 400px;
line-height: 1.5;
}
.terminal-commands-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
justify-content: center;
margin: 8px 0;
}
.terminal-commands-list span {
display: inline-block;
padding: 4px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-muted);
}
.terminal-shortcuts {
font-size: 12px;
color: var(--text-muted);
}
.terminal-shortcuts kbd {
display: inline-block;
padding: 2px 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
}
.terminal-history {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.terminal-entry {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.terminal-prompt-line {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.terminal-prompt {
color: var(--color-success);
font-weight: 600;
flex-shrink: 0;
}
.terminal-command {
color: var(--text);
font-weight: 500;
}
.terminal-output-text {
margin: 0;
padding: var(--space-md) var(--space-lg);
background: var(--card);
border-radius: var(--radius);
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.terminal-output-error {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-left: 3px solid var(--color-error);
}
.terminal-running-indicator {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
color: var(--text-muted);
font-size: 12px;
}
.terminal-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--in-progress);
border-radius: 50%;
animation: terminal-spin 1s linear infinite;
}
@keyframes terminal-spin {
to {
transform: rotate(360deg);
}
}
.terminal-input-area {
border-top: 1px solid var(--border);
background: var(--surface);
padding: var(--space-md) var(--space-lg);
}
.terminal-form {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.terminal-input-prompt {
color: var(--color-success);
font-weight: 600;
font-family: var(--font-mono);
font-size: 13px;
flex-shrink: 0;
}
.terminal-input {
flex: 1;
padding: var(--space-sm) var(--space-md);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color var(--transition-fast);
}
.terminal-input:focus {
border-color: var(--in-progress);
}
.terminal-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.terminal-input::placeholder {
color: var(--text-muted);
}
.terminal-kill-btn {
padding: var(--space-sm) var(--space-lg);
background: var(--color-error);
border: none;
border-radius: var(--radius);
color: white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: opacity var(--transition-fast);
flex-shrink: 0;
}
.terminal-kill-btn:hover {
opacity: 0.9;
}
/* === PTY Terminal Styles === */
.terminal-container {
flex: 1;
min-height: 0;
position: relative;
background: var(--terminal-bg);
overflow: hidden;
}
.terminal-xterm {
width: 100%;
height: 100%;
min-height: 0;
padding: var(--space-xs);
}
/*
FNXC:Terminal 2026-06-18-07:04:
Real iOS Safari recurrence #4 kept wide ASCII cells even after unicode-range scoping and symbols-last ordering; the supplied harness measured AGENTS.md at the same 66.76px for every stack, so ordering is ruled out. Pin text-size adjustment across xterm's measurement subtree so mobile WebKit cannot inflate DOM/canvas cell metrics, while WebGL/desktop keep the same 100% text scale and every terminal preset still resolves through terminalPreferences.
*/
.terminal-xterm,
.terminal-xterm * {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/*
FNXC:Terminal 2026-06-18-15:44:
FN-6659 keeps the loaded symbols @font-face out of xterm's measured font option because every FN-6638 diagnostic stack that measured 66.76px still included that face. Limit the symbols fallback to DOM renderer row spans via a CSS custom property so ASCII measurement, fit, refresh, and WebGL/canvas option paths stay symbols-free while powerline glyphs can still resolve when xterm emits DOM text nodes.
*/
.terminal-xterm .xterm-rows span {
font-family: var(--terminal-glyph-font-family, inherit) !important;
}
/*
* xterm fit may apply inline pixel heights on the `.xterm` root after
* row/line-height recomputation (e.g. after font-size changes). Keep the root
* stretched to the wrapper height so the terminal always fills the modal.
*/
.terminal-xterm .xterm {
height: 100% !important;
min-height: 100%;
}
/*
* Keep xterm's helper textarea tiny and inside terminal bounds.
*
* Mobile browsers are more likely to honor focus for keyboard activation when
* the focused element is not placed far off-screen.
*/
.terminal-xterm .xterm .xterm-helper-textarea {
left: 0 !important;
top: 0 !important;
width: 1px !important;
height: 1px !important;
opacity: 0.01 !important;
pointer-events: auto !important;
z-index: 1 !important;
}
/*
* On touch-primary devices (mobile/tablet), expand the helper textarea to
* cover the entire terminal surface so taps land directly on it. iOS Safari
* reliably opens the soft keyboard and routes input events only when focus
* originates from a tap on the focused element itself — programmatic focus()
* from a parent's touch handler is often silently ignored. The textarea
* remains visually invisible via opacity:0 and sits above the canvas so
* tap/drag events land on it. Desktop (mouse) keeps the 1×1 rule so xterm
* can still handle drag-to-select and mouse-tracking on the canvas.
*/
@media (hover: none) and (pointer: coarse) {
.terminal-xterm .xterm .xterm-helper-textarea {
width: 100% !important;
height: 100% !important;
opacity: 0 !important;
z-index: 2 !important;
}
}
.terminal-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
inset: 0;
z-index: 2;
background: var(--terminal-bg);
gap: var(--space-lg);
color: var(--text-muted);
}
.terminal-loading .terminal-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--in-progress);
border-radius: 50%;
animation: terminal-spin 1s linear infinite;
}
.terminal-shell-name {
font-size: 12px;
color: var(--text-muted);
font-family: "SF Mono", Monaco, Consolas, monospace;
margin-left: 8px;
}
.terminal-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-left: 8px;
}
.terminal-status.connected {
background: var(--done);
}
.terminal-status.connecting,
.terminal-status.reconnecting {
background: var(--in-progress);
animation: terminal-pulse 1.5s ease-in-out infinite;
}
.terminal-status.disconnected {
background: var(--color-error);
}
@keyframes terminal-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/*
FNXC:Terminal 2026-06-22-17:15:
The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack into separate rows. The panel no longer wraps; the modifier-row and arrow-row are inline (intrinsic width, no 100% / margin-bottom), and the panel scrolls horizontally if the buttons exceed the width.
*/
.terminal-shortcut-panel {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
overflow-x: auto;
/*
FNXC:Terminal 2026-06-22-22:00:
On a narrow folded phone the modifier/arrow/letter keys exceed the viewport width, so the bar MUST scroll horizontally to keep every button reachable. touch-action: pan-x lets a horizontal swipe scroll the row (instead of the browser hijacking it as a page gesture), overscroll-behavior-x: contain stops the swipe from bleeding into page/back-navigation at the ends, and -webkit-overflow-scrolling: touch gives momentum scroll on iOS.
*/
touch-action: pan-x;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.terminal-shortcut-modifier-row,
.terminal-shortcut-arrow-row {
display: flex;
align-items: center;
gap: var(--space-xs);
flex: 0 0 auto;
}
.terminal-shortcut-btn {
display: inline-flex;
align-items: center;
justify-content: center;
/*
FNXC:Terminal 2026-06-22-22:00:
Keys keep their intrinsic width and never shrink/grow, so the row's total width exceeds a narrow viewport and the panel's overflow-x: auto produces a real horizontal scroll reaching the rightmost buttons. flex:1 / width:100% here would collapse every key to fit the viewport and defeat the scroll.
*/
flex: 0 0 auto;
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-preferences-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 4), 1fr));
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
}
.terminal-preference-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
color: var(--text-muted);
font-size: 0.875rem;
}
.terminal-preference-field--checkbox {
flex-direction: row;
align-items: center;
align-self: end;
color: var(--text);
}
.terminal-preference-control {
width: 100%;
min-width: 0;
}
.terminal-preference-note {
color: var(--text-muted);
font-size: 0.8rem;
}
.terminal-preferences-reset {
align-self: end;
justify-self: start;
}
/*
FNXC:Terminal 2026-06-23-00:15:
Footer reads left-to-right: text-size control, then the relocated Clear/Shortcuts/Preferences action cluster, then connection status / exit code / zoom-hint pushed to the right edge (margin-left:auto on the connection status). flex-wrap keeps the action cluster from clipping when the docked/floating panel is narrow; gap replaces the old space-between distribution.
*/
.terminal-status-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
background: var(--surface);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
/* FNXC:Terminal 2026-06-23-00:15: Grouped cluster of the relocated Clear/Shortcuts/Preferences buttons; scrolls horizontally as a unit on very narrow footers so the buttons stay reachable instead of clipping. */
.terminal-footer-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
overflow-x: auto;
}
/* FNXC:Terminal 2026-06-23-00:15: Connection status starts the right-aligned trailing group (status + exit code + zoom hint) so the left edge holds the text-size and action controls. */
.terminal-connection-status {
font-weight: 500;
margin-left: auto;
}
.terminal-connection-status.connected {
color: var(--done);
}
.terminal-connection-status.connecting,
.terminal-connection-status.reconnecting {
color: var(--in-progress);
}
.terminal-connection-status.disconnected {
color: var(--color-error);
}
.terminal-exit-code {
color: var(--color-error);
font-weight: 500;
}
.terminal-font-size-controls {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
.terminal-font-size-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
padding: 0;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.terminal-font-size-btn:hover {
background: var(--card-hover);
border-color: var(--text-muted);
}
.terminal-font-size-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.terminal-font-size-value {
min-width: calc(var(--space-xl) + var(--space-xs));
text-align: center;
font-family: var(--font-mono);
color: var(--text-muted);
}
.terminal-reconnect-btn,
.terminal-restart-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 6px 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.terminal-reconnect-btn:hover,
.terminal-restart-btn:hover {
background: var(--card-hover);
border-color: var(--text-muted);
}
.terminal-error {
padding: var(--space-md) var(--space-lg);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-bottom: 1px solid var(--border);
color: var(--color-error);
font-size: 13px;
}
.terminal-error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md, 12px);
}
.terminal-error-content span {
color: var(--color-error);
text-align: center;
}
.terminal-error-actions {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--space-sm, 8px);
}
.terminal-retry-btn {
display: flex;
align-items: center;
gap: var(--space-xs, 4px);
padding: 6px 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.terminal-retry-btn:hover {
background: var(--card-hover);
border-color: var(--text-muted);
}
/* === Terminal Modal Mobile Responsive === */
@media (max-width: 768px) {
.modal.terminal-modal {
/* Reset the desktop min-width: 480px / min-height: 320px — they pin the
modal at 480×320 on phones narrower than 480px and force horizontal
overflow. The fullscreen width/height below then take effect normally.
!important is required because useModalResizePersist writes inline
width/height from saved desktop sessions, and inline styles would
otherwise win over the media query. */
min-width: 0 !important;
min-height: 0 !important;
width: 100% !important;
width: 100vw !important;
max-width: 100vw !important;
min-height: 100dvh !important;
height: 100dvh !important;
max-height: 100dvh !important;
border-radius: 0;
border: none;
/* Disable user resize on mobile — fullscreen layout. */
resize: none;
}
/* Keep tabs and header actions on a single row */
.terminal-header {
flex-wrap: nowrap;
padding-top: env(safe-area-inset-top, 0);
overflow: hidden;
}
/* Tabs stay scrollable and yield space to the fixed action cluster */
.terminal-tabs {
flex: 1 1 auto;
min-width: 0;
}
.terminal-workspace-picker {
margin-left: 0;
padding-left: 0;
border-left: none;
}
.terminal-workspace-picker-trigger {
width: clamp(92px, 28vw, 132px);
max-width: 132px;
min-height: 36px;
padding: 0 var(--space-xs);
}
.terminal-workspace-picker-open {
width: 36px;
min-height: 36px;
}
.terminal-workspace-picker-menu {
width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-sm) * 2)));
max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-sm) * 2)));
-webkit-overflow-scrolling: touch;
}
.terminal-workspace-picker-option-meta {
max-width: 150px;
}
/* Hide the redundant title/status indicator on mobile — .terminal-status-bar shows connection state */
.terminal-title {
display: none;
}
/* Actions stay pinned to the right edge of the header row */
.terminal-actions {
flex: 0 0 auto;
justify-content: flex-end;
border-top: none;
padding: 0 var(--space-xs) 0 0;
min-height: 36px;
}
/* Hide text labels on action buttons to save space */
.terminal-action-label {
display: none;
}
.terminal-reconnect-btn,
.terminal-restart-btn,
.terminal-clear-btn {
padding: 8px;
min-height: 36px;
min-width: 36px;
justify-content: center;
}
.terminal-tab {
min-height: 36px;
padding: 6px 10px;
font-size: 13px;
}
.terminal-tab-label {
font-size: 13px;
}
.terminal-close {
width: 36px;
height: 36px;
}
.terminal-toolbar {
padding: 10px 12px;
padding-bottom: max(10px, env(safe-area-inset-bottom, 0));
}
.terminal-task-title {
font-size: 12px;
max-width: 200px;
}
.terminal-empty-state {
padding: var(--space-xl);
padding-bottom: max(24px, env(safe-area-inset-bottom, 0));
}
/* Mobile styles for interactive terminal */
.terminal-output {
padding: var(--space-md);
font-size: 12px;
}
.terminal-welcome {
padding: 24px 16px;
}
.terminal-welcome h3 {
font-size: 16px;
}
.terminal-commands-list {
gap: 6px;
}
.terminal-commands-list span {
padding: 3px 8px;
font-size: 11px;
}
.terminal-shortcuts {
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-preferences-panel {
grid-template-columns: 1fr;
max-height: calc(var(--space-2xl) * 8);
overflow-y: auto;
}
.terminal-preference-field--checkbox,
.terminal-preferences-reset {
align-self: stretch;
}
.terminal-font-size-btn {
min-width: calc(var(--space-xl) + var(--space-md));
min-height: calc(var(--space-xl) + var(--space-md));
}
.terminal-shortcuts kbd {
padding: 1px 4px;
font-size: 10px;
}
.terminal-input-area {
padding: 10px 12px;
padding-bottom: max(10px, env(safe-area-inset-bottom, 0));
}
.terminal-input {
font-size: 16px;
padding: 6px 10px;
}
.terminal-kill-btn {
padding: 6px 12px;
font-size: 11px;
}
.terminal-output-text {
padding: 10px 12px;
font-size: 12px;
}
/* When a virtual keyboard is open, constrain the modal to fit entirely
above the keyboard so the status bar / entry area stays visible.
--vv-height is the visualViewport.height in pixels (set by JS) and
works uniformly across Chrome Android and iOS Safari.
Falls back to 100dvh - overlap when --vv-height is not available. */
.terminal-modal[style*="--keyboard-overlap"] {
/* Neutralize inherited desktop min-height (90vh) so the modal can
shrink below the full-viewport mobile default when the keyboard
is open. Without this, min-height keeps the modal taller than
the available space and the bottom overlaps the keyboard. */
min-height: auto !important;
/* Apply both height and max-height so the modal is *exactly* the
visual viewport height — not just capped at it. Using height
ensures the element cannot be taller than max-height due to
inherited min-height or flex layout.
!important is required because the mobile fullscreen rule above
sets `height: 100dvh !important` to defeat persisted desktop
sizes from useModalResizePersist; without `!important` here, the
keyboard-aware shrink would lose the specificity battle and the
terminal would stay at 100dvh while the keyboard covered the
bottom of the viewport. */
height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) !important;
max-height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) !important;
/* Clip any content that exceeds the constrained height during
the keyboard-open transition so the terminal doesn't poke out
below the visible area while xterm re-fits. */
overflow: hidden;
}
/* Anchor the overlay to the top when the keyboard is open so the header
stays visible. Without this, align-items:center (from the non-keyboard
modal-overlay:has rule) vertically centers the shrunken modal and pushes
the top area out of the visible viewport. */
.modal-overlay:has(.terminal-modal[style*="--keyboard-overlap"]) {
align-items: flex-start !important;
}
/* Terminal tab close button: touch target on mobile */
.terminal-tab-close {
min-width: 36px;
min-height: 36px;
}
}