Rebaseline the i18n hardcoded-string lint guardrail for shipping dashboard surfaces. - Add scoped i18n lint ignores for non-shipping tests, stories, and deferred source-copy files with follow-up rationale. - Localize remaining plugin/view copy and replace technical glyph literals that should not be linted as prose. - Refresh synced locale catalogs and generated resource types, and document the lint baseline policy. Files changed: .changeset/fn-6749-i18n-lint-rebaseline.md | 5 + docs/i18n-contributing.md | 14 + i18next.config.ts | 96 ++ .../dashboard/app/components/AgentDetailView.tsx | 4 +- .../app/components/ConversationHistory.tsx | 4 +- .../app/components/DockerNodeOnboardingModal.tsx | 8 +- .../components/MilestoneSliceInterviewModal.tsx | 4 +- .../app/components/MissionInterviewModal.tsx | 4 +- .../app/components/ModelSelectionModal.tsx | 4 +- .../dashboard/app/components/NodeDetailModal.tsx | 15 +- .../dashboard/app/components/PlanningModeModal.tsx | 4 +- .../dashboard/app/components/SessionTerminal.tsx | 15 +- .../app/components/SubtaskBreakdownModal.tsx | 4 +- packages/dashboard/app/components/TaskForm.tsx | 4 +- .../dashboard/app/components/TerminalModal.tsx | 18 +- .../dashboard/app/plugins/pluginViewRegistry.tsx | 7 +- packages/i18n/locales/en/app.json | 890 +++++++++--- packages/i18n/locales/en/common.json | 82 +- packages/i18n/locales/es/app.json | 986 ++++++++++--- packages/i18n/locales/es/common.json | 82 +- packages/i18n/locales/fr/app.json | 986 ++++++++++--- packages/i18n/locales/fr/common.json | 82 +- packages/i18n/locales/ko/app.json | 1482 ++++++++++++++------ packages/i18n/locales/ko/cli.json | 8 +- packages/i18n/locales/ko/common.json | 82 +- packages/i18n/locales/zh-CN/app.json | 1482 ++++++++++++++------ packages/i18n/locales/zh-CN/cli.json | 8 +- packages/i18n/locales/zh-CN/common.json | 82 +- packages/i18n/locales/zh-TW/app.json | 1482 ++++++++++++++------ packages/i18n/locales/zh-TW/cli.json | 8 +- packages/i18n/locales/zh-TW/common.json | 82 +- packages/i18n/src/resources.d.ts | 459 +++--- 32 files changed, 6055 insertions(+), 2438 deletions(-) Fusion-Task-Id: FN-6749 Fusion-Task-Lineage: 789a7cbb-fdf8-4190-8cd2-6bf7d5e2d552
833 lines
31 KiB
TypeScript
833 lines
31 KiB
TypeScript
import "./SessionTerminal.css";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-react";
|
|
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
|
|
import { appendTokenQuery } from "../auth";
|
|
import { api } from "../api";
|
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
|
import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
|
|
import {
|
|
TERMINAL_PREFERENCES_KEY,
|
|
readTerminalPreferences,
|
|
resolveTerminalFontFamily,
|
|
resolveTerminalGlyphFontFamily,
|
|
waitForTerminalFontMetrics,
|
|
} from "../utils/terminalPreferences";
|
|
|
|
/**
|
|
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
|
|
* agent session. Lazy-loads xterm + fit/webgl/unicode11 addons (kept out of the
|
|
* main bundle), bridges to the U10 WebSocket attach channel with ACK flow
|
|
* control, and renders the posture chip / read-only badge / confirm-advance
|
|
* strip / replay states described in the U11 visibility matrix.
|
|
*
|
|
* The WS bridge:
|
|
* 1. POST /api/cli-sessions/:id/attach-ticket → { ticket }
|
|
* 2. open WS /api/cli-sessions/ws?sessionId=&ticket= (fn_token carried on URL)
|
|
* 3. base64 scrollback/data → term.write; term.onData → input frames
|
|
* 4. fit + debounced ResizeObserver → resize frames
|
|
* 5. ACK {type:"ack",bytes} via term.write callbacks (~32KB cadence)
|
|
*/
|
|
|
|
/** ACK cadence — ACK roughly every 32KB of consumed output. */
|
|
const ACK_THRESHOLD_BYTES = 32 * 1024;
|
|
const RESIZE_DEBOUNCE_MS = 100;
|
|
|
|
/**
|
|
* Control sequences emitted by the accessory key bar (U13). These are
|
|
* deliberate user keystrokes routed straight to the session input path —
|
|
* exempt from U2's injected-text neutralization (which governs composed /
|
|
* injected strings, not real keystrokes).
|
|
*/
|
|
const SEQ_ESC = "\x1b"; // 0x1B
|
|
const SEQ_TAB = "\x09"; // 0x09
|
|
const SEQ_CTRL_C = "\x03"; // 0x03
|
|
const SEQ_ARROW_UP = "\x1b[A"; // CSI A
|
|
const SEQ_ARROW_DOWN = "\x1b[B"; // CSI B
|
|
const SEQ_ARROW_RIGHT = "\x1b[C"; // CSI C
|
|
const SEQ_ARROW_LEFT = "\x1b[D"; // CSI D
|
|
|
|
const CLI_TERMINAL_KEY_LABELS = {
|
|
ctrl: "Ctrl",
|
|
escape: "Esc",
|
|
tab: "Tab",
|
|
ctrlC: "^C",
|
|
} as const;
|
|
|
|
/**
|
|
* Resolve the control byte for a sticky-Ctrl + key combination. Ctrl maps a
|
|
* letter to its control code (A→0x01 … Z→0x1A): code = (toUpper(ch) & 0x1f).
|
|
* Returns null for keys that have no meaningful Ctrl combination.
|
|
*/
|
|
function ctrlCombo(key: string): string | null {
|
|
if (key.length !== 1) return null;
|
|
const upper = key.toUpperCase();
|
|
const code = upper.charCodeAt(0);
|
|
if (code >= 0x40 && code <= 0x5f) {
|
|
// @ A-Z [ \ ] ^ _ → 0x00-0x1F
|
|
return String.fromCharCode(code & 0x1f);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Reactive mobile-viewport detection via the repo breakpoint convention. */
|
|
function useIsMobileViewport(): boolean {
|
|
const [isMobile, setIsMobile] = useState<boolean>(() => isMobileViewport());
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
return;
|
|
}
|
|
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
|
|
const onChange = () => setIsMobile(isMobileViewport());
|
|
onChange();
|
|
// Safari < 14 only has addListener/removeListener.
|
|
if (typeof mql.addEventListener === "function") {
|
|
mql.addEventListener("change", onChange);
|
|
return () => mql.removeEventListener("change", onChange);
|
|
}
|
|
mql.addListener(onChange);
|
|
return () => mql.removeListener(onChange);
|
|
}, []);
|
|
|
|
return isMobile;
|
|
}
|
|
|
|
/** The posture surfaced on the session record (denormalized at launch, U15). */
|
|
export interface SessionTerminalPosture {
|
|
/** Adapter display name (single Terminal icon for all adapters). */
|
|
adapterName: string;
|
|
/** Resolved autonomy mode label (e.g. "auto-approve", "default"). */
|
|
mode?: string;
|
|
/**
|
|
* Whether the resolved argv+env elevates above the adapter baseline. When
|
|
* true the chip renders in warning color with a shield naming the flag.
|
|
*/
|
|
elevated?: boolean;
|
|
/** The elevated flag(s), named on the chip / tooltip when elevated. */
|
|
elevatedFlags?: string[];
|
|
/** Resolved posture lines shown in the click tooltip. */
|
|
resolved?: string[];
|
|
}
|
|
|
|
/** Replay/live mode for the terminal viewport. */
|
|
export type SessionTerminalMode = "live" | "idle" | "ended";
|
|
|
|
export interface SessionTerminalProps {
|
|
sessionId: string;
|
|
/** When true, term.onData is dropped (one-shot / replay sessions). */
|
|
readOnly?: boolean;
|
|
posture?: SessionTerminalPosture;
|
|
/** Drives the replay header: live | "session idle" | "session ended". */
|
|
mode?: SessionTerminalMode;
|
|
projectId?: string;
|
|
/** Generic-tier idle confirm-advance strip — POST confirm-advance on Advance. */
|
|
onConfirmAdvance?: (decision: "advance" | "not-yet") => void | Promise<void>;
|
|
/** Whether the confirm-advance strip is offered (generic-tier idle). */
|
|
showConfirmAdvance?: boolean;
|
|
/** Settings deep link for the posture chip tooltip. */
|
|
onOpenAdapterSettings?: () => void;
|
|
}
|
|
|
|
interface AttachTicketResponse {
|
|
ticket: string;
|
|
expiresAt: string;
|
|
readOnly: boolean;
|
|
}
|
|
|
|
/** Build the WS URL for the cli-sessions attach channel (mirrors useTerminal). */
|
|
function buildCliWsUrl(sessionId: string, ticket: string): string {
|
|
if (typeof window === "undefined") return "";
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const base =
|
|
`${protocol}//${window.location.host}/api/cli-sessions/ws` +
|
|
`?sessionId=${encodeURIComponent(sessionId)}&ticket=${encodeURIComponent(ticket)}`;
|
|
return appendTokenQuery(base);
|
|
}
|
|
|
|
function decodeBase64ToString(b64: string): string {
|
|
if (typeof window !== "undefined" && typeof window.atob === "function") {
|
|
// atob → binary string → UTF-8 decode.
|
|
const binary = window.atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return new TextDecoder("utf-8").decode(bytes);
|
|
}
|
|
return Buffer.from(b64, "base64").toString("utf8");
|
|
}
|
|
|
|
export function SessionTerminal({
|
|
sessionId,
|
|
readOnly = false,
|
|
posture,
|
|
mode = "live",
|
|
projectId,
|
|
onConfirmAdvance,
|
|
showConfirmAdvance = false,
|
|
onOpenAdapterSettings,
|
|
}: SessionTerminalProps) {
|
|
const { t } = useTranslation("app");
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const xtermRef = useRef<XTerm | null>(null);
|
|
const fitAddonRef = useRef<ITerminalAddon | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
const [postureTooltipOpen, setPostureTooltipOpen] = useState(false);
|
|
const [advanceDismissed, setAdvanceDismissed] = useState(false);
|
|
const [advancePending, setAdvancePending] = useState(false);
|
|
|
|
// ── Mobile input model (U13) ───────────────────────────────────────────────
|
|
const isMobile = useIsMobileViewport();
|
|
// Only arm keyboard tracking on mobile (the hook no-ops off-mobile anyway).
|
|
const { keyboardOpen, keyboardOverlap } = useMobileKeyboard({ enabled: isMobile });
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const [mobileInput, setMobileInput] = useState("");
|
|
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
|
|
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
|
|
const [ctrlSticky, setCtrlSticky] = useState(false);
|
|
|
|
/** Write raw bytes to the session input path (mobile bar + submit). */
|
|
const sendInput = useCallback((data: string) => {
|
|
if (!data) return;
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "input", data }));
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
|
|
* Ctrl combination, send the combined control byte and clear the modifier;
|
|
* otherwise send the literal sequence. Keeps the input focused (the caller's
|
|
* pointerdown preventDefault stops the blur).
|
|
*/
|
|
const emitBarKey = useCallback(
|
|
(seq: string) => {
|
|
if (ctrlSticky) {
|
|
const combined = ctrlCombo(seq);
|
|
setCtrlSticky(false);
|
|
if (combined) {
|
|
sendInput(combined);
|
|
return;
|
|
}
|
|
}
|
|
sendInput(seq);
|
|
},
|
|
[ctrlSticky, sendInput],
|
|
);
|
|
|
|
/** iOS composer pattern: keep focus on the visible input when tapping a key. */
|
|
const keepFocus = useCallback((e: { preventDefault: () => void }) => {
|
|
e.preventDefault();
|
|
}, []);
|
|
|
|
const handleMobileSubmit = useCallback(
|
|
(e?: { preventDefault?: () => void }) => {
|
|
e?.preventDefault?.();
|
|
// User-typed text + Enter — deliberate input, no neutralization.
|
|
if (mobileInput) sendInput(mobileInput);
|
|
sendInput("\r");
|
|
setMobileInput("");
|
|
},
|
|
[mobileInput, sendInput],
|
|
);
|
|
|
|
/**
|
|
* Input onChange. When sticky Ctrl is armed, the next typed character is
|
|
* captured as a Ctrl combination (Ctrl-D `0x04`, Ctrl-Z `0x1A`, …) instead of
|
|
* landing in the field — this is how Ctrl-letter chords beyond the bar's
|
|
* dedicated Ctrl-C are reached on mobile. Otherwise the value updates
|
|
* normally for free-text + Enter submit.
|
|
*/
|
|
const handleMobileInputChange = useCallback(
|
|
(next: string) => {
|
|
if (ctrlSticky && next.length > mobileInput.length) {
|
|
// The newly-typed character is the last one appended.
|
|
const ch = next.slice(mobileInput.length, mobileInput.length + 1);
|
|
const combined = ctrlCombo(ch);
|
|
setCtrlSticky(false);
|
|
if (combined) {
|
|
sendInput(combined);
|
|
return; // swallow — do not echo the raw key into the field
|
|
}
|
|
}
|
|
setMobileInput(next);
|
|
},
|
|
[ctrlSticky, mobileInput, sendInput],
|
|
);
|
|
|
|
// Re-arm the strip whenever a fresh idle window is offered.
|
|
useEffect(() => {
|
|
if (showConfirmAdvance) setAdvanceDismissed(false);
|
|
}, [showConfirmAdvance, sessionId]);
|
|
|
|
const applyLiveTerminalPreferences = useCallback(() => {
|
|
const terminal = xtermRef.current;
|
|
if (!terminal) {
|
|
return;
|
|
}
|
|
|
|
const terminalPreferences = readTerminalPreferences();
|
|
terminal.options.fontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
|
containerRef.current?.style.setProperty(
|
|
"--terminal-glyph-font-family",
|
|
resolveTerminalGlyphFontFamily(terminalPreferences.fontFamily),
|
|
);
|
|
terminal.options.fontSize = terminalPreferences.fontSize;
|
|
terminal.options.cursorStyle = terminalPreferences.cursorStyle;
|
|
terminal.options.cursorBlink = terminalPreferences.cursorBlink && !readOnly && mode === "live";
|
|
|
|
try {
|
|
(fitAddonRef.current as { fit?: () => void } | null)?.fit?.();
|
|
} catch {
|
|
/* ignore transient measure failures */
|
|
}
|
|
}, [mode, readOnly]);
|
|
|
|
/*
|
|
FNXC:Terminal 2026-06-17-01:05:
|
|
Font and cursor preferences live-apply through the shared storage key so SessionTerminal follows changes made in another terminal surface without remounting. Renderer remains excluded from this handler because renderer addon teardown/re-attach only happens safely during the next session init.
|
|
*/
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key !== TERMINAL_PREFERENCES_KEY) {
|
|
return;
|
|
}
|
|
applyLiveTerminalPreferences();
|
|
};
|
|
|
|
window.addEventListener("storage", onStorage);
|
|
return () => window.removeEventListener("storage", onStorage);
|
|
}, [applyLiveTerminalPreferences]);
|
|
|
|
// ── xterm lifecycle + WS bridge ──────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!sessionId || typeof window === "undefined") return;
|
|
let disposed = false;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let unackedBytes = 0;
|
|
|
|
const sendResize = (cols: number, rows: number) => {
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
}
|
|
};
|
|
|
|
const ackBytes = (n: number) => {
|
|
unackedBytes += n;
|
|
if (unackedBytes < ACK_THRESHOLD_BYTES) return;
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "ack", bytes: unackedBytes }));
|
|
}
|
|
unackedBytes = 0;
|
|
};
|
|
|
|
const init = async () => {
|
|
// 1. Mint a single-use attach ticket via the app API helper.
|
|
let ticketRes: AttachTicketResponse;
|
|
try {
|
|
ticketRes = await api<AttachTicketResponse>(
|
|
`/cli-sessions/${encodeURIComponent(sessionId)}/attach-ticket`,
|
|
{ method: "POST", body: JSON.stringify(projectId ? { projectId } : {}) },
|
|
);
|
|
} catch {
|
|
return; // surfaced via the "disconnected" state header below
|
|
}
|
|
if (disposed) return;
|
|
|
|
// 2. Lazy-load xterm + addons (out of the main bundle).
|
|
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
|
|
import("@xterm/xterm"),
|
|
import("@xterm/addon-fit"),
|
|
import("@xterm/addon-unicode11"),
|
|
]);
|
|
if (disposed || !containerRef.current) return;
|
|
|
|
const terminalPreferences = readTerminalPreferences();
|
|
const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily);
|
|
containerRef.current.style.setProperty(
|
|
"--terminal-glyph-font-family",
|
|
resolveTerminalGlyphFontFamily(terminalPreferences.fontFamily),
|
|
);
|
|
|
|
/*
|
|
FNXC:Terminal 2026-06-18-15:42:
|
|
SessionTerminal shares TerminalModal's recurrence #5 root cause: FN-6638's 66.76px diagnostic compared only symbols-inclusive stacks, so real iOS Safari still let the loaded symbols @font-face pollute xterm's ASCII measurement. Pass only the symbols-free resolved family to xterm on this attach surface too; DOM glyph fallback is scoped to the viewport CSS variable and never to the xterm font option used by DOM/canvas measurement or desktop WebGL.
|
|
|
|
FNXC:Terminal 2026-06-17-00:50:
|
|
SessionTerminal consumes the shared localStorage terminal preferences for parity with TerminalModal, but replay safety still owns input posture: cursor blink is the user preference AND-gated by !readOnly && mode === "live" so read-only, idle, and ended sessions never blink.
|
|
*/
|
|
const term = new Terminal({
|
|
convertEol: false,
|
|
cursorBlink: terminalPreferences.cursorBlink && !readOnly && mode === "live",
|
|
cursorStyle: terminalPreferences.cursorStyle,
|
|
disableStdin: readOnly,
|
|
scrollback: 10000,
|
|
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
|
|
// server-side neutralizer (U10) strips it; we add no client handling.
|
|
fontFamily: resolvedFontFamily,
|
|
fontSize: terminalPreferences.fontSize,
|
|
});
|
|
const fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
const unicode11 = new Unicode11Addon();
|
|
term.loadAddon(unicode11);
|
|
term.unicode.activeVersion = "11";
|
|
|
|
term.open(containerRef.current);
|
|
xtermRef.current = term;
|
|
fitAddonRef.current = fitAddon as unknown as ITerminalAddon;
|
|
|
|
/*
|
|
FNXC:Terminal 2026-06-17-00:55:
|
|
The embedded session terminal follows the shared renderer preference, but mobile viewports are a hard WebGL skip floor to avoid glyph artifacts in WebKit. Renderer changes are init-only because swapping xterm render addons mid-session is unsafe; users get the new renderer on the next mount/session.
|
|
*/
|
|
const shouldLoadWebgl = terminalPreferences.renderer === "auto" && !isMobileViewport();
|
|
if (shouldLoadWebgl) {
|
|
// WebGL renderer with context-loss fallback to the DOM renderer.
|
|
try {
|
|
const { WebglAddon } = await import("@xterm/addon-webgl");
|
|
if (!disposed) {
|
|
const webgl = new WebglAddon();
|
|
webgl.onContextLoss(() => {
|
|
try {
|
|
webgl.dispose();
|
|
} catch {
|
|
/* fall back to DOM renderer */
|
|
}
|
|
});
|
|
term.loadAddon(webgl);
|
|
}
|
|
} catch {
|
|
/* WebGL unavailable — DOM renderer is the default fallback */
|
|
}
|
|
}
|
|
|
|
try {
|
|
(fitAddon as unknown as { fit: () => void }).fit();
|
|
} catch {
|
|
/* container not measurable yet */
|
|
}
|
|
|
|
void (async () => {
|
|
const fontMetricsSettled = await waitForTerminalFontMetrics(
|
|
terminalPreferences.fontSize,
|
|
resolvedFontFamily,
|
|
);
|
|
if (
|
|
!fontMetricsSettled ||
|
|
disposed ||
|
|
xtermRef.current !== term ||
|
|
fitAddonRef.current !== fitAddon
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
/*
|
|
FNXC:Terminal 2026-06-18-07:15:
|
|
SessionTerminal shares TerminalModal's real-iOS DOM/canvas measurement path and the same user-selectable font presets. FN-6638 ruled out stack ordering with the 66.76px-identical diagnostic, so this attach surface must also reapply font options and refit after best-effort FontFaceSet settlement even when iOS rejects the multi-family shorthand; WebGL desktop remains safe because the same invalidation path refreshes renderer metrics without changing renderer selection.
|
|
*/
|
|
term.options.fontFamily = resolvedFontFamily;
|
|
term.options.fontSize = terminalPreferences.fontSize;
|
|
(fitAddon as unknown as { fit: () => void }).fit();
|
|
sendResize(term.cols, term.rows);
|
|
term.refresh(0, Math.max(0, term.rows - 1));
|
|
} catch {
|
|
/* ignore teardown or transient measure failures */
|
|
}
|
|
})();
|
|
|
|
// term.onData → input frames (skip entirely when read-only).
|
|
if (!readOnly) {
|
|
term.onData((data: string) => {
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "input", data }));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Debounced ResizeObserver → resize frames.
|
|
resizeObserver = new ResizeObserver(() => {
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => {
|
|
try {
|
|
(fitAddon as unknown as { fit: () => void }).fit();
|
|
sendResize(term.cols, term.rows);
|
|
} catch {
|
|
/* ignore transient measure failures */
|
|
}
|
|
}, RESIZE_DEBOUNCE_MS);
|
|
});
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
// 3. Open the WS attach channel.
|
|
const ws = new WebSocket(buildCliWsUrl(sessionId, ticketRes.ticket));
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
sendResize(term.cols, term.rows);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
let msg: { type?: string; data?: string };
|
|
try {
|
|
msg = JSON.parse(typeof event.data === "string" ? event.data : "");
|
|
} catch {
|
|
return;
|
|
}
|
|
switch (msg.type) {
|
|
case "scrollback":
|
|
case "data": {
|
|
if (typeof msg.data !== "string") return;
|
|
const text = decodeBase64ToString(msg.data);
|
|
const byteLen = text.length;
|
|
// ACK once xterm has flushed the chunk to the screen.
|
|
term.write(text, () => ackBytes(byteLen));
|
|
break;
|
|
}
|
|
// state / error / exit frames are advisory; the SSE channel and the
|
|
// mode prop drive header copy. We intentionally do not mutate the
|
|
// viewport on them.
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
};
|
|
|
|
void init();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
if (resizeObserver) resizeObserver.disconnect();
|
|
const ws = wsRef.current;
|
|
if (ws) {
|
|
ws.onopen = null;
|
|
ws.onmessage = null;
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
/* already closing */
|
|
}
|
|
wsRef.current = null;
|
|
}
|
|
const term = xtermRef.current;
|
|
if (term) {
|
|
try {
|
|
term.dispose();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
xtermRef.current = null;
|
|
}
|
|
fitAddonRef.current = null;
|
|
};
|
|
}, [sessionId, readOnly, mode, projectId]);
|
|
|
|
const replayLabel = useMemo(() => {
|
|
if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle");
|
|
if (mode === "ended") return t("cliTerminal.replayEnded", "Session ended");
|
|
return null;
|
|
}, [mode, t]);
|
|
|
|
const handleAdvance = useCallback(async () => {
|
|
if (!onConfirmAdvance) return;
|
|
setAdvancePending(true);
|
|
try {
|
|
await onConfirmAdvance("advance");
|
|
setAdvanceDismissed(true);
|
|
} finally {
|
|
setAdvancePending(false);
|
|
}
|
|
}, [onConfirmAdvance]);
|
|
|
|
const handleNotYet = useCallback(async () => {
|
|
if (onConfirmAdvance) await onConfirmAdvance("not-yet");
|
|
// "Not yet" stays in execute and re-arms the idle timer (server-side); the
|
|
// strip hides until the next idle window re-offers it.
|
|
setAdvanceDismissed(true);
|
|
}, [onConfirmAdvance]);
|
|
|
|
const elevated = Boolean(posture?.elevated);
|
|
const flagSummary = posture?.elevatedFlags?.join(", ");
|
|
const terminalGlyphStyle = {
|
|
"--terminal-glyph-font-family": resolveTerminalGlyphFontFamily(
|
|
readTerminalPreferences().fontFamily,
|
|
),
|
|
} as CSSProperties;
|
|
|
|
return (
|
|
<div
|
|
className={`cli-session-terminal${isMobile ? " cli-session-terminal--mobile" : ""}${
|
|
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
|
|
}`}
|
|
data-mode={mode}
|
|
data-read-only={readOnly}
|
|
data-mobile={isMobile}
|
|
data-keyboard-open={isMobile && keyboardOpen}
|
|
>
|
|
<header className="cli-session-terminal__header">
|
|
{posture && (
|
|
<div className="cli-session-terminal__posture-wrap">
|
|
<button
|
|
type="button"
|
|
className={`cli-posture-chip${elevated ? " cli-posture-chip--elevated" : ""}`}
|
|
data-elevated={elevated}
|
|
aria-expanded={postureTooltipOpen}
|
|
onClick={() => setPostureTooltipOpen((v) => !v)}
|
|
>
|
|
{elevated ? (
|
|
<ShieldAlert size={13} aria-hidden="true" />
|
|
) : (
|
|
<TerminalIcon size={13} aria-hidden="true" />
|
|
)}
|
|
<span className="cli-posture-chip__name">{posture.adapterName}</span>
|
|
{posture.mode && (
|
|
<span className="cli-posture-chip__mode">{posture.mode}</span>
|
|
)}
|
|
{elevated && flagSummary && (
|
|
<span className="cli-posture-chip__flag">{flagSummary}</span>
|
|
)}
|
|
</button>
|
|
{postureTooltipOpen && (
|
|
<div className="cli-posture-tooltip" role="tooltip">
|
|
<p className="cli-posture-tooltip__title">
|
|
{t("cliTerminal.postureResolved", "Resolved posture")}
|
|
</p>
|
|
<ul className="cli-posture-tooltip__list">
|
|
{(posture.resolved ?? []).map((line, i) => (
|
|
<li key={i}>{line}</li>
|
|
))}
|
|
{(posture.resolved ?? []).length === 0 && (
|
|
<li>{posture.mode ?? t("cliTerminal.postureBaseline", "Baseline")}</li>
|
|
)}
|
|
</ul>
|
|
{onOpenAdapterSettings && (
|
|
<button
|
|
type="button"
|
|
className="cli-posture-tooltip__settings"
|
|
onClick={() => {
|
|
setPostureTooltipOpen(false);
|
|
onOpenAdapterSettings();
|
|
}}
|
|
>
|
|
<Settings size={12} aria-hidden="true" />
|
|
{t("cliTerminal.adapterSettings", "Adapter settings")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{readOnly && (
|
|
<span className="cli-session-terminal__readonly-badge">
|
|
<Eye size={12} aria-hidden="true" />
|
|
{t("cliTerminal.readOnly", "Read-only")}
|
|
</span>
|
|
)}
|
|
{replayLabel && (
|
|
<span className="cli-session-terminal__replay-badge" data-replay-mode={mode}>
|
|
{replayLabel}
|
|
</span>
|
|
)}
|
|
</header>
|
|
|
|
<div
|
|
className="cli-session-terminal__viewport"
|
|
ref={containerRef}
|
|
data-testid="cli-terminal-viewport"
|
|
style={terminalGlyphStyle}
|
|
/>
|
|
|
|
{showConfirmAdvance && !advanceDismissed && (
|
|
<div className="cli-session-terminal__advance-strip" role="region">
|
|
<span className="cli-session-terminal__advance-copy">
|
|
{t(
|
|
"cliTerminal.advancePrompt",
|
|
"This session looks idle — advance to review?",
|
|
)}
|
|
</span>
|
|
<div className="cli-session-terminal__advance-actions">
|
|
<button
|
|
type="button"
|
|
className="cli-session-terminal__advance-btn"
|
|
disabled={advancePending}
|
|
onClick={handleAdvance}
|
|
>
|
|
{t("cliTerminal.advance", "Advance")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-session-terminal__advance-btn cli-session-terminal__advance-btn--secondary"
|
|
disabled={advancePending}
|
|
onClick={handleNotYet}
|
|
>
|
|
{t("cliTerminal.notYet", "Not yet")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isMobile && !readOnly && (
|
|
<div
|
|
className={`cli-session-terminal__mobile-bar${
|
|
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
|
|
}`}
|
|
data-testid="cli-terminal-mobile-bar"
|
|
style={
|
|
// Lift the fixed footer above the virtual keyboard when it's open.
|
|
keyboardOpen ? { bottom: `${keyboardOverlap}px` } : undefined
|
|
}
|
|
>
|
|
<div
|
|
className="cli-session-terminal__key-row"
|
|
data-testid="cli-terminal-key-bar"
|
|
>
|
|
<button
|
|
type="button"
|
|
className={`cli-terminal-key cli-terminal-key--ctrl${
|
|
ctrlSticky ? " cli-terminal-key--active" : ""
|
|
}`}
|
|
data-testid="cli-key-ctrl"
|
|
aria-label={t("cliTerminal.mobileKeyCtrl", "Sticky Ctrl modifier")}
|
|
aria-pressed={ctrlSticky}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => setCtrlSticky((v) => !v)}
|
|
>
|
|
{CLI_TERMINAL_KEY_LABELS.ctrl}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-terminal-key"
|
|
data-testid="cli-key-esc"
|
|
aria-label={t("cliTerminal.mobileKeyEsc", "Send Escape")}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => emitBarKey(SEQ_ESC)}
|
|
>
|
|
{CLI_TERMINAL_KEY_LABELS.escape}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-terminal-key"
|
|
data-testid="cli-key-tab"
|
|
aria-label={t("cliTerminal.mobileKeyTab", "Send Tab")}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => emitBarKey(SEQ_TAB)}
|
|
>
|
|
{CLI_TERMINAL_KEY_LABELS.tab}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-terminal-key cli-terminal-key--ctrlc"
|
|
data-testid="cli-key-ctrl-c"
|
|
aria-label={t("cliTerminal.mobileKeyCtrlC", "Send Ctrl-C")}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => {
|
|
// Dedicated shortcut: always Ctrl-C, regardless of sticky state.
|
|
setCtrlSticky(false);
|
|
sendInput(SEQ_CTRL_C);
|
|
}}
|
|
>
|
|
{CLI_TERMINAL_KEY_LABELS.ctrlC}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-terminal-key"
|
|
data-testid="cli-key-arrow-up"
|
|
aria-label={t("cliTerminal.mobileKeyArrowUp", "Cursor up")}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => emitBarKey(SEQ_ARROW_UP)}
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-terminal-key"
|
|
data-testid="cli-key-arrow-down"
|
|
aria-label={t("cliTerminal.mobileKeyArrowDown", "Cursor down")}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => emitBarKey(SEQ_ARROW_DOWN)}
|
|
>
|
|
↓
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-terminal-key"
|
|
data-testid="cli-key-arrow-left"
|
|
aria-label={t("cliTerminal.mobileKeyArrowLeft", "Cursor left")}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => emitBarKey(SEQ_ARROW_LEFT)}
|
|
>
|
|
←
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="cli-terminal-key"
|
|
data-testid="cli-key-arrow-right"
|
|
aria-label={t("cliTerminal.mobileKeyArrowRight", "Cursor right")}
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => emitBarKey(SEQ_ARROW_RIGHT)}
|
|
>
|
|
→
|
|
</button>
|
|
</div>
|
|
<form
|
|
className="cli-session-terminal__input-row"
|
|
onSubmit={handleMobileSubmit}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
className="cli-session-terminal__mobile-input"
|
|
data-testid="cli-terminal-mobile-input"
|
|
value={mobileInput}
|
|
placeholder={t(
|
|
"cliTerminal.mobileInputPlaceholder",
|
|
"Type to send to the session…",
|
|
)}
|
|
autoCapitalize="off"
|
|
autoCorrect="off"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
onChange={(e) => handleMobileInputChange(e.target.value)}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="cli-session-terminal__mobile-send"
|
|
data-testid="cli-terminal-mobile-send"
|
|
aria-label={t("cliTerminal.mobileSend", "Send")}
|
|
// iOS pattern: act on click, preventDefault on pointer/mouse down
|
|
// so the input doesn't blur (which dismisses the keyboard).
|
|
onPointerDown={keepFocus}
|
|
onMouseDown={keepFocus}
|
|
onClick={() => handleMobileSubmit()}
|
|
>
|
|
{t("cliTerminal.mobileSend", "Send")}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|