[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
/**
* Custom Theme Editor Panel
* Inline color editor for creating/editing custom terminal themes.
* Uses native <input type="color"> for zero-dependency color picking.
*/
import React, { useCallback, memo } from 'react';
import { TerminalTheme } from '../../domain/models';
import { useI18n } from '../../application/i18n/I18nProvider';
interface ColorFieldDef {
key: keyof TerminalTheme['colors'];
labelKey: string;
}
const GENERAL_COLORS: ColorFieldDef[] = [
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
];
const NORMAL_COLORS: ColorFieldDef[] = [
{ key: 'black', labelKey: 'terminal.customTheme.color.black' },
{ key: 'red', labelKey: 'terminal.customTheme.color.red' },
{ key: 'green', labelKey: 'terminal.customTheme.color.green' },
{ key: 'yellow', labelKey: 'terminal.customTheme.color.yellow' },
{ key: 'blue', labelKey: 'terminal.customTheme.color.blue' },
{ key: 'magenta', labelKey: 'terminal.customTheme.color.magenta' },
{ key: 'cyan', labelKey: 'terminal.customTheme.color.cyan' },
{ key: 'white', labelKey: 'terminal.customTheme.color.white' },
];
const BRIGHT_COLORS: ColorFieldDef[] = [
{ key: 'brightBlack', labelKey: 'terminal.customTheme.color.brightBlack' },
{ key: 'brightRed', labelKey: 'terminal.customTheme.color.brightRed' },
{ key: 'brightGreen', labelKey: 'terminal.customTheme.color.brightGreen' },
{ key: 'brightYellow', labelKey: 'terminal.customTheme.color.brightYellow' },
{ key: 'brightBlue', labelKey: 'terminal.customTheme.color.brightBlue' },
{ key: 'brightMagenta', labelKey: 'terminal.customTheme.color.brightMagenta' },
{ key: 'brightCyan', labelKey: 'terminal.customTheme.color.brightCyan' },
{ key: 'brightWhite', labelKey: 'terminal.customTheme.color.brightWhite' },
];
const ColorInput = memo(({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) => {
// Local state for text input — allows partial hex while typing
const [textValue, setTextValue] = React.useState(value);
// Sync external value changes into local state
React.useEffect(() => { setTextValue(value); }, [value]);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
if (!/^#[0-9a-fA-F]{0,6}$/.test(v)) return;
setTextValue(v);
// Only commit complete hex values (#rgb or #rrggbb)
if (/^#[0-9a-fA-F]{3}$/.test(v) || /^#[0-9a-fA-F]{6}$/.test(v)) {
// Normalize #rgb to #rrggbb
const normalized = v.length === 4
? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`
: v;
onChange(normalized);
}
};
// On blur, revert to the last committed value if incomplete
const handleBlur = () => { setTextValue(value); };
return (
<div className="flex items-center gap-2">
<div className="relative">
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
style={{ appearance: 'none', WebkitAppearance: 'none', background: value }}
/>
</div>
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
<input
type="text"
value={textValue}
onChange={handleTextChange}
onBlur={handleBlur}
className="w-[68px] text-[10px] font-mono px-1.5 py-0.5 rounded border border-border bg-background text-foreground uppercase"
spellCheck={false}
/>
</div>
);
});
ColorInput.displayName = 'ColorInput';
interface CustomThemeEditorProps {
theme: TerminalTheme;
onChange: (theme: TerminalTheme) => void;
onBack?: () => void; // kept for API compat but no longer rendered
isNew?: boolean;
}
export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
theme,
onChange,
onBack: _onBack,
isNew: _isNew,
}) => {
const { t } = useI18n();
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
onChange({
...theme,
colors: { ...theme.colors, [key]: value },
});
}, [theme, onChange]);
const updateName = useCallback((name: string) => {
onChange({ ...theme, name });
}, [theme, onChange]);
const toggleType = useCallback(() => {
onChange({ ...theme, type: theme.type === 'dark' ? 'light' : 'dark' });
}, [theme, onChange]);
const renderColorGroup = (title: string, fields: ColorFieldDef[]) => (
<div>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
{title}
</div>
<div className="space-y-1">
{fields.map(({ key, labelKey }) => (
<ColorInput
key={key}
label={t(labelKey)}
value={theme.colors[key]}
onChange={(v) => updateColor(key, v)}
/>
))}
</div>
</div>
);
return (
<div className="flex flex-col h-full">
{/* Name + Type */}
<div className="p-2 space-y-2 border-b border-border shrink-0">
<div>
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
{t('terminal.customTheme.name')}
</label>
<input
type="text"
value={theme.name}
onChange={(e) => updateName(e.target.value)}
className="w-full mt-1 text-xs px-2 py-1.5 rounded border border-border bg-background text-foreground"
placeholder={t('terminal.customTheme.namePlaceholder')}
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold flex-1">
{t('terminal.customTheme.type')}
</label>
<button
onClick={toggleType}
className="text-[10px] px-2 py-0.5 rounded border border-border bg-muted/30 text-foreground hover:bg-muted transition-colors capitalize"
>
{theme.type}
</button>
</div>
</div>
{/* Color Groups */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{renderColorGroup(t('terminal.customTheme.group.general'), GENERAL_COLORS)}
{renderColorGroup(t('terminal.customTheme.group.normal'), NORMAL_COLORS)}
{renderColorGroup(t('terminal.customTheme.group.bright'), BRIGHT_COLORS)}
</div>
</div>
);
};

View File

@@ -0,0 +1,232 @@
/**
* Dedicated Custom Theme Editor Modal
* Standalone modal with two-column layout: editor (left) + preview (right)
* Opens on top of ThemeCustomizeModal for creating/editing custom themes.
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Trash2, X } from 'lucide-react';
import { TerminalTheme } from '../../domain/models';
import { useI18n } from '../../application/i18n/I18nProvider';
import { CustomThemeEditor } from './CustomThemeEditor';
import { Button } from '../ui/button';
import { isAppLockOverlayActive } from '../../infrastructure/appLockOverlayDom';
interface CustomThemeModalProps {
open: boolean;
theme: TerminalTheme;
isNew: boolean;
onSave: (theme: TerminalTheme) => void;
onDelete?: (themeId: string) => void;
onCancel: () => void;
}
// Minimal terminal preview for the right panel
const MiniPreview: React.FC<{ theme: TerminalTheme }> = ({ theme }) => (
<div
className="rounded-lg border border-border/50 overflow-hidden font-mono text-[11px] leading-relaxed flex-1"
style={{ backgroundColor: theme.colors.background, color: theme.colors.foreground }}
>
{/* Title bar */}
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-black/20">
<div className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
<div className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
<span className="flex-1 text-center text-[10px] opacity-50">Terminal Preview</span>
</div>
<div className="p-3 space-y-0.5">
<div>
<span style={{ color: theme.colors.green }}>user@server</span>
<span style={{ color: theme.colors.foreground }}>:</span>
<span style={{ color: theme.colors.blue }}>~</span>
<span style={{ color: theme.colors.foreground }}>$ neofetch</span>
</div>
<div style={{ color: theme.colors.cyan }}>{' ,g$$P" """Y$$."". '}</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` ,$$P' `}</span>
<span style={{ color: theme.colors.blue }}>OS</span>
<span>: Ubuntu 22.04 LTS</span>
</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` '',$$P `}</span>
<span style={{ color: theme.colors.blue }}>Kernel</span>
<span>: 5.15.0-generic</span>
</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` d$$' `}</span>
<span style={{ color: theme.colors.blue }}>Shell</span>
<span>: bash 5.1.16</span>
</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` $$P `}</span>
<span style={{ color: theme.colors.blue }}>Memory</span>
<span>: 4.2G / 16G (26%)</span>
</div>
<div>&nbsp;</div>
{/* ANSI color palette */}
<div className="flex gap-0.5">
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div className="flex gap-0.5">
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div>&nbsp;</div>
<div>
<span style={{ color: theme.colors.green }}>user@server</span>
<span>:</span>
<span style={{ color: theme.colors.blue }}>~</span>
<span>$ </span>
<span style={{ backgroundColor: theme.colors.cursor, color: theme.colors.background }}>&nbsp;</span>
</div>
</div>
</div>
);
export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
open,
theme: initialTheme,
isNew,
onSave,
onDelete,
onCancel,
}) => {
const { t } = useI18n();
const [editingTheme, setEditingTheme] = useState<TerminalTheme>(initialTheme);
// Reset when opened with a new theme
React.useEffect(() => {
if (open) {
setEditingTheme({ ...initialTheme, colors: { ...initialTheme.colors } });
}
}, [open, initialTheme]);
const handleChange = useCallback((theme: TerminalTheme) => {
setEditingTheme(theme);
}, []);
const handleSave = useCallback(() => {
onSave(editingTheme);
}, [editingTheme, onSave]);
const handleDelete = useCallback(() => {
onDelete?.(editingTheme.id);
}, [editingTheme.id, onDelete]);
// Dummy back handler — in the standalone modal, back = cancel
const handleBack = useCallback(() => {
onCancel();
}, [onCancel]);
const themeInfo = useMemo(() => {
return `${editingTheme.name}${editingTheme.type.toUpperCase()}`;
}, [editingTheme.name, editingTheme.type]);
// Handle Escape key — close child editor
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown, true); // capture phase
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [open, onCancel]);
if (!open) return null;
const modalContent = (
<div
className="fixed inset-0 z-[300] flex items-center justify-center"
>
{/* Backdrop — clicking it dismisses the modal */}
<div className="absolute inset-0 bg-black/60 supports-[backdrop-filter]:backdrop-blur-sm" onClick={onCancel} />
{/* Modal */}
<div className="relative z-10 bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm rounded-xl shadow-2xl border border-border/50 flex flex-col"
style={{ width: 'min(820px, 90vw)', height: 'min(600px, 85vh)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
<h2 className="text-sm font-semibold text-foreground">
{isNew ? t('terminal.customTheme.newTitle') : t('terminal.customTheme.editTitle')}
</h2>
<button
onClick={onCancel}
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X size={16} />
</button>
</div>
{/* Body: Editor (left) + Preview (right) */}
<div className="flex flex-1 min-h-0">
{/* Left: Editor */}
<div className="w-[300px] shrink-0 border-r border-border flex flex-col min-h-0">
<CustomThemeEditor
theme={editingTheme}
onChange={handleChange}
onBack={handleBack}
isNew={isNew}
/>
</div>
{/* Right: Preview */}
<div className="flex-1 flex flex-col p-4 min-w-0">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{t('terminal.themeModal.livePreview')}
</div>
<MiniPreview theme={editingTheme} />
<div className="mt-2 text-xs text-muted-foreground text-center">
{themeInfo}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
{/* Delete button (only for existing themes) */}
{!isNew && onDelete && (
<Button
variant="ghost"
onClick={handleDelete}
className="h-9 text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5"
>
<Trash2 size={14} />
{t('terminal.customTheme.delete')}
</Button>
)}
<div className="flex-1" />
<Button
variant="ghost"
onClick={onCancel}
className="h-9 px-5"
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSave}
className="h-9 px-6"
>
{t('common.save')}
</Button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default CustomThemeModal;

View File

@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { decideGhostSuggestion } from "./autocomplete/ghostSuggestionPolicy.ts";
test("keeps the active ghost suggestion while input still fits it", () => {
const decision = decideGhostSuggestion("docker ps -a", "doc", "docker compose ls");
assert.deepEqual(decision, { type: "keep" });
});
test("switches to a new suggestion once the active one no longer matches", () => {
const decision = decideGhostSuggestion("docker ps -a", "dog", "dogstatsd");
assert.deepEqual(decision, { type: "show", suggestion: "dogstatsd" });
});
test("hides the ghost when neither the active nor next suggestion matches", () => {
const decision = decideGhostSuggestion("docker ps -a", "dog", null);
assert.deepEqual(decision, { type: "hide" });
});

View File

@@ -0,0 +1,918 @@
import test from "node:test";
import assert from "node:assert/strict";
import { GhostTextAddon } from "./autocomplete/GhostTextAddon.ts";
type RenderListener = () => void;
type ResizeListener = () => void;
class FakeElement {
public readonly style: Record<string, string> = {};
public textContent = "";
public className = "";
public children: FakeElement[] = [];
appendChild(child: FakeElement): FakeElement {
this.children.push(child);
return child;
}
insertBefore(child: FakeElement, referenceNode: FakeElement | null): FakeElement {
if (!referenceNode) {
this.children.push(child);
return child;
}
const index = this.children.indexOf(referenceNode);
if (index < 0) {
this.children.push(child);
return child;
}
this.children.splice(index, 0, child);
return child;
}
remove(): void {
// No-op for tests.
}
querySelector(selector: string): FakeElement | null {
if (selector === ".xterm-screen") {
return this.children.find((child) => child.className === "xterm-screen") ?? null;
}
return null;
}
}
function installFakeDocument(): () => void {
const previousDocument = globalThis.document;
const fakeDocument = {
createElement() {
return new FakeElement();
},
} as unknown as Document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: fakeDocument,
});
return () => {
if (previousDocument === undefined) {
delete (globalThis as { document?: Document }).document;
return;
}
Object.defineProperty(globalThis, "document", {
configurable: true,
value: previousDocument,
});
};
}
function createFakeTerm() {
const renderListeners: RenderListener[] = [];
const resizeListeners: ResizeListener[] = [];
const element = new FakeElement();
const screen = new FakeElement();
screen.className = "xterm-screen";
element.appendChild(screen);
const term = {
element,
cols: 80,
rows: 24,
options: {
fontSize: 14,
fontFamily: "monospace",
},
buffer: {
active: {
cursorX: 2,
cursorY: 0,
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: {
width: 9,
height: 18,
},
},
},
},
},
onRender(listener: RenderListener) {
renderListeners.push(listener);
return {
dispose() {
const index = renderListeners.indexOf(listener);
if (index >= 0) renderListeners.splice(index, 1);
},
};
},
onResize(listener: ResizeListener) {
resizeListeners.push(listener);
return {
dispose() {
const index = resizeListeners.indexOf(listener);
if (index >= 0) resizeListeners.splice(index, 1);
},
};
},
};
return {
term,
ghostElement: () => screen.children[0]?.children[0] ?? null,
fireRender() {
for (const listener of [...renderListeners]) listener();
},
};
}
test("shifts ghost to predicted cursor column as matching input is typed", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.display, "block");
assert.equal(ghost.textContent, "cker");
// show() anchored at cursorX=2, cell width=9 → left=18.
assert.equal(ghost.style.left, "18px");
addon.adjustToInput("doc");
// After one matching char, the ghost predicts the cursor has moved
// to column 3 and trims "c" from the tail so the next char starts
// where the echo will land. Not waiting for xterm's render keeps
// ghost + real input aligned across SSH echo latency.
assert.equal(ghost.style.display, "block");
assert.equal(ghost.textContent, "ker");
assert.equal(ghost.style.left, "27px");
assert.equal(addon.getGhostText(), "ker");
} finally {
restoreDocument();
}
});
test("walks the anchor column backwards on backspace so the ghost re-aligns", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
addon.adjustToInput("doc");
assert.equal(ghost.textContent, "ker");
assert.equal(ghost.style.left, "27px");
// Backspace below the anchor input — the ghost should shift *left*,
// not stay pinned at the show-time anchor column. Pinning would
// leave a visual gap between the real cursor and the ghost.
addon.adjustToInput("d");
assert.equal(ghost.textContent, "ocker");
// anchor was cursorX=2 captured at show(); "d" is 1 char below
// anchorInputLength=2 → predicted cursor column = 1.
assert.equal(ghost.style.left, "9px");
// Backspace past the anchor back to empty: left is clamped at 0.
addon.adjustToInput("");
assert.equal(ghost.textContent, "docker");
assert.equal(ghost.style.left, "0px");
} finally {
restoreDocument();
}
});
test("advances the anchor by two cells when a CJK glyph is typed", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// Suggestion starts with a CJK char so the prefix-match survives
// the next keystroke.
addon.show("你好世界", "");
const ghost = ghostElement();
assert.ok(ghost);
// show() anchored at cursorX=2. Input length 0 → delta 0 → left=18.
assert.equal(ghost.style.left, "18px");
addon.adjustToInput("你");
// One CJK char = 2 cells. Predicted col = 2 + 2 = 4 → left 36px.
assert.equal(ghost.textContent, "好世界");
assert.equal(ghost.style.left, "36px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the next row when the predicted column crosses cols", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Shrink the terminal to 10 cols to keep the math obvious. Anchor at
// col 8 with 5 ASCII chars to type → predicted col = 13, which should
// wrap to col 3 of row 1.
term.cols = 10;
term.buffer.active.cursorX = 8;
addon.activate(term as never);
addon.show("abcdefghij", "ab");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.top, "0px");
addon.adjustToInput("abcde");
// Predicted col = 8 + (5-2) = 11 → wraps to col 1 on next row.
// cellWidth=9, cellHeight=18.
assert.equal(ghost.textContent, "fghij");
assert.equal(ghost.style.left, "9px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("self-heals a stale anchor on render while no adjustToInput has fired", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// show() captures cursorX=2 — simulate this firing during the
// keystroke→echo gap by later advancing the live cursor and
// verifying the ghost anchor snaps to the echoed position.
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.left, "18px");
term.buffer.active.cursorX = 5;
fireRender();
// Input hasn't moved from the show-time baseline, so updatePosition
// re-reads live cursor: new left = 5 * 9 = 45px.
assert.equal(ghost.style.left, "45px");
} finally {
restoreDocument();
}
});
test("self-heal adopts live X/Y when echo wraps instead of double-counting", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Prompt ends at col 8; four pending cells predict X=12 on a 10-col
// terminal (wraps to col 2 / row 1). When echo lands there, Math.max
// on X alone would keep 12 and paint the ghost on row 2.
term.cols = 10;
term.buffer.active.cursorX = 8;
term.buffer.active.cursorY = 0;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
addon.show("abcdefghij", "abcd");
const ghost = ghostElement();
assert.ok(ghost);
// Predicted wrap: col 2 on row 1 → left 18px, top 18px.
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "18px");
term.buffer.active.cursorX = 2;
term.buffer.active.cursorY = 1;
term.buffer.active.getLine = () => ({
isWrapped: true,
translateToString: () => "abcd",
});
fireRender();
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("self-heal adopts live X when a bottom-row wrap scrolls instead of changing Y", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Bottom-row prediction: prompt at col 8 + 4 pending cells → X=12.
// Echo scrolls the buffer so Y stays on the last row while live X
// becomes the normalized wrap column (2). Math.max would keep 12 and
// paint the ghost one row below the visible screen.
term.cols = 10;
term.rows = 24;
term.buffer.active.cursorX = 8;
term.buffer.active.cursorY = 23;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
addon.show("abcdefghij", "abcd");
const ghost = ghostElement();
assert.ok(ghost);
// Pre-echo: col 2 on predicted row 24 → top 24*18.
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "432px");
term.buffer.active.cursorX = 2;
term.buffer.active.cursorY = 23;
term.buffer.active.getLine = () => ({
isWrapped: true,
translateToString: () => "abcd",
});
fireRender();
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "414px");
} finally {
restoreDocument();
}
});
test("anchors ghost after wide pre-echo input when the line has not echoed yet", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
// Live cursor still at the prompt; typed "部署" is only in the keystroke buffer.
addon.show("部署脚本", "部署");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "脚本");
// cursorX=2 + 4 wide cells → column 6 → left 54px.
assert.equal(ghost.style.left, "54px");
} finally {
restoreDocument();
}
});
test("anchors ghost using only the unechoed suffix after a partial shell echo", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Shell has echoed "$ doc"; buffered input is still the full "docker".
term.buffer.active.cursorX = 5;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ doc",
});
addon.activate(term as never);
addon.show("docker compose", "docker");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " compose");
// Unechoed "ker" is 3 cells → column 8 → left 72px (not 5+6=11).
assert.equal(ghost.style.left, "72px");
} finally {
restoreDocument();
}
});
test("anchors ghost by cell columns when the prompt is a multi-code-unit emoji", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Family ZWJ emoji is many UTF-16 units but only 2 terminal cells.
// A UTF-16 slice(0, cursorX) would stop inside the emoji and treat the
// fully-echoed "docker" as unechoed, shifting the ghost right.
const emoji = "👨‍👩‍👧‍👦";
const cells: Array<{ chars: string; width: number }> = [
{ chars: emoji, width: 2 },
{ chars: " ", width: 1 },
{ chars: "$", width: 1 },
{ chars: " ", width: 1 },
...Array.from("docker", (ch) => ({ chars: ch, width: 1 })),
];
const totalCols = cells.reduce((sum, cell) => sum + cell.width, 0);
term.buffer.active.cursorX = totalCols;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = totalCols,
) => {
let col = 0;
let text = "";
for (const cell of cells) {
if (col >= endColumn) break;
if (col >= startColumn) text += cell.chars;
col += cell.width;
}
return text;
},
});
addon.activate(term as never);
addon.show("docker compose", "docker");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " compose");
// Fully echoed → stay at live cursor (11 cells → 99px), not 11+6.
assert.equal(ghost.style.left, "99px");
} finally {
restoreDocument();
}
});
test("does not overshoot the ghost when the echoed command already wrapped", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// cols=10, prompt "$ " + "docker com" wraps; cursor sits on the
// continuation row after a fully-echoed "docker compose".
// Physical rows: "$ docker c" | "ompose|"
term.cols = 10;
term.buffer.active.baseY = 0;
term.buffer.active.cursorY = 1;
term.buffer.active.cursorX = 6;
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
0: { text: "$ docker c" },
1: { isWrapped: true, text: "ompose" },
};
term.buffer.active.getLine = (y: number) => {
const row = lines[y];
if (!row) return undefined;
return {
isWrapped: row.isWrapped,
translateToString: (
_trimRight?: boolean,
startColumn?: number,
endColumn?: number,
) => {
if (startColumn !== undefined && endColumn !== undefined) {
return row.text.padEnd(endColumn).slice(startColumn, endColumn);
}
return row.text;
},
};
};
addon.activate(term as never);
addon.show("docker compose up", "docker compose");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " up");
// Fully echoed across the wrap → anchor stays at live cursor (col 6),
// not liveX + full input width.
assert.equal(ghost.style.left, "54px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
term.cols = 10;
term.buffer.active.cursorX = 1;
term.buffer.active.cursorY = 1;
addon.activate(term as never);
// Anchored at row 1 col 1 with 5 chars already typed.
addon.show("abcdefghij", "abcde");
const ghost = ghostElement();
assert.ok(ghost);
// Backspace back to 2 chars — delta = -3 across a row boundary.
addon.adjustToInput("ab");
// targetCol = 1 - 3 = -2 → col = 8 (wrapped) on row 0.
assert.equal(ghost.textContent, "cdefghij");
assert.equal(ghost.style.left, "72px");
assert.equal(ghost.style.top, "0px");
} finally {
restoreDocument();
}
});
test("hides ghost immediately when input no longer matches suggestion", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.display, "block");
addon.adjustToInput("dox");
assert.equal(ghost.style.display, "none");
assert.equal(ghost.textContent, "");
assert.equal(addon.isActive(), false);
} finally {
restoreDocument();
}
});
test("applyKeystroke: printable char trims ghost tail when buffer is unreliable (issue #906)", () => {
// Repro for issue #906: after Tab passes to shell and the typed-buffer
// is flagged unreliable, the ghost addon's currentInput is the only
// source of truth for what the user has typed since the last show().
// Without applyKeystroke, line 798's reliability gate prevents
// adjustToInput from firing and the ghost retains its show-time tail
// — when the next keystroke advances the cursor, the stale tail
// overlaps the just-typed glyph (e.g., typing 't' after 'systemctl s'
// makes the screen read 'systemctl sttop firewalld').
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("systemctl stop firewalld", "systemctl s");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "top firewalld");
addon.applyKeystroke("t");
// Ghost tail must shrink by exactly one char so when the shell
// echoes 't', the next visible glyph after the cursor is 'o', not
// 't' (which would render as 'sttop').
assert.equal(ghost.textContent, "op firewalld");
assert.equal(addon.isActive(), true);
} finally {
restoreDocument();
}
});
test("applyKeystroke: backspace re-grows ghost tail by one char", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "doc");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "ker");
addon.applyKeystroke("\x7f");
assert.equal(ghost.textContent, "cker");
} finally {
restoreDocument();
}
});
test("applyKeystroke: Ctrl+W word-erases trailing word from currentInput", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// Mid-suggestion: user has typed two words; Ctrl+W should drop the
// tail word and let the ghost regrow to cover what was erased.
addon.show("git commit -m wip", "git com");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "mit -m wip");
addon.applyKeystroke("\x17");
// The same /\s*\S+\s*$/ regex used by handleInput consumes the
// leading whitespace too, so "git com" → "git"; the ghost regrows
// to cover the now-uncovered leading space + remainder.
assert.equal(ghost.textContent, " commit -m wip");
} finally {
restoreDocument();
}
});
test("applyKeystroke: hides ghost when next char diverges from suggestion", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
// 'x' breaks the prefix invariant — ghost must hide immediately so
// a → -accept after this point can't pull a stale tail onto a line
// that no longer matches the suggestion.
addon.applyKeystroke("x");
assert.equal(ghost.style.display, "none");
assert.equal(addon.isActive(), false);
} finally {
restoreDocument();
}
});
test("applyKeystroke: ignores non-typing data (escape sequences, control codes)", () => {
// Escape sequences and other control codes are routed through
// clearState() in handleInput, not propagated to the ghost — but we
// want applyKeystroke to be a safe no-op if accidentally called with
// them (defense in depth).
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
const tailBefore = ghost.textContent;
addon.applyKeystroke("\x1b[A"); // up-arrow escape sequence
addon.applyKeystroke("\x01"); // Ctrl+A
addon.applyKeystroke(""); // empty
assert.equal(ghost.textContent, tailBefore);
assert.equal(addon.isActive(), true);
} finally {
restoreDocument();
}
});
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// We believe only "network in" is typed; suggestion is the full command.
addon.show("network interface show", "network in");
assert.equal(addon.isActive(), true);
// The real line shows MORE than we tracked: a bastion host echoed the
// next char ("t") that our client-side buffer never recorded.
const line = "ecOS# network int";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = line.length;
active.getLine = () => ({ translateToString: () => line });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when TopsecOS-style backspace leaves stale text after the cursor (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// The user deleted back from "system..." to "syst", so the tracked
// input is shorter and the ghost regrows to "em license show".
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
// TopsecOS devices shown in #1060 leave the old suffix visible after
// the cursor while processing Backspace, so the real buffer looks like:
// TopsecOS# syst|em license show
const beforeCursor = "TopsecOS# syst";
const line = `${beforeCursor}em license show`;
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => line });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when only the deleted prefix remains after the cursor (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}em` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when TopsecOS-style backspace leaves stale text after empty input (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# ";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}system license show` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when stale text is followed by right-side status text (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}em 12:34 ok` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when a stale argument suffix starts with a space (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "system");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# system";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor} license show` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("keeps the ghost when unrelated right-side prompt text is visible", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "host# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
fireRender();
assert.equal(addon.isActive(), true);
assert.notEqual(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("keeps the ghost when only right-side spacing overlaps a space-prefixed suffix", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "system");
assert.equal(addon.isActive(), true);
const beforeCursor = "host# system";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
fireRender();
assert.equal(addon.isActive(), true);
assert.notEqual(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("keeps the ghost when adjacent right-side text only shares the first character", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "host# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}error` });
fireRender();
assert.equal(addon.isActive(), true);
assert.notEqual(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host, KeywordHighlightRule } from "../../types.ts";
import { addHostKeywordHighlightRule } from "./HostKeywordHighlightPopover.tsx";
const baseHost: Host = {
id: "host-1",
label: "Production",
hostname: "prod.example.com",
username: "root",
tags: [],
os: "linux",
keywordHighlightEnabled: false,
keywordHighlightRules: [
{
id: "old-rule",
label: "Old rule",
patterns: ["OLD"],
color: "#FBBF24",
enabled: true,
},
],
};
const newRule: KeywordHighlightRule = {
id: "new-rule",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
};
test("adding a host keyword highlight rule enables host highlighting", () => {
const updated = addHostKeywordHighlightRule(baseHost, newRule);
assert.equal(updated.keywordHighlightEnabled, true);
assert.deepEqual(updated.keywordHighlightRules, [
...(baseHost.keywordHighlightRules ?? []),
newRule,
]);
});

View File

@@ -0,0 +1,318 @@
/**
* Host Keyword Highlight Popover
* Allows users to manage host-specific keyword highlighting rules in the terminal statusbar
*/
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
import React, { useState, useCallback, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host, KeywordHighlightRule } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface HostKeywordHighlightPopoverProps {
host?: Host;
onUpdateHost?: (host: Host) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
buttonClassName?: string;
}
const DEFAULT_NEW_RULE_COLOR = '#F87171';
export function addHostKeywordHighlightRule(host: Host, rule: KeywordHighlightRule): Host {
return {
...host,
keywordHighlightRules: [...(host.keywordHighlightRules ?? []), rule],
keywordHighlightEnabled: true,
};
}
export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverProps> = ({
host,
onUpdateHost,
isOpen,
setIsOpen,
buttonClassName = '',
}) => {
const { t } = useI18n();
const [newRuleLabel, setNewRuleLabel] = useState('');
const [newRulePattern, setNewRulePattern] = useState('');
const [newRuleColor, setNewRuleColor] = useState(DEFAULT_NEW_RULE_COLOR);
const [patternError, setPatternError] = useState<string | null>(null);
const rules = useMemo(() => host?.keywordHighlightRules ?? [], [host?.keywordHighlightRules]);
const enabled = host?.keywordHighlightEnabled ?? false;
const updateRules = useCallback((newRules: KeywordHighlightRule[]) => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, keywordHighlightRules: newRules });
}, [host, onUpdateHost]);
const toggleEnabled = useCallback(() => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, keywordHighlightEnabled: !enabled });
}, [host, onUpdateHost, enabled]);
const validatePattern = (pattern: string): boolean => {
try {
new RegExp(pattern, 'gi');
return true;
} catch {
return false;
}
};
const handleAddRule = useCallback(() => {
if (!newRuleLabel.trim() || !newRulePattern.trim()) {
return;
}
if (!validatePattern(newRulePattern)) {
setPatternError(t('terminal.toolbar.hostHighlight.invalidPattern'));
return;
}
const newRule: KeywordHighlightRule = {
id: crypto.randomUUID(),
label: newRuleLabel.trim(),
patterns: [newRulePattern.trim()],
color: newRuleColor,
enabled: true,
};
if (host && onUpdateHost) {
onUpdateHost(addHostKeywordHighlightRule(host, newRule));
}
// Reset form
setNewRuleLabel('');
setNewRulePattern('');
setNewRuleColor(DEFAULT_NEW_RULE_COLOR);
setPatternError(null);
}, [newRuleLabel, newRulePattern, newRuleColor, host, onUpdateHost, t]);
const handleDeleteRule = useCallback((ruleId: string) => {
updateRules(rules.filter((r) => r.id !== ruleId));
}, [rules, updateRules]);
const handleColorChange = useCallback((ruleId: string, color: string) => {
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, color } : r)));
}, [rules, updateRules]);
const handleToggleRule = useCallback((ruleId: string) => {
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)));
}, [rules, updateRules]);
const handleClearAll = useCallback(() => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, keywordHighlightRules: [], keywordHighlightEnabled: false });
}, [host, onUpdateHost]);
const handlePatternChange = (value: string) => {
setNewRulePattern(value);
if (patternError && validatePattern(value)) {
setPatternError(null);
}
};
// Disable if no host (local/serial terminal sessions)
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const isDisabled = !host || !onUpdateHost || isLocalTerminal || isSerialTerminal;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
{/* Force-close tooltip while the panel is open so the blue label does not
sit on top of the trigger/panel (especially in the compact top-right
cluster when the host info bar is hidden). */}
<Tooltip open={isOpen ? false : undefined}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonClassName}
aria-label={t('terminal.toolbar.hostHighlight.title')}
disabled={isDisabled}
>
<Highlighter size={12} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
{/* Toolbar sits at the top of the terminal pane; open below the trigger
so the label is not clipped by the window/title edge (especially in
compact top-right action cluster when the host info bar is hidden). */}
<TooltipContent side="bottom" sideOffset={8} className="whitespace-nowrap max-w-none">
{t('terminal.toolbar.hostHighlight.title')}
</TooltipContent>
</Tooltip>
<PopoverContent className="w-80 p-0" align="end" side="bottom" sideOffset={8} collisionPadding={12}>
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between gap-2 min-w-0">
<span className="text-xs font-semibold uppercase text-muted-foreground truncate min-w-0">
{t('terminal.toolbar.hostHighlight.title')}
</span>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-xs text-muted-foreground">
{enabled ? t('common.enabled') : t('common.disabled')}
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={toggleEnabled}
className={`
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}
`}
>
<span
className={`
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0
transition duration-200 ease-in-out
${enabled ? 'translate-x-4' : 'translate-x-0'}
`}
/>
</button>
</label>
</div>
<ScrollArea className="max-h-64">
<div className="p-2 space-y-1.5">
{rules.length === 0 ? (
<div className="px-2 py-4 text-xs text-muted-foreground text-center italic">
{t('terminal.toolbar.hostHighlight.noRules')}
</div>
) : (
rules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleToggleRule(rule.id)}
className={`
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
${rule.enabled
? 'bg-primary border-primary'
: 'bg-transparent border-muted-foreground/50'
}
`}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
{rule.enabled ? t('common.enabled') : t('common.disabled')}
</TooltipContent>
</Tooltip>
<div className="flex-1 min-w-0">
<div
className="text-xs font-medium truncate"
style={{ color: rule.enabled ? rule.color : 'inherit' }}
>
{rule.label}
</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">
{rule.patterns.join(', ')}
</div>
</div>
<label className="relative flex-shrink-0">
<input
type="color"
value={rule.color}
onChange={(e) => handleColorChange(rule.id, e.target.value)}
className="sr-only"
aria-label={`${t('terminal.toolbar.hostHighlight.changeColor')} ${rule.label}`}
/>
<span
className="block w-6 h-4 rounded cursor-pointer border border-border/50 hover:border-border"
style={{ backgroundColor: rule.color }}
/>
</label>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteRule(rule.id)}
>
<Trash2 size={10} />
</Button>
</div>
))
)}
</div>
</ScrollArea>
{/* Add new rule form */}
<div className="p-2 border-t bg-muted/20 space-y-2">
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('terminal.toolbar.hostHighlight.addRule')}
</div>
<div className="flex gap-1.5">
<Input
placeholder={t('terminal.toolbar.hostHighlight.labelPlaceholder')}
value={newRuleLabel}
onChange={(e) => setNewRuleLabel(e.target.value)}
className="h-7 text-xs flex-1"
/>
<label className="relative flex-shrink-0">
<input
type="color"
value={newRuleColor}
onChange={(e) => setNewRuleColor(e.target.value)}
className="sr-only"
aria-label={t('terminal.toolbar.hostHighlight.selectColor')}
/>
<span
className="block w-7 h-7 rounded cursor-pointer border border-border/50 hover:border-border"
style={{ backgroundColor: newRuleColor }}
/>
</label>
</div>
<div className="flex gap-1.5">
<Input
placeholder={t('terminal.toolbar.hostHighlight.patternPlaceholder')}
value={newRulePattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={`h-7 text-xs font-mono flex-1 ${patternError ? 'border-destructive' : ''}`}
/>
<Button
variant="secondary"
size="icon"
className="h-7 w-7 flex-shrink-0"
onClick={handleAddRule}
disabled={!newRuleLabel.trim() || !newRulePattern.trim()}
>
<Plus size={12} />
</Button>
</div>
{patternError && (
<div className="text-[10px] text-destructive">{patternError}</div>
)}
</div>
{/* Footer actions */}
{rules.length > 0 && (
<div className="p-2 border-t flex justify-end">
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground hover:text-destructive"
onClick={handleClearAll}
>
<RotateCcw size={10} className="mr-1" />
{t('terminal.toolbar.hostHighlight.clearAll')}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
};
export default HostKeywordHighlightPopover;

View File

@@ -0,0 +1,279 @@
/**
* Floating credential list for sudo/su password-prompt assist (picker mode).
* Positioned next to the terminal cursor using the same anchor/placement
* helpers as AutocompletePopup. Secrets are never shown.
*/
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { KeyRound } from "lucide-react";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { PasswordPromptPickerItem } from "./runtime/terminalSudoAutofill";
import {
clampAutocompletePopupGeometry,
computeAutocompletePopupPlacement,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteClampViewport,
} from "./autocomplete/terminalAutocompleteLayout";
export type PasswordCredentialPickerProps = {
items: PasswordPromptPickerItem[];
selectedIndex: number;
visible: boolean;
onSelect: (id: string) => void;
title: string;
emptyText: string;
themeColors?: {
background?: string;
foreground?: string;
selection?: string;
cursor?: string;
};
termRef?: React.RefObject<XTerm | null>;
containerRef?: React.RefObject<HTMLDivElement | null>;
};
const ROW_HEIGHT = 28;
const HEADER_HEIGHT = 32;
const LIST_PADDING = 8;
const MAX_LIST_HEIGHT = 192;
const POPUP_MIN_WIDTH = 240;
const POPUP_MAX_WIDTH = 360;
const PasswordCredentialPicker: React.FC<PasswordCredentialPickerProps> = ({
items,
selectedIndex,
visible,
onSelect,
title,
emptyText,
themeColors,
termRef,
containerRef,
}) => {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const [anchorTick, setAnchorTick] = useState(0);
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
const requestReposition = useCallback(() => {
setAnchorTick((n) => n + 1);
}, []);
useEffect(() => {
if (!visible) return;
selectedRef.current?.scrollIntoView({ block: "nearest" });
}, [visible, selectedIndex]);
// Recalculate when the terminal/container resizes or the window moves.
useEffect(() => {
if (!visible) return;
let frameId = 0;
const schedule = () => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
frameId = 0;
requestReposition();
});
};
const container = containerRef?.current;
const observer = container ? new ResizeObserver(schedule) : null;
if (container) observer?.observe(container);
window.addEventListener("resize", schedule);
window.addEventListener("scroll", schedule, true);
// Two rAFs so xterm has finished layout after the password line paints.
let first = 0;
let second = 0;
first = requestAnimationFrame(() => {
requestReposition();
second = requestAnimationFrame(requestReposition);
});
return () => {
if (frameId) cancelAnimationFrame(frameId);
if (first) cancelAnimationFrame(first);
if (second) cancelAnimationFrame(second);
observer?.disconnect();
window.removeEventListener("resize", schedule);
window.removeEventListener("scroll", schedule, true);
};
}, [visible, containerRef, requestReposition, items.length]);
const itemCount = Math.max(1, items.length);
const estimatedListHeight = Math.min(MAX_LIST_HEIGHT, itemCount * ROW_HEIGHT + LIST_PADDING);
const estimatedPopupHeight = estimatedListHeight + HEADER_HEIGHT;
const placement = useMemo(() => {
// anchorTick forces recompute when the cursor/container moves.
void anchorTick;
const term = termRef?.current ?? null;
const container = containerRef?.current ?? null;
const clampViewport = resolveAutocompleteClampViewport(container);
const empty = {
left: clampViewport.left + 8,
top: clampViewport.top + 8,
maxHeight: MAX_LIST_HEIGHT,
renderUpward: true,
};
if (!term || !visible) return empty;
const anchor = resolveAutocompleteAnchorInViewport(term, container, itemCount);
const result = computeAutocompletePopupPlacement({
anchorTop: anchor.anchorTop,
anchorBottom: anchor.anchorBottom,
anchorLeft: anchor.anchorLeft,
viewportWidth: clampViewport.width,
viewportHeight: clampViewport.height,
clampViewport,
desiredHeight: estimatedPopupHeight,
totalWidth: POPUP_MAX_WIDTH,
clampWidth: POPUP_MAX_WIDTH,
maxHeight: estimatedPopupHeight,
anchorGap: 8,
viewportPadding: 8,
// Password prompts sit on the input line; prefer opening upward so the
// list does not cover what the user is about to type.
expandUpwardHint: true,
});
return {
left: result.left,
top: result.top,
maxHeight: Math.max(ROW_HEIGHT + LIST_PADDING, result.maxHeight - HEADER_HEIGHT),
renderUpward: result.renderUpward,
};
}, [
anchorTick,
termRef,
containerRef,
visible,
itemCount,
estimatedPopupHeight,
]);
useLayoutEffect(() => {
if (!visible) {
setMeasuredSize((current) => (current === null ? current : null));
return;
}
const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return;
setMeasuredSize((current) => {
if (
current
&& Math.abs(current.width - rect.width) < 0.5
&& Math.abs(current.height - rect.height) < 0.5
) {
return current;
}
return { width: rect.width, height: rect.height };
});
}, [visible, placement.left, placement.top, items.length, selectedIndex]);
if (!visible) return null;
const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null);
const finalGeometry = measuredSize
? clampAutocompletePopupGeometry({
left: placement.left,
top: placement.top,
width: measuredSize.width,
height: measuredSize.height,
clampViewport,
viewportPadding: 8,
})
: { left: placement.left, top: placement.top };
const background = themeColors?.background ?? "hsl(var(--popover))";
const foreground = themeColors?.foreground ?? "hsl(var(--popover-foreground))";
const selection = themeColors?.selection ?? "hsl(var(--accent))";
const border = themeColors?.cursor
? `${themeColors.cursor}55`
: "hsl(var(--border))";
const node = (
<div
ref={wrapperRef}
role="listbox"
aria-label={title}
data-testid="password-credential-picker"
style={{
position: "fixed",
left: `${finalGeometry.left}px`,
top: `${finalGeometry.top}px`,
zIndex: 10000,
minWidth: POPUP_MIN_WIDTH,
maxWidth: POPUP_MAX_WIDTH,
overflow: "hidden",
borderRadius: 6,
border: `1px solid ${border}`,
background,
color: foreground,
boxShadow: placement.renderUpward
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
: "0 2px 6px rgba(0, 0, 0, 0.15)",
fontSize: 13,
pointerEvents: "auto",
}}
onMouseDown={(e) => {
// Keep terminal focus; prevent selection loss before click select.
e.preventDefault();
e.stopPropagation();
}}
>
<div
className="flex items-center gap-1.5 border-b px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide opacity-70"
style={{ borderColor: border, height: HEADER_HEIGHT, boxSizing: "border-box" }}
>
<KeyRound size={12} />
<span>{title}</span>
</div>
<div
ref={listRef}
style={{
maxHeight: `${placement.maxHeight}px`,
overflowY: "auto",
padding: "4px 0",
}}
>
{items.length === 0 ? (
<div className="px-3 py-2 text-xs opacity-70">{emptyText}</div>
) : (
items.map((item, index) => {
const selected = index === selectedIndex;
return (
<button
key={item.id}
ref={selected ? selectedRef : undefined}
type="button"
role="option"
aria-selected={selected}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors"
style={{
background: selected ? selection : undefined,
height: ROW_HEIGHT,
boxSizing: "border-box",
}}
onClick={() => onSelect(item.id)}
>
<span className="min-w-0 flex-1 truncate font-medium">{item.label}</span>
{item.username ? (
<span className="shrink-0 font-mono text-xs opacity-70">{item.username}</span>
) : null}
<span className="shrink-0 font-mono text-xs opacity-50"></span>
</button>
);
})
)}
</div>
</div>
);
// Portal to body so overflow:hidden on terminal chrome cannot clip the list
// (same pattern as AutocompletePopup).
return ReactDOM.createPortal(node, document.body);
};
export default memo(PasswordCredentialPicker);

View File

@@ -0,0 +1,687 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { getCommandToRecordOnEnter } from "./autocomplete/useTerminalAutocomplete.ts";
function createFakeTerm(lineText: string, cursorX: number) {
return {
buffer: {
active: {
cursorX,
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() {
return lineText;
},
};
},
},
},
};
}
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
return {
cols,
buffer: {
active: {
cursorX,
cursorY,
baseY: 0,
getLine(line: number) {
const lineText = rows[line];
if (lineText === undefined) return undefined;
return {
isWrapped: line > 0,
translateToString() {
return lineText;
},
};
},
},
},
};
}
test("records aligned short commands when standard prompt echo lags by one character", () => {
const cases = [
{ lineText: "$ l", typedInput: "ls" },
{ lineText: "$ c", typedInput: "cd" },
{ lineText: "prod-web> l", typedInput: "ls", promptText: "prod-web> " },
{ lineText: "prod> l", typedInput: "ls", promptText: "prod> " },
{ lineText: "prod.web> l", typedInput: "ls", promptText: "prod.web> " },
{ lineText: "user@host:~$ l", typedInput: "ls", promptText: "user@host:~$ " },
{ lineText: "[user@host ~]$ l", typedInput: "ls", promptText: "[user@host ~]$ " },
{ lineText: "➜ netcatty $ l", typedInput: "ls", promptText: "➜ netcatty $ " },
{ lineText: "➜ git l", typedInput: "ls", promptText: "➜ git " },
{ lineText: "➜ git np", typedInput: "npm", promptText: "➜ git " },
];
for (const { lineText, typedInput, promptText = "$ " } of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, promptText, lineText);
assert.equal(result.prompt.userInput, typedInput, lineText);
assert.equal(result.alignedTyped, typedInput, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
typedInput,
lineText,
);
}
});
test("records aligned typed input instead of lagging standard prompt input on Enter", () => {
const typedInput = "git status";
const term = createFakeTerm("$ git ", "$ git ".length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
typedInput,
);
});
test("does not record themed prompt decorations when typed input is unreliable", () => {
const cases = [
{
lineText: "➜ ~ git status",
promptText: "➜ ",
expectedUserInput: " ~ git status",
},
{
lineText: "➜ netcatty git:(main) ✗ git status",
promptText: "➜ ",
expectedUserInput: " netcatty git:(main) ✗ git status",
},
{
lineText: " ~ git status",
promptText: " ",
expectedUserInput: " ~ git status",
},
];
for (const { lineText, promptText, expectedUserInput } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
"",
false,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, promptText, lineText);
assert.equal(result.prompt.userInput, expectedUserInput, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
null,
lineText,
);
}
});
test("records recognized themed prompts when typed input is unreliable", () => {
const cases = [
"➜ git status",
" git status",
"➜ netcatty $ git status",
"➜ netcatty git:(main) ✗ $ git status",
" ~ $ git status",
];
for (const lineText of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
"",
false,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.userInput, "git status", lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
"git status",
lineText,
);
}
});
test("aligns themed bare directory prompts with reliable typed input", () => {
const cases = [
{ dir: "netcatty", typedInput: "ls" },
{ dir: "git", typedInput: "ls" },
{ dir: "git", typedInput: "npm" },
{ dir: "git", typedInput: "git status" },
{ dir: "git", typedInput: "npm test" },
{ dir: "make", typedInput: "sudo" },
{ dir: "make", typedInput: "make build" },
{ dir: "make", typedInput: "git status" },
{ dir: "node", typedInput: "yarn" },
{ dir: "node", typedInput: "npm test" },
{ dir: "docker", typedInput: "git status" },
{ dir: "go", typedInput: "test" },
{ dir: "go", typedInput: "npm test" },
{ dir: "kubectl", typedInput: "sudo" },
{ dir: "kubectl", typedInput: "git status" },
];
for (const { dir, typedInput } of cases) {
const lineText = `${dir} ${typedInput}`;
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, true, dir);
assert.equal(result.prompt.promptText, `${dir} `, dir);
assert.equal(result.prompt.userInput, typedInput, dir);
assert.equal(result.alignedTyped, typedInput, dir);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
typedInput,
dir,
);
}
});
test("records reliable typed input before shell echo appears", () => {
const cases = [
{ lineText: "$ ", typedInput: "ls" },
{ lineText: "server> ", typedInput: "exit" },
{ lineText: "staging> ", typedInput: "show dbs" },
{ lineText: "test> ", typedInput: "exit" },
{ lineText: "test> ", typedInput: "help" },
{ lineText: "test> ", typedInput: "show dbs" },
{ lineText: "➜ git ", typedInput: "npm" },
{ lineText: "➜ make ", typedInput: "sudo" },
{ lineText: "➜ node ", typedInput: "yarn" },
];
for (const { lineText, typedInput } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
typedInput,
lineText,
);
}
});
test("does not record reliable typed input before interactive echo appears", () => {
const cases = [
{ lineText: "test> ", typedInput: "const x = 1" },
{ lineText: "test> ", typedInput: "await db.users.findOne()" },
{ lineText: "test> ", typedInput: "db" },
{ lineText: "rs0 [direct: primary] reporting> ", typedInput: "const x = 1" },
{ lineText: "rs0 [direct: primary] reporting> ", typedInput: "await db.users.findOne()" },
{ lineText: "rs0 [direct: primary] reporting> ", typedInput: "db.stats()" },
{ lineText: "Atlas a [primary] reporting> ", typedInput: "db.stats()" },
];
for (const { lineText, typedInput } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
null,
lineText,
);
}
});
test("detects themed bare directory prompts with standard terminators", () => {
const cases = [
{ lineText: "➜ git $ npm test", promptText: "➜ git $ ", typedInput: "npm test" },
{ lineText: "➜ make $ git status", promptText: "➜ make $ ", typedInput: "git status" },
];
for (const { lineText, promptText, typedInput } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
"",
false,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, promptText, lineText);
assert.equal(result.prompt.userInput, typedInput, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
typedInput,
lineText,
);
}
});
test("does not record path-decorated themed prompts when typed input is unreliable", () => {
const cases = [
"➜ ~/repo git status",
" ~/repo git status",
];
for (const lineText of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
"",
false,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
null,
lineText,
);
}
});
test("does not record partial themed prompt decorations when short command echo lags", () => {
const cases = [
{ lineText: "➜ ~ l", typedInput: "ls" },
{ lineText: "➜ ~ c", typedInput: "cd" },
{ lineText: "➜ ~ s", typedInput: "sudo" },
];
for (const { lineText, typedInput } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
null,
lineText,
);
}
});
test("aligns typed input after a no-space root prompt when a short command echo lags by a word", () => {
const prompt = "root@host:~#";
const cases = [
{ echoedInput: "ls ", typedInput: "ls -la" },
{ echoedInput: "cd ", typedInput: "cd /tmp" },
];
for (const { echoedInput, typedInput } of cases) {
const lineText = `${prompt}${echoedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true, typedInput);
assert.equal(result.prompt.promptText, prompt, typedInput);
assert.equal(result.prompt.userInput, typedInput, typedInput);
assert.equal(result.alignedTyped, typedInput, typedInput);
}
});
test("aligns typed input after a no-space root prompt when a short command echo lags by one character", () => {
const prompt = " root@stwo:~#";
const cases = [
{ echoedInput: "l", typedInput: "ls" },
{ echoedInput: "c", typedInput: "cd" },
];
for (const { echoedInput, typedInput } of cases) {
const lineText = `${prompt}${echoedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true, typedInput);
assert.equal(result.prompt.promptText, prompt, typedInput);
assert.equal(result.prompt.userInput, typedInput, typedInput);
assert.equal(result.alignedTyped, typedInput, typedInput);
}
});
test("does not align stale typed input against unrelated prompt text", () => {
const term = createFakeTerm("$ ls", 4);
const result = getAlignedPrompt(term as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "ls");
assert.equal(result.alignedTyped, null);
});
test("does not align stale typed input when the live command ends with it", () => {
const term = createFakeTerm("$ echo sudo", "$ echo sudo".length);
const result = getAlignedPrompt(term as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "echo sudo");
assert.equal(result.alignedTyped, null);
});
test("does not align stale typed input after host prompt command symbols", () => {
const prompt = "user@host:~$ ";
const cases = [
`${prompt}echo # sudo`,
`${prompt}printf % sudo`,
`${prompt}echo $ sudo`,
];
for (const lineText of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, prompt, lineText);
assert.equal(result.prompt.userInput, lineText.slice(prompt.length), lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("does not align stale typed input when the live path ends with it", () => {
const cases = [
"$ cd ~/sudo",
"$ echo /tmp/sudo",
"$ printf foo:sudo",
"$ cat ./sudo",
"$ run [sudo",
"$ cat > sudo",
"$ echo path#sudo",
"$ echo 100%sudo",
];
for (const lineText of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, "$ ", lineText);
assert.equal(result.prompt.userInput, lineText.slice(2), lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("does not align stale typed input from partial echoes after a no-space prompt", () => {
const prompt = " root@stwo:~#";
const cases = [
`${prompt}s`,
`${prompt}sud`,
];
for (const lineText of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, false, lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("does not align stale typed input after no-space prompt command suffixes", () => {
const prompt = " root@stwo:~#";
const cases = [
`${prompt}cat > sudo`,
`${prompt}echo # sudo`,
`${prompt}echo $ sudo`,
`${prompt}printf % sudo`,
`${prompt}echo path#sudo`,
`${prompt}> sudo`,
`${prompt}# sudo`,
`${prompt}% sudo`,
`${prompt}$ sudo`,
];
cases.push("root#echo $ sudo", "root@host:~#make $ sudo");
for (const lineText of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, false, lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("does not align stale typed input from short standard prompt prefixes", () => {
for (const lineText of ["$ s", "$ su", "$ sud"]) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, "$ ", lineText);
assert.equal(result.prompt.userInput, lineText.slice(2), lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("aligns wrapped typed input after a no-space root prompt", () => {
const prompt = " root@stwo:~#";
const typedInput = "printf 1234567890";
const cols = 20;
const firstInputSegmentLength = cols - prompt.length;
const rows = [
`${prompt}${typedInput.slice(0, firstInputSegmentLength)}`,
typedInput.slice(firstInputSegmentLength),
];
const term = createWrappedFakeTerm(rows, 1, rows[1].length, cols);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("aligns wrapped typed input after a no-space root prompt when shell echo lags", () => {
const prompt = " root@stwo:~#";
const typedInput = "printf 1234567890";
const echoedInput = typedInput.slice(0, -2);
const cols = 20;
const firstInputSegmentLength = cols - prompt.length;
const rows = [
`${prompt}${echoedInput.slice(0, firstInputSegmentLength)}`,
echoedInput.slice(firstInputSegmentLength),
];
const term = createWrappedFakeTerm(rows, 1, rows[1].length, cols);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("does not resurrect python REPL prompts during fallback alignment", () => {
const typedInput = "print('ok')";
const lineText = `>>> ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("does not resurrect mysql REPL prompts during fallback alignment", () => {
const typedInput = "select 1";
const lineText = `mysql> ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("does not resurrect mysql continuation prompts during fallback alignment", () => {
const prompts = [
" -> ",
" '> ",
" \"> ",
" `> ",
];
for (const prompt of prompts) {
const typedInput = "select 1";
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false, prompt);
assert.equal(result.alignedTyped, null, prompt);
}
});
test("does not resurrect redis-cli REPL prompts during fallback alignment", () => {
const prompts = [
"redis-cli> ",
"redis> ",
"127.0.0.1:6379> ",
"127.0.0.1:6379[1]> ",
"localhost:6379> ",
];
for (const prompt of prompts) {
const typedInput = "get key";
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false, prompt);
assert.equal(result.alignedTyped, null, prompt);
}
});
test("does not resurrect mariadb REPL prompts during fallback alignment", () => {
const typedInput = "select 1";
const prompt = "MariaDB [(none)]> ";
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("does not resurrect postgres REPL prompts during fallback alignment", () => {
for (const prompt of [
"postgres=# ",
"postgres=> ",
"postgres-# ",
"postgres'# ",
"postgres(# ",
"postgres*# ",
"postgres!# ",
"postgres^# ",
"postgres$tag$# ",
"postgres(> ",
"postgres*> ",
"postgres!> ",
"postgres^> ",
"postgres$tag$> ",
]) {
const typedInput = "select 1";
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false, prompt);
assert.equal(result.alignedTyped, null, prompt);
}
});
test("keeps host-style greater-than shell prompts", () => {
const prompt = "prod-web> ";
for (const typedInput of ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)"]) {
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true, typedInput);
assert.equal(result.prompt.promptText, prompt, typedInput);
assert.equal(result.prompt.userInput, typedInput, typedInput);
assert.equal(result.alignedTyped, typedInput, typedInput);
}
});
test("does not resurrect shell continuation prompts during fallback alignment", () => {
const typedInput = "echo ok";
const lineText = `> ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("does not resurrect no-space python REPL prompts during fallback alignment", () => {
const typedInput = "print(1)";
const lineText = `>>>${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("does not resurrect no-space mysql REPL prompts during fallback alignment", () => {
const typedInput = "select 1";
const lineText = `mysql>${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("does not resurrect host-like no-space REPL prompts during fallback alignment", () => {
const typedInput = "select 1";
const lineText = `user@db>${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("does not resurrect no-space shell continuation prompts during fallback alignment", () => {
const typedInput = "echo ok";
const lineText = `>${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, false);
assert.equal(result.alignedTyped, null);
});
test("keeps typed command intact for PUA-only prompts when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = ` root  ~  ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root  ~  ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});

View File

@@ -0,0 +1,819 @@
import test from "node:test";
import assert from "node:assert/strict";
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
import { getCommandToRecordOnEnter } from "./autocomplete/useTerminalAutocomplete.ts";
function createFakeTerm(lineText: string, cursorX: number) {
return {
buffer: {
active: {
cursorX,
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() {
return lineText;
},
};
},
},
},
};
}
/** Simulates xterm padding empty cells as trailing spaces after wide glyphs. */
function createPaddedFakeTerm(content: string, cursorX: number, cols = 80) {
const pad = Math.max(0, cols - stringCellWidth(content));
const lineText = content + " ".repeat(pad);
return createFakeTerm(lineText, cursorX);
}
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
return {
cols,
buffer: {
active: {
cursorX,
cursorY,
baseY: 0,
getLine(line: number) {
const lineText = rows[line];
if (lineText === undefined) return undefined;
return {
isWrapped: line > 0,
translateToString() {
return lineText;
},
};
},
},
},
};
}
test("keeps raw input when a standard shell prompt echo is still behind", () => {
const term = createFakeTerm("$ do", 4);
const result = getAlignedPrompt(term as never, "doc", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "do");
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, null);
});
test("uses reliable typed buffer when prompt echo has not started (IME / high-latency commit)", () => {
const term = createFakeTerm("$ ", 2);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.prompt.cursorOffset, 2);
// Pre-echo input is surfaced for alignment, but empty echo alone must not
// authorize history recording or third-party completion providers until echo
// validates the line. Local history/fig/snippet popups may still query from
// the keystroke buffer (#2830).
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("uses typed buffer before echo on padded themed prompts", () => {
// robbyrussell-style PS1 pads after the arrow: detectPrompt keeps the
// trailing space in userInput, which must still count as visually empty.
const term = createFakeTerm("➜ ", 3);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("uses typed buffer before echo on Nerd Font themed terminators", () => {
const term = createFakeTerm(" ", 2);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " ");
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("does not treat empty-echo shell-shaped prompts as validated typed input", () => {
const term = createFakeTerm("$ ", 2);
const secret = "s3cret-token";
const result = getAlignedPrompt(term as never, secret, true);
assert.equal(result.prompt.isAtPrompt, true);
// Keystroke buffer is still visible on the prompt view, but empty echo
// must not authorize history recording or external providers.
assert.equal(result.prompt.userInput, secret);
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("still trims prompt decorations out of the detected input", () => {
const term = createFakeTerm("➜ ~ do", 7);
const result = getAlignedPrompt(term as never, "do", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "➜ ~ ");
assert.equal(result.prompt.userInput, "do");
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, "do");
});
test("detects oh-my-posh Nerd Font chevron (U+F105) prompt terminator", () => {
// Real-world PS1 captured from oh-my-posh themed bash on a server:
// "<U+F31B> root@oracle ~ <U+F105> " then user input
const term = createFakeTerm(" root@oracle ~  ls", 21);
const result = getAlignedPrompt(term as never, "ls", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " root@oracle ~  ");
assert.equal(result.prompt.userInput, "ls");
});
test("detects Powerline right-arrow (U+E0B0) prompt terminator", () => {
// oh-my-posh agnoster-style: colored block ending with U+E0B0 + space
const term = createFakeTerm(" root  ~  git", 16);
const result = getAlignedPrompt(term as never, "git", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.userInput, "git");
assert.ok(result.prompt.promptText.endsWith(" "));
});
test("PUA char without trailing space is not a prompt boundary", () => {
// A bare PUA glyph mid-token (e.g. paste artifact) should not trigger detection.
const term = createFakeTerm("echo foo", 13);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, false);
});
test("keeps typed command intact when command text contains Powerline glyphs", () => {
const typedInput = "echo  foo";
const lineText = `$ ${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("does not treat a mid-line dollar as a prompt boundary", () => {
const lineText = "$ echo $HOME";
const term = createFakeTerm(lineText, "$ echo $".length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "echo $");
assert.equal(result.prompt.cursorOffset, "echo $".length);
});
test("does not treat a mid-line redirection as a prompt boundary", () => {
const lineText = "$ cat >file";
const term = createFakeTerm(lineText, "$ cat >".length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "cat >");
assert.equal(result.prompt.cursorOffset, "cat >".length);
});
test("does not treat a spaced redirection as a prompt boundary", () => {
const lineText = "$ cat > file";
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "cat > file");
});
test("does not treat common interactive program prompts as shell prompts", () => {
const cases = [
{ lineText: "sftp> get file", typedInput: "get file" },
{ lineText: "ftp> ls", typedInput: "ls" },
{ lineText: "ghci> :t map", typedInput: ":t map" },
{ lineText: "node> .help", typedInput: ".help" },
{ lineText: "mongo> db.stats()", typedInput: "db.stats()" },
{ lineText: "rs0:PRIMARY> db.stats()", typedInput: "db.stats()" },
{ lineText: "rs0 [direct: primary] test> db.stats()", typedInput: "db.stats()" },
{ lineText: "rs0 [direct: primary] reporting> db.stats()", typedInput: "db.stats()" },
{ lineText: "rs0 [direct: primary] reporting> const x = 1", typedInput: "const x = 1" },
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", typedInput: "await db.users.findOne()" },
{ lineText: "Atlas a [primary] reporting> db.stats()", typedInput: "db.stats()" },
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", typedInput: "await db.users.findOne()" },
{ lineText: "rs0 primary reporting> exit", typedInput: "exit" },
{ lineText: "irb(main):001> puts 1", typedInput: "puts 1" },
{ lineText: "pry(main)> whereami", typedInput: "whereami" },
{ lineText: "[1] pry(main)> whereami", typedInput: "whereami" },
{ lineText: "SQL> select 1", typedInput: "select 1" },
{ lineText: "cqlsh> select * from users", typedInput: "select * from users" },
{ lineText: "hive> select 1", typedInput: "select 1" },
{ lineText: "spark-sql> select 1", typedInput: "select 1" },
{ lineText: "jshell> /help", typedInput: "/help" },
{ lineText: " ...> System.out.println(1)", typedInput: "System.out.println(1)" },
{ lineText: "ksql> select 1", typedInput: "select 1" },
{ lineText: "trino> select 1", typedInput: "select 1" },
{ lineText: "trino:tpch> select 1", typedInput: "select 1" },
{ lineText: "presto> show catalogs", typedInput: "show catalogs" },
{ lineText: "presto:default> show tables", typedInput: "show tables" },
{ lineText: "duckdb> select 1", typedInput: "select 1" },
{ lineText: "lftp user@example.com:~> ls", typedInput: "ls" },
{ lineText: "cqlsh:cycling> select * from cyclist", typedInput: "select * from cyclist" },
{ lineText: "hive (default)> select 1", typedInput: "select 1" },
{ lineText: "0: jdbc:hive2://localhost:10000/default> select 1", typedInput: "select 1" },
{ lineText: "spark-sql (default)> select 1", typedInput: "select 1" },
{ lineText: "test> db.stats()", typedInput: "db.stats()" },
{ lineText: "test> const x = 1", typedInput: "const x = 1" },
{ lineText: "test> await db.users.findOne()", typedInput: "await db.users.findOne()" },
{ lineText: "test> db", typedInput: "db" },
{ lineText: "rs0 primary test> db.stats()", typedInput: "db.stats()" },
{ lineText: "test> rs.status()", typedInput: "rs.status()" },
{ lineText: "test> print(1)", typedInput: "print(1)" },
{ lineText: "test> 1 + 1", typedInput: "1 + 1" },
{ lineText: "admin@localhost:27017> db.stats()", typedInput: "db.stats()" },
];
for (const { lineText, typedInput } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, false, lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("does not treat sensitive authentication challenges as shell prompts", () => {
for (const lineText of [
"OTP> 123456",
"Verification code> 123456",
"Duo passcode: 123456",
"验证码> 123456",
]) {
const typedInput = lineText.slice(lineText.lastIndexOf(" ") + 1);
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, false, lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("does not treat wrapped interactive program prompts as shell prompts", () => {
const cases = [
{ rows: ["sftp> get very-long-", "remote-file"], typedInput: "get very-long-remote-file" },
{ rows: ["node> console.", "log('ok')"], typedInput: "console.log('ok')" },
{ rows: ["mongo> db.", "stats()"], typedInput: "db.stats()" },
{ rows: ["cqlsh> select *", " from users"], typedInput: "select * from users" },
{ rows: ["jshell> System.out.", "println(1)"], typedInput: "System.out.println(1)" },
{ rows: [" ...> System.out.", "println(1)"], typedInput: "System.out.println(1)" },
{ rows: ["trino> select", " 1"], typedInput: "select 1" },
{ rows: ["trino:tpch> select", " 1"], typedInput: "select 1" },
{ rows: ["duckdb> select", " 1"], typedInput: "select 1" },
{ rows: ["cqlsh:cycling> select *", " from cyclist"], typedInput: "select * from cyclist" },
{ rows: ["hive (default)> select", " 1"], typedInput: "select 1" },
{ rows: ["0: jdbc:hive2://localhost:10000/default> select", " 1"], typedInput: "select 1" },
{ rows: ["test> db.", "stats()"], typedInput: "db.stats()" },
{ rows: ["test> d", "b"], typedInput: "db" },
{ rows: ["rs0:PRIMARY> db.", "stats()"], typedInput: "db.stats()" },
{ rows: ["rs0 [direct: primary] test> db.", "stats()"], typedInput: "db.stats()" },
{ rows: ["rs0 [direct: primary]", " test> db.stats()"], typedInput: "db.stats()" },
{ rows: ["rs0 [direct: primary]", " reporting> db.stats()"], typedInput: "db.stats()" },
{ rows: ["rs0 [direct: primary]", " reporting> const x = 1"], typedInput: "const x = 1" },
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], typedInput: "db.stats()" },
{ rows: ["rs0 primary test> db.", "stats()"], typedInput: "db.stats()" },
{ rows: ["test> print", "(1)"], typedInput: "print(1)" },
{ rows: ["test> 1 ", "+ 1"], typedInput: "1 + 1" },
{ rows: ["admin@localhost:27017> db.", "stats()"], typedInput: "db.stats()" },
];
for (const { rows, typedInput } of cases) {
const result = getAlignedPrompt(
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, false, rows[0]);
assert.equal(result.alignedTyped, null, rows[0]);
}
});
test("keeps non-Mongo-looking default-name greater-than prompts usable", () => {
const prompts = ["test> ", "admin> ", "local> ", "config> "];
const commands = ["deploy", "exit", "help", "show dbs"];
for (const prompt of prompts) {
for (const typedInput of commands) {
const lineText = `${prompt}${typedInput}`;
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, prompt, lineText);
assert.equal(result.prompt.userInput, typedInput, lineText);
assert.equal(result.alignedTyped, typedInput, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
typedInput,
lineText,
);
}
}
});
test("keeps wrapped non-Mongo-looking default-name greater-than prompts usable", () => {
const cases = [
{ rows: ["test> hel", "p"], typedInput: "help", promptText: "test> " },
{ rows: ["test> show ", "dbs"], typedInput: "show dbs", promptText: "test> " },
{ rows: ["admin> ex", "it"], typedInput: "exit", promptText: "admin> " },
{ rows: ["local> dep", "loy"], typedInput: "deploy", promptText: "local> " },
];
for (const { rows, typedInput, promptText } of cases) {
const result = getAlignedPrompt(
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, true, rows[0]);
assert.equal(result.prompt.promptText, promptText, rows[0]);
assert.equal(result.prompt.userInput, typedInput, rows[0]);
assert.equal(result.alignedTyped, typedInput, rows[0]);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
typedInput,
rows[0],
);
}
});
test("keeps host-style greater-than prompts usable", () => {
const prompts = [
"prod-web> ",
"prod> ",
"prod.web> ",
"server> ",
"staging> ",
"webdb> ",
"prod.db> ",
];
const commands = [
"deploy",
"exit",
"show dbs",
"use app",
"it",
"help",
"print(1)",
"db.stats()",
];
for (const prompt of prompts) {
for (const typedInput of commands) {
const lineText = `${prompt}${typedInput}`;
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, prompt, lineText);
assert.equal(result.prompt.userInput, typedInput, lineText);
assert.equal(result.alignedTyped, typedInput, lineText);
}
}
});
test("keeps strong bare Mongo prompt signals out of shell prompts", () => {
const cases = [
{ lineText: "test> db.stats()", typedInput: "db.stats()" },
{ lineText: "test> db", typedInput: "db" },
{ lineText: "test> const x = 1", typedInput: "const x = 1" },
{ lineText: "test> await db.users.findOne()", typedInput: "await db.users.findOne()" },
{ lineText: "test> print(1)", typedInput: "print(1)" },
{ lineText: "test> 1 + 1", typedInput: "1 + 1" },
];
for (const { lineText, typedInput } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, false, lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("does not align stale typed input after themed prompt command suffixes", () => {
const cases = [
"➜ ~ echo sudo",
"➜ echo sudo",
"➜ make sudo",
"➜ docker sudo",
"➜ ./script sudo",
"➜ ./script sudo",
"➜ ~ echo # sudo",
];
for (const lineText of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, "➜ ", lineText);
assert.equal(result.prompt.userInput, lineText.slice("➜ ".length), lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("aligns themed prompt decorations when command echo lags", () => {
const typedInput = "git status";
const cases = [
{ lineText: "➜ ~ git ", promptText: "➜ ~ " },
{ lineText: "➜ ~ git st", promptText: "➜ ~ " },
{
lineText: "➜ netcatty git:(main) ✗ git ",
promptText: "➜ netcatty git:(main) ✗ ",
},
{
lineText: "➜ netcatty git:(main) ✗ git st",
promptText: "➜ netcatty git:(main) ✗ ",
},
];
for (const { lineText, promptText } of cases) {
const result = getAlignedPrompt(
createFakeTerm(lineText, lineText.length) as never,
typedInput,
true,
);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, promptText, lineText);
assert.equal(result.prompt.userInput, typedInput, lineText);
assert.equal(result.alignedTyped, typedInput, lineText);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
typedInput,
lineText,
);
}
});
test("trims single-space themed prompt decorations out of the detected input", () => {
const cases = [
{ lineText: "➜ ~/repo do", typedInput: "do", promptText: "➜ ~/repo " },
{
lineText: "➜ netcatty git:(main) ✗ ls",
typedInput: "ls",
promptText: "➜ netcatty git:(main) ✗ ",
},
{
lineText: "➜ netcatty git:(main) ✗ + ls",
typedInput: "ls",
promptText: "➜ netcatty git:(main) ✗ + ",
},
{ lineText: "➜ netcatty ✗ $ ls", typedInput: "ls", promptText: "➜ netcatty ✗ $ " },
{ lineText: "➜ netcatty $ ls", typedInput: "ls", promptText: "➜ netcatty $ " },
];
for (const { lineText, typedInput, promptText } of cases) {
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, promptText, lineText);
assert.equal(result.prompt.userInput, typedInput, lineText);
assert.equal(result.alignedTyped, typedInput, lineText);
}
});
test("does not treat later shell symbols followed by spaces as prompt boundaries", () => {
const cases = [
"$ echo # comment",
"$ printf % value",
"$ echo $ value",
];
for (const lineText of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "", true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, "$ ", lineText);
assert.equal(result.prompt.userInput, lineText.slice(2), lineText);
}
});
test("does not treat command-leading shell symbols as prompt boundaries", () => {
const cases = [
"$ # comment",
"$ > file",
"$ % value",
"$ $ value",
"root@host:~# foo $ value",
];
for (const lineText of cases) {
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "", false);
assert.equal(result.prompt.isAtPrompt, true, lineText);
const expectedPrompt = lineText.startsWith("root@host:~#") ? "root@host:~# " : "$ ";
assert.equal(result.prompt.promptText, expectedPrompt, lineText);
assert.equal(result.prompt.userInput, lineText.slice(expectedPrompt.length), lineText);
assert.equal(result.alignedTyped, null, lineText);
}
});
test("keeps prompt symbols that are part of the prompt text", () => {
const prompts = [
"user@host ~/foo#bar $ ",
"user@host ~/foo# bar $ ",
"user@host:~/foo# bar $ ",
"user@host ~/foo% bar $ ",
"user@host ~/foo> bar $ ",
];
const typedInput = "ls";
for (const prompt of prompts) {
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true, prompt);
assert.equal(result.prompt.promptText, prompt, prompt);
assert.equal(result.prompt.userInput, typedInput, prompt);
assert.equal(result.alignedTyped, typedInput, prompt);
}
});
test("keeps prompt symbols in prompt text without typed-buffer alignment", () => {
const prompts = [
"user@host ~/foo# bar $ ",
"user@host ~/foo# git $ ",
"user@host ~/foo#git $ ",
"root@host ~/foo# bar # ",
"root@host ~/foo#bar # ",
"fish@host ~/foo# bar % ",
"fish@host ~/foo%bar % ",
"user@host:~/foo# bar $ ",
"user@host ~/repo # $ ",
"➜ ~ $ ",
"user@host ~/foo% bar $ ",
"user@host ~/foo> bar $ ",
"user@host ~/foo# bar> ",
"user@host ~/foo# bar ",
"user@host ~/foo#bar> ",
];
for (const prompt of prompts) {
const lineText = `${prompt}ls`;
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "", false);
assert.equal(result.prompt.isAtPrompt, true, prompt);
assert.equal(result.prompt.promptText, prompt, prompt);
assert.equal(result.prompt.userInput, "ls", prompt);
assert.equal(result.alignedTyped, null, prompt);
}
});
test("prefers standard prompt terminator over later Powerline glyphs", () => {
const lineText = "$ echo  foo";
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "echo  foo");
});
test("ignores xterm row padding after a no-space root prompt", () => {
const prompt = " root@stwo:~#";
const term = createFakeTerm(`${prompt} `, prompt.length);
const result = getAlignedPrompt(term as never, "", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, "");
});
test("aligns typed input after a no-space root prompt", () => {
const prompt = " root@stwo:~#";
const typedInput = "printf ok";
const lineText = `${prompt}${typedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("aligns typed input after a no-space root prompt when shell echo lags", () => {
const prompt = " root@stwo:~#";
const typedInput = "printf ok";
const echoedInput = typedInput.slice(0, -1);
const lineText = `${prompt}${echoedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("aligns typed input after a no-space root prompt when shell echo lags by a word", () => {
const prompt = " root@stwo:~#";
const typedInput = "printf ok";
const echoedInput = "printf ";
const lineText = `${prompt}${echoedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("aligns typed input after a no-space root prompt when a longer command echo lags by a word", () => {
const prompt = "root@host:~#";
const typedInput = "git status";
const echoedInput = "git ";
const lineText = `${prompt}${echoedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("aligns typed input after a no-space root prompt when command echo lags mid-word", () => {
const prompt = "root@host:~#";
const typedInput = "git status";
const echoedInput = "git st";
const lineText = `${prompt}${echoedInput}`;
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, prompt);
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("aligns reliable typed input when standard prompt echo lags near completion", () => {
const typedInput = "git status";
const term = createFakeTerm("$ git statu", "$ git statu".length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, typedInput);
assert.equal(result.alignedTyped, typedInput);
});
test("aligns reliable typed input when standard prompt echo lags after a word boundary", () => {
const typedInput = "git status";
const cases = ["$ git ", "$ git st"];
for (const lineText of cases) {
const term = createFakeTerm(lineText, lineText.length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true, lineText);
assert.equal(result.prompt.promptText, "$ ", lineText);
assert.equal(result.prompt.userInput, typedInput, lineText);
assert.equal(result.alignedTyped, typedInput, lineText);
}
});
test("does not record partial standard prompt input while reliable typed input is still echoing", () => {
const typedInput = "sudo";
const term = createFakeTerm("$ s", "$ s".length);
const result = getAlignedPrompt(term as never, typedInput, true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "s");
assert.equal(result.alignedTyped, null);
assert.equal(
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
null,
);
});
test("CMD path prompts keep pre-echo Chinese input usable for snippet matching (#2813)", () => {
const prompts = [
String.raw`C:\Users\foo>`,
String.raw`C:\Users\用户>`,
String.raw`PS C:\Users\foo> `,
String.raw`PS C:\Users\用户> `,
];
const typedInput = "部署";
const snippet = { id: "zh", label: "部署服务", command: "echo deploy" };
for (const prompt of prompts) {
const term = createPaddedFakeTerm(prompt, stringCellWidth(prompt));
const raw = detectPrompt(term as never);
assert.equal(raw.isAtPrompt, true, prompt);
assert.equal(raw.userInput.trim(), "", prompt);
const aligned = getAlignedPrompt(term as never, typedInput, true);
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
assert.equal(aligned.prompt.userInput, typedInput, prompt);
assert.equal(aligned.alignedTyped, null, prompt);
assert.equal(aligned.allowExternalProviders, false, prompt);
const query = resolveAutocompleteQueryInput(
aligned.prompt,
typedInput,
true,
);
assert.equal(query, typedInput, prompt);
assert.equal(
getSnippetSuggestions(query ?? "", [snippet as never], {})[0]?.snippet?.id,
"zh",
prompt,
);
}
});
test("CMD path prompts with CJK directories do not absorb padding into echoed Chinese input", () => {
const prompt = String.raw`C:\Users\用户>`;
const typedInput = "部署";
const content = `${prompt}${typedInput}`;
const term = createPaddedFakeTerm(content, stringCellWidth(content));
const raw = detectPrompt(term as never);
assert.equal(raw.isAtPrompt, true);
assert.equal(raw.promptText, prompt);
assert.equal(raw.userInput, typedInput);
assert.equal(raw.cursorOffset, typedInput.length);
// Unreliable typed buffer must still match snippets from the live line
// (fresh local CMD sessions often clear keystroke reliability on startup).
const query = resolveAutocompleteQueryInput(raw, "", false);
assert.equal(query, typedInput);
assert.equal(
getSnippetSuggestions(query ?? "", [{
id: "zh",
label: "部署服务",
command: "echo deploy",
} as never], {})[0]?.snippet?.id,
"zh",
);
});

View File

@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mock, test } from "node:test";
import { fileURLToPath } from "node:url";
import React from "react";
import { act, create } from "react-test-renderer";
import {
SCRIPT_OVERLAY_TOP_COMPACT_PX,
SCRIPT_OVERLAY_TOP_DEFAULT_PX,
SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS,
ScriptExecutionOverlay,
} from "./ScriptExecutionOverlay.tsx";
import type { ScriptRun } from "@/types/global/netcatty-bridge-script.d.ts";
const completedRun: ScriptRun = {
runId: "completed-run",
sessionId: "session-1",
status: "completed",
startedAt: 0,
endedAt: 1_000,
logs: [],
};
test("script overlay sits lower under the full host toolbar than under compact chrome", () => {
assert.equal(SCRIPT_OVERLAY_TOP_DEFAULT_PX, 34);
assert.equal(SCRIPT_OVERLAY_TOP_COMPACT_PX, 8);
assert.ok(SCRIPT_OVERLAY_TOP_COMPACT_PX < SCRIPT_OVERLAY_TOP_DEFAULT_PX);
});
test("script overlay covers compact speed-dial full-width instead of reserving a right gutter", () => {
const overlaySource = readFileSync(
fileURLToPath(new URL("./ScriptExecutionOverlay.tsx", import.meta.url)),
"utf8",
);
const terminalSource = readFileSync(
fileURLToPath(new URL("../Terminal.tsx", import.meta.url)),
"utf8",
);
assert.match(overlaySource, /compactTopChrome/);
assert.match(overlaySource, /left-2 right-2/);
assert.match(overlaySource, /z-40/);
assert.doesNotMatch(overlaySource, /right-10/);
assert.match(overlaySource, /SCRIPT_OVERLAY_TOP_COMPACT_PX/);
assert.match(
terminalSource,
/compactTopChrome=\{terminalSettings\?\.showHostInfoBar === false\}/,
);
});
test("script overlay dismisses a completed run after five seconds", () => {
mock.timers.enable({ apis: ["setTimeout"] });
let dismissCount = 0;
let renderer: ReturnType<typeof create> | undefined;
const renderOverlay = (onDismiss: () => void) => React.createElement(ScriptExecutionOverlay, {
run: completedRun,
onPause: () => {},
onResume: () => {},
onStop: () => {},
onDismiss,
});
try {
act(() => {
renderer = create(renderOverlay(() => { dismissCount += 1; }));
});
mock.timers.tick(SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS - 1_000);
assert.equal(dismissCount, 0);
// Script run broadcasts replace the callback without changing this run.
act(() => {
renderer?.update(renderOverlay(() => { dismissCount += 1; }));
});
mock.timers.tick(999);
assert.equal(dismissCount, 0);
mock.timers.tick(1);
assert.equal(dismissCount, 1);
} finally {
renderer?.unmount();
mock.timers.reset();
}
});

View File

@@ -0,0 +1,285 @@
import { Check, Loader2, Pause, Play, Square, X } from 'lucide-react';
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils.ts';
export interface ScriptExecutionOverlayProps {
run: ScriptRun;
onPause: () => void;
onResume: () => void;
onStop: () => void;
onDismiss: () => void;
/**
* Host info bar is hidden: no full toolbar. Sit the banner higher and stack
* above the compact speed-dial (cover it for the run duration).
*/
compactTopChrome?: boolean;
}
/** Default top offset under the full host-info toolbar. */
export const SCRIPT_OVERLAY_TOP_DEFAULT_PX = 34;
/** Top offset when only the compact speed-dial is present. */
export const SCRIPT_OVERLAY_TOP_COMPACT_PX = 8;
/** Completed script results remain visible briefly before dismissing themselves. */
export const SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS = 5_000;
function formatElapsed(ms: number) {
const seconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
if (minutes > 0) {
return `${minutes}:${String(rest).padStart(2, '0')}`;
}
return `${rest}s`;
}
function resolveWaitingPattern(
run: ScriptRun,
t: (key: string, params?: Record<string, string | number>) => string,
) {
if (!run.waitingFor) return undefined;
if (run.waitingFor === 'shell prompt' || run.waitingFor.includes(' | ')) {
return t('scripts.running.waitingForShellPrompt');
}
return run.waitingFor;
}
function isLowValueActivityLabel(label?: string) {
if (!label) return true;
const normalized = label.trim().toLowerCase();
return normalized === 'log' || normalized.startsWith('sleep ');
}
function DotSeparator() {
return <span className="text-muted-foreground/35 px-0.5">·</span>;
}
function Muted({ children }: { children: React.ReactNode }) {
return <span className="text-muted-foreground">{children}</span>;
}
function Accent({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span className={cn('text-primary font-semibold tabular-nums', className)}>
{children}
</span>
);
}
function ScriptStatusIcon({ status }: { status: ScriptRun['status'] }) {
const iconClass = 'block';
const boxClass = 'inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center';
if (status === 'completed') {
return (
<span className={boxClass} aria-hidden>
<Check size={14} className={cn(iconClass, 'text-emerald-500')} />
</span>
);
}
if (status === 'failed') {
return (
<span className={boxClass} aria-hidden>
<X size={14} className={cn(iconClass, 'text-destructive')} />
</span>
);
}
return (
<span className={boxClass} aria-hidden>
<Loader2 size={14} className={cn(iconClass, 'animate-spin text-primary')} />
</span>
);
}
function ScriptStatusLine({
run,
elapsedMs,
lastSent,
t,
}: {
run: ScriptRun;
elapsedMs: number;
lastSent?: string;
t: (key: string, params?: Record<string, string | number>) => string;
}) {
const label = run.scriptLabel || t('scripts.running.unnamed');
const opCount = run.stepIndex ?? 0;
const elapsed = formatElapsed(elapsedMs);
const waitingPattern = resolveWaitingPattern(run, t);
const isFinished = run.status === 'completed' || run.status === 'failed';
const opsSegment = (
<>
<Muted>{t('scripts.running.opsPrefix')}</Muted>
<Accent>{opCount}</Accent>
<Muted>{t('scripts.running.opsSuffix')}</Muted>
</>
);
const elapsedSegment = <Accent>{elapsed}</Accent>;
const activitySegment = !isLowValueActivityLabel(run.activityLabel) ? (
<>
<DotSeparator />
<span className="text-foreground/90">{run.activityLabel}</span>
</>
) : null;
const progressSegment = run.progressMode === 'determinate' && run.progressTotal ? (
<>
<DotSeparator />
<Muted>{run.progressLabel || t('scripts.running.progressFallback')}</Muted>
{' '}
<Accent>
{run.progressCurrent ?? 0}
/
{run.progressTotal}
</Accent>
</>
) : null;
const pausedSegment = run.status === 'paused' ? (
<>
<DotSeparator />
<span className="text-amber-500 font-medium">{t('scripts.running.status.paused')}</span>
</>
) : null;
const waitingSegment = waitingPattern ? (
<>
<DotSeparator />
<Muted>{t('scripts.running.waitingForLabel')}</Muted>
{' '}
<span className="text-amber-500 font-medium">{waitingPattern}</span>
</>
) : null;
const lastSentSegment = !waitingPattern && lastSent ? (
<>
<DotSeparator />
<Muted>{t('scripts.running.lastSentLabel')}</Muted>
{' '}
<span className="text-primary/90 font-mono">{lastSent}</span>
</>
) : null;
return (
<span className="flex min-w-0 flex-1 items-center gap-1.5 leading-4">
<ScriptStatusIcon status={run.status} />
<span className="shrink-0 whitespace-nowrap font-semibold text-foreground">{label}</span>
<span className="inline-flex min-w-0 flex-1 items-center truncate">
{(isFinished || opCount > 0) ? (
<>
<DotSeparator />
{opsSegment}
</>
) : null}
<DotSeparator />
{elapsedSegment}
{!isFinished ? (
<>
{progressSegment}
{activitySegment}
{pausedSegment}
{waitingSegment}
{lastSentSegment}
</>
) : null}
</span>
</span>
);
}
export const ScriptExecutionOverlay: React.FC<ScriptExecutionOverlayProps> = ({
run,
onPause,
onResume,
onStop,
onDismiss,
compactTopChrome = false,
}) => {
const { t } = useI18n();
const [tick, setTick] = useState(0);
const isFinished = run.status === 'completed' || run.status === 'failed';
const dismissFinishedRun = useEffectEvent(onDismiss);
useEffect(() => {
if (isFinished) return undefined;
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer);
}, [isFinished, run.runId]);
useEffect(() => {
if (!isFinished) return undefined;
const timer = setTimeout(dismissFinishedRun, SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS);
return () => clearTimeout(timer);
}, [isFinished, run.runId]);
void tick;
const elapsedMs = run.elapsedMs
?? (run.endedAt ? run.endedAt - run.startedAt : Date.now() - run.startedAt);
const lastSent = [...(run.logs || [])].reverse().find((entry) => entry.message.startsWith('→ '))?.message.slice(2);
const errorMessage = run.status === 'failed' ? run.error : undefined;
const statusLine = useMemo(
() => (
<ScriptStatusLine run={run} elapsedMs={elapsedMs} lastSent={lastSent} t={t} />
),
[run, elapsedMs, lastSent, t],
);
return (
<div
// z-40 sits above the compact speed-dial (z-30) so the full-width banner
// covers the toggle while a script is running — no right-edge gutter.
className="absolute left-2 right-2 z-40 rounded-md border shadow-md backdrop-blur-md pointer-events-auto px-3 py-2"
style={{
top: compactTopChrome ? SCRIPT_OVERLAY_TOP_COMPACT_PX : SCRIPT_OVERLAY_TOP_DEFAULT_PX,
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg) 92%, transparent)',
borderColor: 'var(--terminal-ui-border)',
color: 'var(--terminal-ui-fg)',
}}
data-section="script-execution-overlay"
data-compact-top-chrome={compactTopChrome ? "true" : "false"}
>
<div className="flex items-center gap-2 min-w-0">
<div className="flex min-w-0 flex-1 items-center text-[11px] leading-4">
{statusLine}
</div>
{errorMessage ? (
<div
className="min-w-0 max-w-[42%] shrink truncate text-right text-[11px] leading-4 text-destructive"
title={errorMessage}
>
{errorMessage}
</div>
) : null}
<div className="flex items-center gap-1 shrink-0">
{isFinished ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onDismiss} title={t('scripts.running.dismiss')}>
<X size={14} />
</Button>
) : (
<>
{run.status === 'running' ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onPause}>
<Pause size={14} />
</Button>
) : null}
{run.status === 'paused' ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onResume}>
<Play size={14} />
</Button>
) : null}
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onStop}>
<Square size={14} />
</Button>
</>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { Pause, Play, Square } from 'lucide-react';
import React from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils.ts';
export interface ScriptRecordingIndicatorProps {
elapsedMs: number;
isPaused: boolean;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}
function formatElapsed(ms: number) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
export const ScriptRecordingIndicator: React.FC<ScriptRecordingIndicatorProps> = ({
elapsedMs,
isPaused,
onPause,
onResume,
onStop,
}) => {
const { t } = useI18n();
return (
<div className="flex items-center gap-2 text-xs">
<span className="flex items-center gap-1 text-red-500 font-medium">
<span className={cn('inline-block h-2 w-2 rounded-full bg-red-500', !isPaused && 'animate-pulse')} />
REC
</span>
<span className="tabular-nums text-muted-foreground">{formatElapsed(elapsedMs)}</span>
{isPaused ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onResume} title={t('scripts.recording.resume')}>
<Play size={14} />
</Button>
) : (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onPause} title={t('scripts.recording.pause')}>
<Pause size={14} />
</Button>
)}
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onStop} title={t('scripts.recording.stop')}>
<Square size={14} />
</Button>
</div>
);
};

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
type SessionInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
};
export const SessionInlineRenameInput: React.FC<SessionInlineRenameInputProps> = ({
initialName,
onCommit,
onCancel,
className,
style,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
onCancel();
};
return (
<input
ref={inputRef}
data-session-inline-rename="true"
value={value}
draggable={false}
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
commit();
});
}}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
if (event.key === 'Escape') {
event.preventDefault();
cancel();
}
}}
className={cn(
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
className,
)}
style={style}
/>
);
};

View File

@@ -0,0 +1,311 @@
/**
* Terminal Authentication Dialog
* Displays auth form with password/key selection for SSH connection
*/
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
export interface TerminalAuthDialogProps {
authMethod: TerminalAuthMethod;
setAuthMethod: (method: TerminalAuthMethod) => void;
authUsername: string;
setAuthUsername: (username: string) => void;
authPassword: string;
setAuthPassword: (password: string) => void;
authKeyId: string | null;
setAuthKeyId: (keyId: string | null) => void;
authPassphrase: string;
setAuthPassphrase: (passphrase: string) => void;
showAuthPassphrase: boolean;
setShowAuthPassphrase: (show: boolean) => void;
showAuthPassword: boolean;
setShowAuthPassword: (show: boolean) => void;
authRetryMessage: string | null;
keys: SSHKey[];
onSubmit: () => void;
onSubmitWithoutSave?: () => void;
onCancel: () => void;
isValid: boolean;
}
export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
authMethod,
setAuthMethod,
authUsername,
setAuthUsername,
authPassword,
setAuthPassword,
authKeyId,
setAuthKeyId,
authPassphrase,
setAuthPassphrase,
showAuthPassphrase,
setShowAuthPassphrase,
showAuthPassword,
setShowAuthPassword,
authRetryMessage,
keys,
onSubmit,
onSubmitWithoutSave,
onCancel,
isValid,
}) => {
const { t } = useI18n();
const handleContinue = onSubmitWithoutSave ?? onSubmit;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isValid) {
handleContinue();
}
};
// Show all keys (both regular keys and certificates) in the single key picker.
const selectableKeys = React.useMemo(
() => keys.filter((k) => k.category === 'key' || Boolean(k.certificate?.trim())),
[keys],
);
const [keyDropdownOpen, setKeyDropdownOpen] = React.useState(false);
const [submitOptionsOpen, setSubmitOptionsOpen] = React.useState(false);
const selectedKey = authKeyId ? keys.find((k) => k.id === authKeyId) : null;
return (
<>
{/* Auth method tabs */}
<div className="flex gap-1 p-1 bg-secondary/65 rounded-xl border border-border/50">
<button
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
authMethod === 'password'
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
)}
onClick={() => setAuthMethod('password')}
>
<Lock size={13} />
{t("terminal.auth.password")}
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
authMethod === 'key' || authMethod === 'certificate'
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
)}
onClick={() => setAuthMethod('key')}
>
<Key size={13} />
{t("terminal.auth.sshKey")}
</button>
</div>
{/* Auth retry error message */}
{authRetryMessage && (
<div className="flex items-center gap-2.5 rounded-xl border border-destructive/20 bg-destructive/7 px-3 py-2.5 text-xs text-foreground/90">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-destructive/12 text-destructive">
<Unplug size={11} />
</div>
<div className="min-w-0 leading-4 text-destructive/95">
{authRetryMessage}
</div>
</div>
)}
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
<Input
id="auth-username"
value={authUsername}
onChange={(e) => setAuthUsername(e.target.value)}
placeholder={t("terminal.auth.username.placeholder")}
/>
</div>
{authMethod === 'password' ? (
<div className="space-y-2">
<Label htmlFor="auth-password">{t("terminal.auth.passwordLabel")}</Label>
<div className="relative">
<Input
id="auth-password"
type={showAuthPassword ? 'text' : 'password'}
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder={t("terminal.auth.password.placeholder")}
className={cn("pr-10", authRetryMessage && "border-destructive/50")}
autoFocus={!!authRetryMessage}
onKeyDown={handleKeyDown}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowAuthPassword(!showAuthPassword)}
>
{showAuthPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
) : (
<>
<div className="space-y-2">
<Label>{t("terminal.auth.selectKey")}</Label>
{selectableKeys.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
{t("terminal.auth.noKeysHint")}
</div>
) : (
<Popover open={keyDropdownOpen} onOpenChange={setKeyDropdownOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
selectedKey
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50"
)}
>
{selectedKey ? (
<>
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
selectedKey.certificate?.trim()
? "bg-emerald-500/20 text-emerald-500"
: "bg-primary/20 text-primary"
)}>
{selectedKey.certificate?.trim()
? <BadgeCheck size={14} />
: <Key size={14} />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{selectedKey.label}</div>
<div className="text-xs text-muted-foreground">
{selectedKey.certificate?.trim() ? t("terminal.auth.certificate") : selectedKey.type}
</div>
</div>
</>
) : (
<span className="text-sm text-muted-foreground">{t("hostForm.auth.selectKey")}</span>
)}
<ChevronDown size={16} className="text-muted-foreground shrink-0 ml-auto" />
</button>
</PopoverTrigger>
<PopoverContent className="p-1" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
<div className="max-h-60 overflow-y-auto">
{selectableKeys.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-left",
authKeyId === key.id
? "bg-primary/10 text-primary"
: "hover:bg-secondary/80"
)}
onClick={() => {
setAuthKeyId(key.id);
setAuthMethod(key.certificate?.trim() ? 'certificate' : 'key');
setAuthPassphrase(key.passphrase || '');
setKeyDropdownOpen(false);
}}
>
<div className={cn(
"h-7 w-7 rounded-md flex items-center justify-center shrink-0",
key.certificate?.trim()
? "bg-emerald-500/20 text-emerald-500"
: "bg-primary/20 text-primary"
)}>
{key.certificate?.trim()
? <BadgeCheck size={12} />
: <Key size={12} />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{key.label}</div>
<div className="text-xs text-muted-foreground">
{key.certificate?.trim() ? t("terminal.auth.certificate") : key.type}
</div>
</div>
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="space-y-2">
<Label htmlFor="auth-passphrase">{t("terminal.auth.passphrase")}</Label>
<div className="relative">
<Input
id="auth-passphrase"
type={showAuthPassphrase ? 'text' : 'password'}
value={authPassphrase}
onChange={(e) => setAuthPassphrase(e.target.value)}
placeholder={t("terminal.auth.passphrase.placeholder")}
className="pr-10"
disabled={!selectedKey}
onKeyDown={handleKeyDown}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
onClick={() => setShowAuthPassphrase(!showAuthPassphrase)}
disabled={!selectedKey}
>
{showAuthPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
</>
)}
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<Dropdown open={submitOptionsOpen} onOpenChange={setSubmitOptionsOpen}>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
disabled={!isValid}
onClick={handleContinue}
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
>
{t("common.continue")}
</Button>
<DropdownTrigger asChild>
<Button
disabled={!isValid}
aria-label={t("terminal.auth.continueSave")}
aria-haspopup="menu"
aria-expanded={submitOptionsOpen}
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44 p-1 z-50" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={onSubmit}
disabled={!isValid}
>
{t("terminal.auth.continueSave")}
</button>
</DropdownContent>
</Dropdown>
</div>
</>
);
};
export default TerminalAuthDialog;

View File

@@ -0,0 +1,193 @@
import ReactDOM from "react-dom";
import { useCallback, type ComponentProps, type RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
useTerminalAutocomplete,
AutocompletePopup,
type AutocompleteSettings,
} from "./autocomplete";
import type { Snippet } from "../../domain/models";
import { usePaneVisible } from "./paneVisibilityStore";
import { getWindowPluginTerminalProviderRegistry } from "../../application/state/pluginTerminalProviderRegistry";
import { provideTerminalCompletions } from "./autocomplete/terminalCompletionProviders";
import { shouldUsePluginTerminalCompletionProvider } from "../../domain/terminalPromptSecurity";
type PopupProps = ComponentProps<typeof AutocompletePopup>;
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
type HandlerRef<T> = { current: T | undefined };
interface TerminalAutocompleteProps {
termRef: RefObject<XTerm | null>;
sessionId: string;
hostId: string;
hostGroup?: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
protocol?: string;
workspaceId?: string;
status?: "connecting" | "connected" | "disconnected";
/** Pane visibility fallback when paneVisibilityStore has no entry (popup terminals). */
isVisible?: boolean;
getCwd?: () => string | undefined;
onAcceptText: (text: string) => void;
snippets?: Snippet[];
onAcceptSnippet?: (snippet: Snippet) => void;
themeColors: PopupProps["themeColors"];
containerRef: PopupProps["containerRef"];
searchBarOffset: number;
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
inputRef: HandlerRef<(data: string) => void>;
repositionRef: HandlerRef<() => void>;
closeRef: HandlerRef<() => void>;
sudoHintRef: HandlerRef<(active: boolean) => boolean>;
sudoHintText: string;
isPluginCompletionProviderAvailable?: () => boolean;
sensitiveInputActiveRef: RefObject<boolean>;
allowHostStyleGreaterThanPrompt?: boolean;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean;
}
/**
* Owns the terminal autocomplete hook and renders its popup.
*
* Kept as its own component so the frequent autocomplete state updates
* (suggestions, selection, live-preview navigation) re-render only this small
* subtree rather than the whole Terminal component. The hook's handlers are
* surfaced back to Terminal through refs so the xterm runtime can call them.
*
* Must be mounted unconditionally for the terminal session's lifetime: the hook
* records command history on Enter and intercepts completion keys even while no
* popup is visible. Visibility only gates the rendered popup, not the hook.
*/
export function TerminalAutocomplete({
termRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings,
protocol,
workspaceId,
status = "connected",
isVisible = true,
getCwd,
onAcceptText,
snippets,
onAcceptSnippet,
themeColors,
containerRef,
searchBarOffset,
keyEventRef,
inputRef,
repositionRef,
closeRef,
sudoHintRef,
sudoHintText,
isPluginCompletionProviderAvailable,
sensitiveInputActiveRef,
allowHostStyleGreaterThanPrompt = false,
isNetworkDevice = false,
}: TerminalAutocompleteProps) {
// Self-subscribe to this pane's visibility so toggling it doesn't have to
// flow through (and re-render) the TerminalView ctx. Popup / standalone
// Terminal mounts never publish the store — fall back to the isVisible prop
// (same contract as hibernate).
const visible = usePaneVisible(sessionId, isVisible);
const provideCompletions = useCallback(async (
input: string,
options: Parameters<typeof import("./autocomplete/completionEngine").getCompletions>[1] & {
promptText: string;
signal?: AbortSignal;
},
) => {
const normalizedProtocol: NetcattyTerminalSessionSnapshot['protocol'] = protocol ?? "ssh";
const pluginRegistry = isPluginCompletionProviderAvailable?.() === false
|| options.allowExternalProviders === false
|| !shouldUsePluginTerminalCompletionProvider({
sensitiveInputActive: sensitiveInputActiveRef.current === true,
promptText: options.promptText,
allowHostStyleGreaterThan: allowHostStyleGreaterThanPrompt,
})
? null
: getWindowPluginTerminalProviderRegistry();
return provideTerminalCompletions(pluginRegistry, {
input,
session: {
sessionId,
...(hostId ? { hostId } : {}),
...(workspaceId ? { workspaceId } : {}),
protocol: normalizedProtocol,
status,
...(options.cwd ? { cwd: options.cwd } : {}),
},
hostOs,
hostGroup,
cwdSource: options.cwdSource,
snippets: options.snippets,
maximum: options.maxResults ?? 15,
historyScope: options.historyScope ?? settings?.historyScope,
signal: options.signal,
onLatePathSuggestions: options.onLatePathSuggestions,
});
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
const autocomplete = useTerminalAutocomplete({
termRef,
containerRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings,
onAcceptText,
snippets,
onAcceptSnippet,
protocol,
getCwd,
sensitiveInputActiveRef,
provideCompletions,
isNetworkDevice,
});
// Surface the handlers for runtime wiring. They have stable identities
// (useCallback over refs), so assigning during render is cheap and mirrors
// the wiring Terminal did inline before this was extracted.
keyEventRef.current = autocomplete.handleKeyEvent;
inputRef.current = autocomplete.handleInput;
repositionRef.current = autocomplete.repositionPopup;
closeRef.current = autocomplete.closePopup;
sudoHintRef.current = (active: boolean): boolean => {
if (!active) {
autocomplete.hideSudoHint();
return false;
}
return autocomplete.showSudoHint(sudoHintText);
};
const { state } = autocomplete;
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
return null;
}
// Portal to body so the popup escapes the terminal container's overflow.
return ReactDOM.createPortal(
<AutocompletePopup
suggestions={state.suggestions}
selectedIndex={state.selectedIndex}
anchorViewport={state.popupAnchorViewport}
visible={state.popupVisible}
expandUpward={state.expandUpward}
themeColors={themeColors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={state.subDirPanels}
subDirFocusLevel={state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={searchBarOffset}
onDismiss={autocomplete.closePopup}
/>,
document.body,
);
}

View File

@@ -0,0 +1,526 @@
/**
* Terminal Compose Bar
* An immersive prompt bar below the terminal with a quick-snippet strip,
* user-resizable height, and terminal-matched chrome.
*/
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
import { useI18n } from '../../application/i18n/I18nProvider';
import { resolveSnippetCommand } from '../SnippetExecutionProvider';
import { Snippet } from '../../types';
import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import {
buildSnippetIdKey,
filterComposeBarSnippets,
mergeComposeBarSnippetMap,
resolveComposeBarDefaultSeedIds,
} from './composeBarHelpers';
const SNIPPET_STRIP_HEIGHT = 30;
const RESIZE_HANDLE_HEIGHT = 6;
type ComposeBarTheme = {
resolvedBg: string;
resolvedFg: string;
borderColor: string;
mutedFg: string;
hoverBg: string;
chipBg: string;
chipHoverBg: string;
};
function buildTheme(themeColors?: { background: string; foreground: string }): ComposeBarTheme {
const bg = themeColors?.background ?? '#0a0a0a';
const fg = themeColors?.foreground ?? '#d4d4d4';
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
return {
resolvedBg,
resolvedFg,
borderColor: `color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
mutedFg: `color-mix(in srgb, ${resolvedFg} 55%, ${resolvedBg} 45%)`,
hoverBg: `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
chipBg: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
chipHoverBg: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
};
}
interface ComposeBarSnippetChipProps {
snippet: Snippet;
theme: ComposeBarTheme;
onActivate: (snippet: Snippet, sendImmediately: boolean) => void;
onUnpin: (id: string) => void;
unpinLabel: string;
clickHint: string;
}
const ComposeBarSnippetChip = memo(function ComposeBarSnippetChip({
snippet,
theme,
onActivate,
onUnpin,
unpinLabel,
clickHint,
}: ComposeBarSnippetChipProps) {
const commandPreview = snippet.command.split('\n')[0];
return (
<div
className="group/chip flex items-stretch h-6 max-w-[168px] rounded overflow-hidden flex-shrink-0 transition-colors duration-150"
style={{ backgroundColor: theme.chipBg, color: theme.resolvedFg }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = theme.chipHoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = theme.chipBg;
}}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex-1 min-w-0 px-2 text-[10px] font-mono truncate text-left"
onClick={(e) => { void onActivate(snippet, e.shiftKey); }}
>
{snippet.label}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="font-medium">{snippet.label}</p>
<p className="text-[10px] opacity-80 mt-0.5 font-mono line-clamp-2">
{commandPreview}
</p>
<p className="text-[10px] opacity-60 mt-1">{clickHint}</p>
</TooltipContent>
</Tooltip>
<button
type="button"
className={cn(
'flex items-center justify-center w-5 shrink-0',
'opacity-40 hover:opacity-100 group-hover/chip:opacity-70',
'transition-opacity duration-150',
)}
style={{ color: theme.mutedFg }}
aria-label={unpinLabel}
onClick={(e) => {
e.stopPropagation();
onUnpin(snippet.id);
}}
>
<X size={9} />
</button>
</div>
);
});
interface ComposeBarSnippetManagePopoverProps {
snippets: Snippet[];
pinnedCount: number;
theme: ComposeBarTheme;
isPinned: (id: string) => boolean;
onTogglePin: (id: string) => void;
manageLabel: string;
searchPlaceholder: string;
noSnippetsLabel: string;
noMatchingLabel: string;
pinnedCountLabel: string;
}
const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopover({
snippets,
pinnedCount,
theme,
isPinned,
onTogglePin,
manageLabel,
searchPlaceholder,
noSnippetsLabel,
noMatchingLabel,
pinnedCountLabel,
}: ComposeBarSnippetManagePopoverProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const filteredSnippets = useMemo(
() => filterComposeBarSnippets(snippets, search),
[snippets, search],
);
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearch('');
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="h-6 w-6 flex-shrink-0 flex items-center justify-center rounded transition-colors duration-150"
style={{ color: theme.mutedFg }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = theme.hoverBg;
e.currentTarget.style.color = theme.resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = theme.mutedFg;
}}
aria-label={manageLabel}
title={manageLabel}
>
<Plus size={12} />
</button>
</PopoverTrigger>
<PopoverContent
className="w-72 p-0"
align="end"
side="top"
sideOffset={6}
style={{
backgroundColor: theme.resolvedBg,
borderColor: theme.borderColor,
color: theme.resolvedFg,
}}
>
<div
className="px-2.5 py-2 border-b"
style={{ borderColor: theme.borderColor }}
>
<p className="text-[11px] font-semibold mb-1.5">{manageLabel}</p>
<div
className="flex items-center gap-1.5 rounded px-2 h-7"
style={{ backgroundColor: theme.chipBg }}
>
<Search size={11} style={{ color: theme.mutedFg }} className="shrink-0" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 min-w-0 bg-transparent text-[11px] font-mono outline-none placeholder:opacity-60"
style={{ color: theme.resolvedFg }}
/>
</div>
</div>
<div className="max-h-52 overflow-y-auto p-1">
{snippets.length === 0 ? (
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
{noSnippetsLabel}
</p>
) : filteredSnippets.length === 0 ? (
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
{noMatchingLabel}
</p>
) : (
filteredSnippets.map((snippet) => {
const pinned = isPinned(snippet.id);
return (
<button
key={snippet.id}
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors duration-150"
style={{
color: theme.resolvedFg,
backgroundColor: pinned ? theme.chipHoverBg : 'transparent',
}}
onMouseEnter={(e) => {
if (!pinned) e.currentTarget.style.backgroundColor = theme.hoverBg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = pinned ? theme.chipHoverBg : 'transparent';
}}
onClick={() => onTogglePin(snippet.id)}
>
<Pin
size={11}
className="shrink-0"
style={{
color: pinned ? theme.resolvedFg : theme.mutedFg,
fill: pinned ? 'currentColor' : 'none',
}}
/>
<span className="flex-1 min-w-0 truncate text-[11px] font-mono">
{snippet.label}
</span>
</button>
);
})
)}
</div>
{pinnedCount > 0 && (
<div
className="px-2.5 py-1.5 border-t text-[10px]"
style={{ borderColor: theme.borderColor, color: theme.mutedFg }}
>
{pinnedCountLabel}
</div>
)}
</PopoverContent>
</Popover>
);
});
export interface TerminalComposeBarProps {
onSend: (text: string) => void;
onClose: () => void;
onSnippetClick?: (snippet: Snippet) => void;
snippets?: Snippet[];
isBroadcastEnabled?: boolean;
themeColors?: {
background: string;
foreground: string;
};
}
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
onSend,
onClose,
onSnippetClick,
snippets = [],
isBroadcastEnabled,
themeColors,
}) => {
const { t } = useI18n();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
const resizeCleanupRef = useRef<(() => void) | null>(null);
const [barHeight, setBarHeight, persistBarHeight] = useComposeBarHeight();
const heightRef = useRef(barHeight);
const snippetIdKey = useMemo(
() => buildSnippetIdKey(snippets.map((snippet) => snippet.id)),
[snippets],
);
const defaultSeedIds = useMemo(
() => resolveComposeBarDefaultSeedIds(snippets),
[snippets],
);
const { pinnedIds, unpin, toggle, isPinned } = useComposeBarPinnedSnippets(
snippetIdKey,
defaultSeedIds,
);
heightRef.current = barHeight;
const theme = useMemo(() => buildTheme(themeColors), [themeColors]);
const snippetsById = useMemo(
() => mergeComposeBarSnippetMap(snippets),
[snippets],
);
const pinnedSnippets = useMemo(
() => pinnedIds
.map((id) => snippetsById.get(id))
.filter((snippet): snippet is Snippet => Boolean(snippet)),
[pinnedIds, snippetsById],
);
const clickHint = t('terminal.composeBar.snippetClickHint');
useEffect(() => {
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, []);
useEffect(() => () => {
resizeCleanupRef.current?.();
}, []);
const handleSend = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
const text = el.value;
if (!text) return;
onSend(text);
el.value = '';
el.focus();
}, [onSend]);
const insertCommand = useCallback((command: string) => {
const el = textareaRef.current;
if (!el) return;
const prefix = el.value && !el.value.endsWith('\n') ? '\n' : '';
el.value = el.value ? `${el.value}${prefix}${command}` : command;
el.focus();
}, []);
const handleSnippetActivate = useCallback(async (snippet: Snippet, sendImmediately: boolean) => {
if (sendImmediately) {
if (onSnippetClick) {
onSnippetClick(snippet);
} else {
const command = await resolveSnippetCommand(snippet);
if (command !== null) onSend(command);
}
return;
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
insertCommand(command);
}, [insertCommand, onSend, onSnippetClick]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
handleSend();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [handleSend, onClose]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
resizeCleanupRef.current?.();
const startY = e.clientY;
const startHeight = heightRef.current;
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientY - startY;
setBarHeight(startHeight - delta);
};
const cleanup = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
resizeCleanupRef.current = null;
};
const onUp = () => {
persistBarHeight(heightRef.current);
cleanup();
};
resizeCleanupRef.current = cleanup;
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [persistBarHeight, setBarHeight]);
return (
<div
className="flex-shrink-0 flex flex-col"
style={{
height: barHeight,
backgroundColor: theme.resolvedBg,
borderTop: `1px solid ${theme.borderColor}`,
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('terminal.composeBar.resize')}
className="flex-shrink-0 flex items-center justify-center cursor-ns-resize group"
style={{ height: RESIZE_HANDLE_HEIGHT }}
onMouseDown={handleResizeStart}
>
<GripHorizontal
size={12}
className="opacity-0 group-hover:opacity-60 transition-opacity"
style={{ color: theme.mutedFg }}
/>
</div>
<div
className="flex-shrink-0 flex items-center gap-1 px-2 min-w-0"
style={{ height: SNIPPET_STRIP_HEIGHT }}
>
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-x-auto scrollbar-thin">
{pinnedSnippets.map((snippet) => (
<ComposeBarSnippetChip
key={snippet.id}
snippet={snippet}
theme={theme}
clickHint={clickHint}
unpinLabel={t('terminal.composeBar.unpinSnippet', { label: snippet.label })}
onUnpin={unpin}
onActivate={handleSnippetActivate}
/>
))}
</div>
<ComposeBarSnippetManagePopover
snippets={snippets}
pinnedCount={pinnedSnippets.length}
theme={theme}
isPinned={isPinned}
onTogglePin={toggle}
manageLabel={t('terminal.composeBar.manageSnippets')}
searchPlaceholder={t('terminal.composeBar.searchSnippets')}
noSnippetsLabel={t('terminal.toolbar.noSnippets')}
noMatchingLabel={t('terminal.composeBar.noMatchingSnippets')}
pinnedCountLabel={t('terminal.composeBar.pinnedCount', { count: pinnedSnippets.length })}
/>
</div>
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
<div className="flex flex-1 min-h-0 items-start gap-1.5">
{isBroadcastEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
</TooltipTrigger>
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
</Tooltip>
)}
<textarea
ref={textareaRef}
className={cn(
'flex-1 min-w-0 min-h-0 h-full resize-none bg-transparent border-none px-0 py-0',
'text-xs font-mono leading-relaxed outline-none',
'placeholder:opacity-70 overflow-y-auto',
)}
style={{ color: theme.resolvedFg }}
placeholder={t('terminal.composeBar.placeholder')}
onKeyDown={handleKeyDown}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
style={{
color: theme.mutedFg,
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = theme.hoverBg;
e.currentTarget.style.color = theme.resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = theme.mutedFg;
}}
onClick={onClose}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('terminal.composeBar.close')}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
};
export default TerminalComposeBar;

View File

@@ -0,0 +1,261 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalConnectionDialog } from "./TerminalConnectionDialog.tsx";
const host: Host = {
id: "host-1",
label: "10.2.0.32",
hostname: "10.2.0.32",
port: 22,
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
};
const renderDialog = (
props: Partial<React.ComponentProps<typeof TerminalConnectionDialog>> = {},
) => renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(TerminalConnectionDialog, {
host,
status: "connecting",
error: null,
progressValue: 55,
chainProgress: null,
needsAuth: false,
showLogs: false,
_setShowLogs: () => {},
keys: [],
authProps: {
authMethod: "password",
setAuthMethod: () => {},
authUsername: "root",
setAuthUsername: () => {},
authPassword: "",
setAuthPassword: () => {},
authKeyId: null,
setAuthKeyId: () => {},
authPassphrase: "",
setAuthPassphrase: () => {},
showAuthPassphrase: false,
setShowAuthPassphrase: () => {},
showAuthPassword: false,
setShowAuthPassword: () => {},
authRetryMessage: null,
onSubmit: () => {},
onCancel: () => {},
isValid: true,
},
progressProps: {
timeLeft: 20,
isCancelling: false,
progressLogs: ["Host key verification required for 10.2.0.32."],
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: () => {},
},
...props,
}),
),
);
test("renders host key confirmation inside the connection dialog", () => {
const markup = renderDialog({
showLogs: true,
hostKeyVerification: {
hostKeyInfo: {
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "abc123",
status: "unknown",
},
onClose: () => {},
onContinue: () => {},
onAddAndContinue: () => {},
},
});
assert.match(markup, /Confirm this host key/);
assert.match(markup, /abc123/);
assert.match(markup, /Add and continue/);
assert.match(markup, /Host key verification required for 10\.2\.0\.32\./);
assert.equal(markup.includes("Timeout in"), false);
});
test("does not show a countdown while waiting for user input", () => {
const markup = renderDialog({
progressProps: {
timeLeft: 20,
isAwaitingUserInput: true,
isCancelling: false,
progressLogs: ["Waiting for passphrase."],
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: () => {},
},
});
assert.match(markup, /Waiting for user input/);
assert.equal(markup.includes("Timeout in"), false);
});
test("shows enter reconnect hint when disconnected reconnect is available", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
showEnterReconnectHint: true,
});
assert.match(markup, /Press Enter to reconnect/);
// Focus sink so Enter still reaches the overlay after body/document blur (#2544).
assert.match(markup, /data-terminal-disconnected-dialog="true"/);
assert.match(markup, /tabindex="-1"/);
});
test("does not show enter reconnect hint until the caller marks enter reconnect available", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
});
assert.equal(markup.includes("Press Enter to reconnect"), false);
assert.equal(markup.includes("data-terminal-disconnected-dialog"), false);
});
test("renders changed host key warning in the same connection dialog", () => {
const markup = renderDialog({
hostKeyVerification: {
hostKeyInfo: {
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "new-fingerprint",
knownFingerprint: "old-fingerprint",
status: "changed",
},
onClose: () => {},
onContinue: () => {},
onAddAndContinue: () => {},
},
});
assert.match(markup, /Host key changed/);
assert.match(markup, /new-fingerprint/);
assert.match(markup, /Saved fingerprint/);
assert.match(markup, /old-fingerprint/);
assert.match(markup, /Update and continue/);
});
test("keeps the second progress segment parked until the first segment finishes", () => {
const markup = renderDialog({ progressValue: 75 });
assert.match(markup, /style="width:100%"/);
assert.match(markup, /style="width:0%"/);
});
test("fills both progress segments for disconnected states", () => {
const markup = renderDialog({
status: "disconnected",
error: "Connection timed out.",
progressValue: 5,
});
const fullSegments = markup.match(/style="width:100%"/g) ?? [];
assert.equal(fullSegments.length >= 2, true);
});
test("keeps connection log padding inside the scrollable content", () => {
const markup = renderDialog({
status: "disconnected",
error: "Connection timed out.",
showLogs: true,
progressProps: {
timeLeft: 0,
isCancelling: false,
progressLogs: Array.from({ length: 12 }, (_, index) => `Log line ${index + 1}`),
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: () => {},
},
});
assert.match(markup, /class="[^"]*max-h-44/);
assert.doesNotMatch(markup, /class="[^"]*max-h-44[^"]*p-2\.5/);
assert.match(markup, /class="[^"]*p-2\.5[^"]*pb-4[^"]*pr-4/);
});
test("shows the ET server port (not the SSH port) for an ET host with a custom etPort", () => {
const markup = renderDialog({
host: { ...host, etEnabled: true, port: 22, etPort: 9022 },
});
// ET connectivity hinges on the etserver port, so the dialog must show it.
assert.match(markup, /10\.2\.0\.32:9022/);
assert.equal(markup.includes("10.2.0.32:22"), false);
});
test("defaults the displayed ET port to 2022 when no etPort is set", () => {
const markup = renderDialog({
host: { ...host, etEnabled: true, port: 22 },
});
assert.match(markup, /10\.2\.0\.32:2022/);
assert.equal(markup.includes("10.2.0.32:22"), false);
});
test("labels plugin transports without presenting them as SSH endpoints", () => {
const providerId = "com.example.transport.connection";
const markup = renderDialog({
host: {
...host,
hostname: providerId,
port: 22,
protocol: `plugin:${providerId}`,
pluginConnection: { providerId, configuration: {} },
},
});
assert.match(markup, /Plugin connection/);
assert.equal(markup.includes(`${providerId}:22`), false);
});
test("shows restored session copy for disconnected restored placeholders", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
restoreState: "restored-disconnected",
} as Partial<React.ComponentProps<typeof TerminalConnectionDialog>>);
assert.match(markup, /Restored session/);
assert.match(markup, /This terminal is disconnected/);
assert.match(markup, /Reconnect/);
});
test("disconnected observer surfaces do not advertise a reconnect they cannot perform", () => {
const markup = renderDialog({
status: "disconnected",
error: "Observed session ended.",
showEnterReconnectHint: false,
progressProps: {
timeLeft: 0,
isCancelling: false,
progressLogs: [],
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: undefined,
},
});
assert.equal(markup.includes("Press Enter to reconnect"), false);
assert.equal(markup.includes("Start over"), false);
assert.match(markup, /Close session/);
});

View File

@@ -0,0 +1,457 @@
/**
* Terminal Connection Dialog
* Full connection overlay with host info, progress indicator, and auth/progress content
*/
import { Fingerprint, Loader2, Plug, TerminalSquare, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types';
import { formatHostPort, resolveTelnetPort } from '../../domain/host';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress';
import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification';
import {
resolveDisconnectedDialogTerminalRoot,
restoreTerminalFocusFromDisconnectedDialog,
shouldClaimDisconnectedDialogFocus,
shouldReconnectDisconnectedDialogOnEnterKey,
shouldRestoreDisconnectedDialogTerminalFocus,
} from './terminalHelpers';
export interface ChainProgress {
currentHop: number;
totalHops: number;
currentHostLabel: string;
}
export interface TerminalConnectionDialogProps {
host: Host;
status: 'connecting' | 'connected' | 'disconnected';
restoreState?: 'restored-disconnected';
error: string | null;
progressValue: number;
chainProgress: ChainProgress | null;
needsAuth: boolean;
showLogs: boolean;
_setShowLogs: (show: boolean) => void;
// Auth dialog props
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
keys: SSHKey[];
onDismissDisconnected?: () => void;
showEnterReconnectHint?: boolean;
/** False for unfocused split siblings — do not claim body/document focus. */
isFocusedPane?: boolean;
hostKeyVerification?: {
hostKeyInfo: HostKeyInfo;
onClose: () => void;
onContinue: () => void;
onAddAndContinue: () => void;
};
// Progress props
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | 'showEnterReconnectHint'>;
}
// Helper to get protocol display info
const getProtocolInfo = (host: Host): { i18nKey: string; showPort: boolean; port: number } => {
// Check moshEnabled first since mosh uses protocol: "ssh" with moshEnabled: true
if (host.moshEnabled) {
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
}
// ET likewise uses protocol: "ssh" with etEnabled: true. Show the ET
// server port (default 2022) rather than the SSH port: ET connectivity
// hinges on the etserver port, so surfacing the SSH port (22) here is
// misleading when troubleshooting a connection that is actually stuck on
// the ET port.
if (host.etEnabled) {
return { i18nKey: 'terminal.connection.protocol.et', showPort: true, port: host.etPort || 2022 };
}
const protocol = host.protocol || 'ssh';
if (isPluginHostProtocol(protocol)) {
return { i18nKey: 'terminal.connection.protocol.plugin', showPort: false, port: 0 };
}
switch (protocol) {
case 'local':
return { i18nKey: 'terminal.connection.protocol.local', showPort: false, port: 0 };
case 'telnet':
// Telnet uses telnetPort, not port (which is SSH port)
return { i18nKey: 'terminal.connection.protocol.telnet', showPort: true, port: resolveTelnetPort(host) };
case 'mosh':
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
case 'serial':
return { i18nKey: 'terminal.connection.protocol.serial', showPort: false, port: 0 };
case 'ssh':
default:
return { i18nKey: 'terminal.connection.protocol.ssh', showPort: true, port: host.port || 22 };
}
};
export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> = ({
host,
status,
restoreState,
error,
progressValue,
chainProgress,
needsAuth,
showLogs,
_setShowLogs: setShowLogs, // Rename back to setShowLogs for internal use
authProps,
keys,
onDismissDisconnected,
showEnterReconnectHint,
isFocusedPane,
hostKeyVerification,
progressProps,
}) => {
const { t } = useI18n();
const hasError = Boolean(error);
const isRestoredDisconnected = status === 'disconnected' && restoreState === 'restored-disconnected';
const isConnecting = status === 'connecting';
const canDismissDisconnected = status === 'disconnected' && !needsAuth && !!onDismissDisconnected;
const protocolInfo = getProtocolInfo(host);
const isVerifyingHostKey = Boolean(hostKeyVerification);
const isHostKeyChanged = hostKeyVerification?.hostKeyInfo.status === 'changed';
const shouldCompleteProgress = hasError || (!isConnecting && !needsAuth);
// When the disconnected overlay is up and Enter-reconnect is advertised,
// keep a focus sink on the overlay itself so body/document focus loss cannot
// make the hint lie (#2544). Auth/host-key flows keep their own inputs.
const onRetry = progressProps.onRetry;
const canEnterReconnectFromDialog = Boolean(
showEnterReconnectHint
&& status === 'disconnected'
&& !needsAuth
&& !isVerifyingHostKey
&& onRetry,
);
const dialogFocusRef = useRef<HTMLDivElement | null>(null);
// Unmount cleanup keeps [] deps; read the latest pane ownership then.
const isFocusedPaneRef = useRef(isFocusedPane);
isFocusedPaneRef.current = isFocusedPane;
// Claim focus only when Enter-reconnect mode turns on — not on every
// showLogs/error rerender (those would steal keyboard focus off buttons).
useEffect(() => {
if (!canEnterReconnectFromDialog) return;
const node = dialogFocusRef.current;
if (!node) return;
const sessionRoot = node.closest("[data-session-id]");
const focusOverlay = () => {
if (typeof document === "undefined") return;
if (!shouldClaimDisconnectedDialogFocus({
activeElement: document.activeElement,
dialogNode: node,
sessionRoot,
documentBody: document.body,
documentElement: document.documentElement,
isFocusedPane,
})) {
return;
}
node.focus({ preventScroll: true });
};
focusOverlay();
// Re-assert after paint/microtasks so late blur from xterm teardown
// cannot leave focus on document.body — still never steals other panes.
const timer = window.setTimeout(focusOverlay, 0);
return () => window.clearTimeout(timer);
}, [canEnterReconnectFromDialog, isFocusedPane]);
// Restore xterm focus only when this overlay unmounts (connected / dismiss).
// Do not key on Enter-reconnect mode: reconnect may keep the dialog mounted
// for connecting / auth / host-key, and restoring then routes keyboard behind
// the blocking overlay.
useEffect(() => {
const node = dialogFocusRef.current;
if (!node) return;
// Capture the terminal root while the dialog is still mounted — after
// unmount, parentElement is null and popup trees have no data-session-id.
const sessionRoot = resolveDisconnectedDialogTerminalRoot(
node,
node.closest("[data-session-id]"),
);
return () => {
if (typeof document === "undefined") return;
if (!shouldRestoreDisconnectedDialogTerminalFocus(node)) return;
restoreTerminalFocusFromDisconnectedDialog({
activeElement: document.activeElement,
dialogNode: node,
sessionRoot,
documentBody: document.body,
documentElement: document.documentElement,
isFocusedPane: isFocusedPaneRef.current,
});
};
}, []);
const handleDialogKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!shouldReconnectDisconnectedDialogOnEnterKey({
key: event.key,
enabled: canEnterReconnectFromDialog,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
isComposing: event.nativeEvent.isComposing,
target: event.target,
})) {
return;
}
event.preventDefault();
event.stopPropagation();
onRetry?.();
}, [canEnterReconnectFromDialog, onRetry]);
const targetFirstSegmentWidth = isVerifyingHostKey || shouldCompleteProgress
? 100
: Math.min(100, progressValue * 2);
const targetSecondSegmentWidth = isVerifyingHostKey
? 0
: shouldCompleteProgress
? 100
: Math.max(0, Math.min(100, (progressValue - 50) * 2));
const [secondSegmentUnlocked, setSecondSegmentUnlocked] = React.useState(
() => shouldCompleteProgress || targetSecondSegmentWidth <= 0
);
const secondSegmentUnlockTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (secondSegmentUnlockTimerRef.current) {
clearTimeout(secondSegmentUnlockTimerRef.current);
}
};
}, []);
React.useEffect(() => {
if (needsAuth || isVerifyingHostKey || targetSecondSegmentWidth <= 0 || shouldCompleteProgress) {
if (secondSegmentUnlockTimerRef.current) {
clearTimeout(secondSegmentUnlockTimerRef.current);
secondSegmentUnlockTimerRef.current = null;
}
setSecondSegmentUnlocked(shouldCompleteProgress);
return;
}
if (secondSegmentUnlocked || secondSegmentUnlockTimerRef.current) return;
secondSegmentUnlockTimerRef.current = setTimeout(() => {
secondSegmentUnlockTimerRef.current = null;
setSecondSegmentUnlocked(true);
}, 320);
}, [isVerifyingHostKey, needsAuth, secondSegmentUnlocked, shouldCompleteProgress, targetSecondSegmentWidth]);
const firstSegmentWidth = targetFirstSegmentWidth;
const secondSegmentWidth = shouldCompleteProgress || secondSegmentUnlocked ? targetSecondSegmentWidth : 0;
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center"
style={{
backgroundColor: needsAuth
? 'var(--terminal-ui-bg, var(--background))'
: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 35%, transparent)',
}}
onMouseDown={(event) => {
// Clicking the dimmed backdrop (not a control) should park focus
// on the overlay so the next Enter still reconnects.
if (!canEnterReconnectFromDialog) return;
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.closest("button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='menuitem'], [role='textbox']")) {
return;
}
dialogFocusRef.current?.focus({ preventScroll: true });
}}
>
<div
ref={dialogFocusRef}
tabIndex={canEnterReconnectFromDialog ? -1 : undefined}
data-terminal-disconnected-dialog={canEnterReconnectFromDialog ? 'true' : undefined}
onKeyDown={handleDialogKeyDown}
className="w-[540px] max-w-[88vw] rounded-xl shadow-xl p-4 space-y-3 transition-all duration-200 outline-none"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 95%, transparent)',
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 12%, var(--terminal-ui-bg, var(--background)) 88%)',
color: 'var(--terminal-ui-fg, var(--foreground))',
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} size="md" className="shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>
<div className="text-xs font-semibold truncate">
<span className="text-muted-foreground">
{t('terminal.connection.chainOf', {
current: chainProgress.currentHop,
total: chainProgress.totalHops,
})}
{': '}
</span>
<span>{chainProgress.currentHostLabel}</span>
</div>
<div
className="text-[10px] font-mono truncate"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
>
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
) : (
<>
<div className="text-base font-semibold truncate">{host.label}</div>
<div
className="text-[10px] font-mono truncate"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
>
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
{!needsAuth && (
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-[11px]"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
</Button>
)}
{status === 'connecting' && !needsAuth && !isVerifyingHostKey && (
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-[11px]"
onClick={progressProps.onCancelConnect}
disabled={progressProps.isCancelling}
>
{progressProps.isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
</Button>
)}
{canDismissDisconnected && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
onClick={onDismissDisconnected}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.connection.dismissDisconnectedDialog')}</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-3">
<div className={cn(
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
needsAuth || isVerifyingHostKey
? "bg-primary text-primary-foreground"
: hasError
? "bg-destructive/20 text-destructive"
: isConnecting
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
<Plug size={13} />
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
className={cn(
"absolute inset-y-0 left-0 rounded-full transition-all duration-300",
error ? "bg-destructive" : "bg-primary"
)}
style={{ width: needsAuth ? '0%' : `${firstSegmentWidth}%` }}
/>
</div>
<div className={cn(
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0 transition-all duration-200",
isHostKeyChanged
? "bg-destructive/15 text-destructive ring-2 ring-destructive/25 animate-pulse"
: isVerifyingHostKey
? "bg-amber-500/15 text-amber-400 ring-2 ring-amber-400/25 animate-pulse"
: progressValue > 50 && !hasError
? "bg-primary/15 text-primary"
: hasError
? "bg-destructive/20 text-destructive"
: "bg-muted text-muted-foreground"
)}>
<Fingerprint size={13} />
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
className={cn(
"absolute inset-y-0 left-0 rounded-full transition-all duration-300",
error ? "bg-destructive" : "bg-primary"
)}
style={{ width: needsAuth || isVerifyingHostKey ? '0%' : `${secondSegmentWidth}%` }}
/>
</div>
<div className={cn(
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
)}>
{isConnecting ? (
<Loader2 size={13} className="animate-spin" />
) : (
<TerminalSquare size={13} />
)}
</div>
</div>
</div>
{needsAuth ? (
<TerminalAuthDialog {...authProps} keys={keys} />
) : hostKeyVerification ? (
<TerminalHostKeyVerification
hostKeyInfo={hostKeyVerification.hostKeyInfo}
showLogs={showLogs}
progressLogs={progressProps.progressLogs}
onClose={hostKeyVerification.onClose}
onContinue={hostKeyVerification.onContinue}
onAddAndContinue={hostKeyVerification.onAddAndContinue}
/>
) : (
<>
{isRestoredDisconnected && (
<div className="rounded-md border border-border/35 bg-background/35 p-3 text-xs leading-5">
<div className="font-semibold">{t('terminal.restore.placeholder.title')}</div>
<div
className="mt-1"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 68%, transparent)' }}
>
{t('terminal.restore.placeholder.desc')}
</div>
</div>
)}
<TerminalConnectionProgress
status={status}
error={error}
showLogs={showLogs}
showEnterReconnectHint={showEnterReconnectHint}
reconnectLabel={isRestoredDisconnected ? t('terminal.restore.placeholder.reconnect') : undefined}
{...progressProps}
/>
</>
)}
</div>
</div>
);
};
export default TerminalConnectionDialog;

View File

@@ -0,0 +1,122 @@
/**
* Terminal Connection Progress
* Displays connection progress with logs and timeout
*/
import { Loader2, Play } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
export interface TerminalConnectionProgressProps {
status: 'connecting' | 'connected' | 'disconnected';
error: string | null;
timeLeft: number;
isAwaitingUserInput?: boolean;
showEnterReconnectHint?: boolean;
isCancelling: boolean;
showLogs: boolean;
progressLogs: string[];
onCancelConnect: () => void;
onCloseSession: () => void;
onRetry?: () => void;
reconnectLabel?: string;
}
export interface TerminalConnectionLogListProps {
progressLogs: string[];
error?: string | null;
}
export const TerminalConnectionLogList: React.FC<TerminalConnectionLogListProps> = ({
progressLogs,
error,
}) => (
<div className="rounded-md border border-border/35 bg-background/40">
<ScrollArea className="max-h-44">
<div className="space-y-1 p-2.5 pb-4 pr-4 text-xs text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
<div className="min-w-0 break-words leading-5">{line}</div>
</div>
))}
{error && (
<div className="flex items-start gap-2 text-destructive">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<div className="min-w-0 break-words leading-5">{error}</div>
</div>
)}
</div>
</ScrollArea>
</div>
);
export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProps> = ({
status,
error,
timeLeft,
isAwaitingUserInput = false,
showEnterReconnectHint = false,
isCancelling: _isCancelling,
showLogs,
progressLogs,
onCancelConnect: _onCancelConnect,
onCloseSession,
onRetry,
reconnectLabel,
}) => {
const { t } = useI18n();
return (
<>
<div className="flex items-start justify-between gap-3 text-[11px] text-muted-foreground">
<div className="flex min-w-0 items-start gap-2">
{status === 'connecting' ? (
<>
<Loader2 className="h-3 w-3 mt-0.5 flex-shrink-0 animate-spin" />
<span className="min-w-0 whitespace-pre-wrap break-words leading-5">
{isAwaitingUserInput
? t('terminal.progress.waitingForUserInput')
: t('terminal.progress.timeoutIn', { seconds: timeLeft })}
</span>
</>
) : (
<>
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<span className="min-w-0 whitespace-pre-wrap break-words leading-5 text-destructive">
{error || t('terminal.progress.disconnected')}
</span>
</>
)}
</div>
</div>
{showLogs && (
<TerminalConnectionLogList progressLogs={progressLogs} error={error} />
)}
{status !== 'connecting' && (
<div className="flex flex-wrap items-center justify-between gap-2">
{showEnterReconnectHint && (
<div className="min-w-0 break-words text-[11px] leading-5 text-muted-foreground">
{t('terminal.progress.enterReconnectHint')}
</div>
)}
<div className="ml-auto flex shrink-0 justify-end gap-2">
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
{t('terminal.toolbar.closeSession')}
</Button>
{onRetry && (
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
<Play className="h-3 w-3 mr-1.5" /> {reconnectLabel ?? t('terminal.progress.startOver')}
</Button>
)}
</div>
</div>
)}
</>
);
};
export default TerminalConnectionProgress;

View File

@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('split menu keeps custom shortcuts aligned with the matching split actions', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: 'http://localhost',
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal('window', window);
installGlobal('document', window.document);
installGlobal('navigator', window.navigator);
installGlobal('HTMLElement', window.HTMLElement);
installGlobal('HTMLInputElement', window.HTMLInputElement);
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
installGlobal('Element', window.Element);
installGlobal('SVGElement', window.SVGElement);
installGlobal('Node', window.Node);
installGlobal('NodeFilter', window.NodeFilter);
installGlobal('MutationObserver', window.MutationObserver);
installGlobal('CustomEvent', window.CustomEvent);
installGlobal('DOMRect', window.DOMRect);
installGlobal('Event', window.Event);
installGlobal('KeyboardEvent', window.KeyboardEvent);
installGlobal('MouseEvent', window.MouseEvent);
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
installGlobal('ResizeObserver', ResizeObserverStub);
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
const { TerminalContextMenu } = await import('./TerminalContextMenu.tsx');
const rootNode = window.document.getElementById('root');
assert.ok(rootNode);
const root = createRoot(rootNode);
const actions: string[] = [];
const openMenu = async () => {
const surface = window.document.querySelector<HTMLElement>('[data-testid="terminal-surface"]');
assert.ok(surface);
await act(async () => {
surface.dispatchEvent(new window.MouseEvent('contextmenu', {
bubbles: true,
button: 2,
clientX: 20,
clientY: 20,
}));
});
};
const findMenuItem = (label: string): HTMLElement => {
const item = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((candidate) => candidate.textContent?.includes(label));
assert.ok(item, `${label} menu item should be visible`);
return item;
};
try {
await act(async () => {
root.render(
<I18nProvider locale="zh-CN">
<TerminalContextMenu
sessionId="issue-3082"
status="connected"
hotkeyScheme="mac"
keyBindings={[
{
id: 'split-horizontal',
action: 'splitHorizontal',
label: 'Split Horizontal',
mac: '⌘ + H',
pc: 'Ctrl + H',
category: 'navigation',
},
{
id: 'split-vertical',
action: 'splitVertical',
label: 'Split Vertical',
mac: '⌘ + V',
pc: 'Ctrl + V',
category: 'navigation',
},
]}
onSplitHorizontal={() => actions.push('horizontal')}
onSplitVertical={() => actions.push('vertical')}
>
<div data-testid="terminal-surface">Terminal</div>
</TerminalContextMenu>
</I18nProvider>,
);
});
await openMenu();
const horizontalItem = findMenuItem('水平分屏');
assert.match(horizontalItem.textContent ?? '', /水平分屏\s*⌘ H/);
const horizontalDivider = horizontalItem.querySelector('svg line');
assert.ok(horizontalDivider);
assert.equal(horizontalDivider.getAttribute('y1'), horizontalDivider.getAttribute('y2'));
assert.notEqual(horizontalDivider.getAttribute('x1'), horizontalDivider.getAttribute('x2'));
await act(async () => horizontalItem.click());
await openMenu();
const verticalItem = findMenuItem('垂直分屏');
assert.match(verticalItem.textContent ?? '', /垂直分屏\s*⌘ V/);
const verticalDivider = verticalItem.querySelector('svg line');
assert.ok(verticalDivider);
assert.equal(verticalDivider.getAttribute('x1'), verticalDivider.getAttribute('x2'));
assert.notEqual(verticalDivider.getAttribute('y1'), verticalDivider.getAttribute('y2'));
await act(async () => verticalItem.click());
assert.deepEqual(actions, ['horizontal', 'vertical']);
} finally {
await act(async () => root.unmount());
await new Promise((resolve) => setTimeout(resolve, 0));
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});

View File

@@ -0,0 +1,491 @@
import test from "node:test";
import assert from "node:assert/strict";
import en from "../../application/i18n/locales/en.ts";
import ru from "../../application/i18n/locales/ru.ts";
import es from "../../application/i18n/locales/es.ts";
import zhCN from "../../application/i18n/locales/zh-CN.ts";
import { markMiddleClickContextMenuEvent } from "./runtime/middleClickBehavior.ts";
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
import { shouldEnableYmodemAction } from "./TerminalView.tsx";
const shouldShowReconnectAction = (
terminalContextMenu as {
shouldShowReconnectAction?: (options: {
isReconnectable?: boolean;
onReconnect?: () => void;
}) => boolean;
}
).shouldShowReconnectAction;
const shouldSuppressMouseTrackingContextMenu = (
terminalContextMenu as {
shouldSuppressMouseTrackingContextMenu?: (options: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
}
).shouldSuppressMouseTrackingContextMenu;
const shouldShowAddSelectionToAIContextMenuAction = (
terminalContextMenu as {
shouldShowAddSelectionToAIContextMenuAction?: (onAddSelectionToAI?: () => void) => boolean;
}
).shouldShowAddSelectionToAIContextMenuAction;
const shouldShowUploadClipboardImageContextMenuAction = (
terminalContextMenu as {
shouldShowUploadClipboardImageContextMenuAction?: (onUploadClipboardImage?: () => void) => boolean;
}
).shouldShowUploadClipboardImageContextMenuAction;
const shouldOpenTerminalContextMenu = (
terminalContextMenu as {
shouldOpenTerminalContextMenu?: (options: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: "context-menu" | "paste" | "select-word";
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
}
).shouldOpenTerminalContextMenu;
const shouldRenderTerminalContextMenuContent = (
terminalContextMenu as {
shouldRenderTerminalContextMenuContent?: (options: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
}) => boolean;
}
).shouldRenderTerminalContextMenuContent;
const shouldAllowSuppressedTerminalContextMenuContent = (
terminalContextMenu as {
shouldAllowSuppressedTerminalContextMenuContent?: (options: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
}) => boolean;
}
).shouldAllowSuppressedTerminalContextMenuContent;
test("shows reconnect only for reconnectable terminals with a handler", () => {
assert.equal(typeof shouldShowReconnectAction, "function");
if (typeof shouldShowReconnectAction !== "function") return;
assert.equal(
shouldShowReconnectAction({
isReconnectable: true,
onReconnect: () => {},
}),
true,
);
assert.equal(
shouldShowReconnectAction({
isReconnectable: false,
onReconnect: () => {},
}),
false,
);
assert.equal(shouldShowReconnectAction({ isReconnectable: true }), false);
});
test("localizes the reconnect context menu label", () => {
assert.equal(en["terminal.menu.reconnect"], "Reconnect");
assert.equal(zhCN["terminal.menu.reconnect"], "重新连接");
});
test("shows add selection to AI context menu action when a handler exists", () => {
assert.equal(typeof shouldShowAddSelectionToAIContextMenuAction, "function");
if (typeof shouldShowAddSelectionToAIContextMenuAction !== "function") return;
assert.equal(shouldShowAddSelectionToAIContextMenuAction(() => {}), true);
assert.equal(shouldShowAddSelectionToAIContextMenuAction(), false);
});
test("shows upload clipboard image context menu action when a handler exists", () => {
assert.equal(typeof shouldShowUploadClipboardImageContextMenuAction, "function");
if (typeof shouldShowUploadClipboardImageContextMenuAction !== "function") return;
assert.equal(shouldShowUploadClipboardImageContextMenuAction(() => {}), true);
assert.equal(shouldShowUploadClipboardImageContextMenuAction(), false);
});
test("localizes the upload clipboard image context menu label", () => {
const locales = { en, ru, es, "zh-CN": zhCN };
const keys = [
"terminal.menu.uploadClipboardImage",
"terminal.clipboardImageUpload.noImage",
"terminal.clipboardImageUpload.failed",
] as const;
for (const [locale, messages] of Object.entries(locales)) {
for (const key of keys) {
assert.equal(
typeof messages[key],
"string",
`${locale} should include ${key}`,
);
assert.notEqual(messages[key], "", `${locale} should not leave ${key} empty`);
assert.notEqual(messages[key], key, `${locale} should translate ${key}`);
}
}
assert.equal(en["terminal.menu.uploadClipboardImage"], "Upload clipboard image");
assert.equal(zhCN["terminal.menu.uploadClipboardImage"], "上传剪贴板图片");
assert.equal(ru["terminal.menu.uploadClipboardImage"], "Загрузить изображение из буфера");
});
test("localizes the YMODEM serial send actions", () => {
assert.equal(en["terminal.menu.sendYmodem"], "Send with YMODEM");
assert.equal(en["terminal.menu.receiveYmodem"], "Receive with YMODEM");
assert.equal(en["terminal.toolbar.sendYmodem"], "Send with YMODEM");
assert.equal(en["terminal.toolbar.receiveYmodem"], "Receive with YMODEM");
assert.equal(zhCN["terminal.menu.sendYmodem"], "YMODEM 发送");
assert.equal(zhCN["terminal.menu.receiveYmodem"], "YMODEM 接收");
assert.equal(zhCN["terminal.toolbar.sendYmodem"], "YMODEM 发送");
assert.equal(zhCN["terminal.toolbar.receiveYmodem"], "YMODEM 接收");
});
test("enables YMODEM action only for connected serial terminals", () => {
const handler = () => {};
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "connected",
handleSendYmodem: handler,
}), true);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "connected",
handleReceiveYmodem: handler,
}), true);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "disconnected",
handleReceiveYmodem: handler,
}), false);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "disconnected",
handleSendYmodem: handler,
}), false);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: false,
status: "connected",
handleSendYmodem: handler,
}), false);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "connected",
}), false);
});
test("allows reconnect menu while stale mouse tracking is still active", () => {
assert.equal(typeof shouldSuppressMouseTrackingContextMenu, "function");
if (typeof shouldSuppressMouseTrackingContextMenu !== "function") return;
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: true,
}),
false,
);
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
});
test("forceMenuInAlternateScreen opts out of alternate-screen suppression", () => {
assert.equal(typeof shouldSuppressMouseTrackingContextMenu, "function");
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
if (
typeof shouldSuppressMouseTrackingContextMenu !== "function" ||
typeof shouldOpenTerminalContextMenu !== "function"
) {
return;
}
// Setting on: no suppression, right-click opens the app menu in tmux/vim.
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: false,
forceMenuInAlternateScreen: true,
}),
false,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: { shiftKey: false, nativeEvent: {} as MouseEvent },
rightClickBehavior: "context-menu",
isAlternateScreen: true,
showReconnectAction: false,
forceMenuInAlternateScreen: true,
}),
true,
);
// Setting off (default): alternate screen still suppresses the menu.
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: false,
forceMenuInAlternateScreen: false,
}),
true,
);
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: false,
forceMenuInAlternateScreen: false,
isHistoryPreviewTarget: true,
}),
false,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: { shiftKey: false, nativeEvent: {} as MouseEvent },
rightClickBehavior: "paste",
isAlternateScreen: true,
showReconnectAction: false,
isHistoryPreviewTarget: true,
}),
true,
);
});
test("opens a middle-click menu even when right-click is configured to paste", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
if (typeof shouldOpenTerminalContextMenu !== "function") return;
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
},
rightClickBehavior: "paste",
}),
true,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "paste",
}),
false,
);
});
test("opens and renders middle-click menu while alternate-screen mouse tracking suppresses right-click menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
assert.equal(typeof shouldAllowSuppressedTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function" ||
typeof shouldAllowSuppressedTerminalContextMenuContent !== "function"
) {
return;
}
const middleClickEvent = {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
};
assert.equal(
shouldOpenTerminalContextMenu({
event: middleClickEvent,
rightClickBehavior: "paste",
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
const allowSuppressedMenuContent = shouldAllowSuppressedTerminalContextMenuContent({
event: middleClickEvent,
isAlternateScreen: true,
showReconnectAction: false,
});
assert.equal(allowSuppressedMenuContent, true);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent,
}),
true,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "context-menu",
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldAllowSuppressedTerminalContextMenuContent({
event: {
nativeEvent: {} as MouseEvent,
},
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
false,
);
});
test("uses the current mouse tracking mode when the cached state is stale", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function"
) {
return;
}
const event = {
nativeEvent: {} as MouseEvent,
};
// xterm has already stopped reporting mouse events, but React still has
// the previous tracking state: paste/select-word must not be dropped.
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior: "paste",
isAlternateScreen: true,
terminalMouseTrackingMode: "none",
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
terminalMouseTrackingMode: "none",
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
true,
);
// Conversely, a newly active xterm mode must still suppress the app menu
// while the cached React state has not caught up.
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior: "context-menu",
isAlternateScreen: false,
terminalMouseTrackingMode: "vt200",
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: false,
terminalMouseTrackingMode: "vt200",
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
false,
);
});
test("opens Shift right-click menu content for all right-click modes while mouse tracking suppresses unmodified menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
assert.equal(typeof shouldAllowSuppressedTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function" ||
typeof shouldAllowSuppressedTerminalContextMenuContent !== "function"
) {
return;
}
const event = {
shiftKey: true,
nativeEvent: {} as MouseEvent,
};
for (const rightClickBehavior of ["context-menu", "paste", "select-word"] as const) {
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior,
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
const allowSuppressedMenuContent = shouldAllowSuppressedTerminalContextMenuContent({
event,
isAlternateScreen: true,
showReconnectAction: false,
});
assert.equal(allowSuppressedMenuContent, true);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent,
}),
true,
);
}
assert.equal(
shouldOpenTerminalContextMenu({
event: {
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "context-menu",
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldAllowSuppressedTerminalContextMenuContent({
event: {
nativeEvent: {} as MouseEvent,
},
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
});

View File

@@ -0,0 +1,505 @@
/**
* Terminal Context Menu
* Right-click menu for terminal with split, copy/paste, and other actions
*/
import {
ClipboardPaste,
Copy,
Download,
Pencil,
RefreshCcw,
Sparkles,
SquareArrowOutUpRight,
SplitSquareHorizontal,
SplitSquareVertical,
Terminal as TerminalIcon,
Trash2,
Upload,
} from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from '../ui/context-menu';
import { isMiddleClickContextMenuEvent, isMouseTrackingActive } from './runtime/middleClickBehavior';
import { isHistoryPreviewContextMenuTarget } from './runtime/terminalHistoryScrollOverride';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
export interface TerminalContextMenuProps {
children: React.ReactNode;
sessionId: string;
workspaceId?: string;
status: 'connecting' | 'connected' | 'disconnected';
hostId?: string;
hostProtocol?: string;
hasSelection?: boolean;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
/** Read the current xterm mouse-tracking mode when handling a right-click. */
getMouseTrackingMode?: () => string | undefined;
/** When true, show the app context menu even while a fullscreen app (tmux/vim) holds mouse tracking. */
showContextMenuOverFullscreenApps?: boolean;
onCopy?: () => void;
onPaste?: () => void;
onUploadClipboardImage?: () => void;
onPasteSelection?: () => void;
onSelectAll?: () => void;
onClear?: () => void;
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
isReconnectable?: boolean;
onReconnect?: () => void;
onClose?: () => void;
onSelectWord?: () => void;
onAddSelectionToAI?: () => void;
onRename?: () => void;
onDetach?: () => void;
}
export const shouldShowReconnectAction = ({
isReconnectable,
onReconnect,
}: {
isReconnectable?: boolean;
onReconnect?: () => void;
}): boolean => Boolean(isReconnectable && onReconnect);
export const shouldSuppressMouseTrackingContextMenu = ({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
isHistoryPreviewTarget,
}: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
isHistoryPreviewTarget?: boolean;
}): boolean => Boolean(
!isHistoryPreviewTarget
&& isMouseTrackingActive({
mouseTracking: Boolean(isAlternateScreen),
terminalMouseTrackingMode,
})
&& !showReconnectAction
&& !forceMenuInAlternateScreen,
);
export const shouldShowAddSelectionToAIContextMenuAction = (
onAddSelectionToAI?: () => void,
): boolean => Boolean(onAddSelectionToAI);
export const shouldShowUploadClipboardImageContextMenuAction = (
onUploadClipboardImage?: () => void,
): boolean => Boolean(onUploadClipboardImage);
export const shouldRenderTerminalContextMenuContent = ({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen,
isHistoryPreviewTarget,
}: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
forceMenuInAlternateScreen?: boolean;
isHistoryPreviewTarget?: boolean;
}): boolean =>
allowSuppressedMenuContent ||
!shouldSuppressMouseTrackingContextMenu({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
isHistoryPreviewTarget,
});
export const shouldAllowSuppressedTerminalContextMenuContent = ({
event,
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
isHistoryPreviewTarget,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
isHistoryPreviewTarget?: boolean;
}): boolean =>
isMiddleClickContextMenuEvent(event.nativeEvent)
|| Boolean(isHistoryPreviewTarget)
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
}));
export const shouldOpenTerminalContextMenu = ({
event,
rightClickBehavior = 'context-menu',
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
isHistoryPreviewTarget,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
isHistoryPreviewTarget?: boolean;
}): boolean => {
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
return true;
}
if (event.shiftKey || isHistoryPreviewTarget) {
return true;
}
if (shouldSuppressMouseTrackingContextMenu({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
isHistoryPreviewTarget,
})) {
return false;
}
return rightClickBehavior === 'context-menu';
};
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
children,
sessionId,
workspaceId,
status,
hostId,
hostProtocol,
hasSelection = false,
hotkeyScheme = 'mac',
keyBindings,
rightClickBehavior = 'context-menu',
isAlternateScreen = false,
getMouseTrackingMode,
showContextMenuOverFullscreenApps = false,
onCopy,
onPaste,
onUploadClipboardImage,
onPasteSelection,
onSelectAll,
onClear,
onSplitHorizontal,
onSplitVertical,
onSendYmodem,
onReceiveYmodem,
isReconnectable,
onReconnect,
onClose,
onSelectWord,
onAddSelectionToAI,
onRename,
onDetach,
}) => {
const { t } = useI18n();
const [menuOpen, setMenuOpen] = useState(false);
const terminalContext = buildTerminalPluginContributionContext({
surface: 'terminal/context',
sessionId,
status,
hostId,
hostProtocol,
workspaceId,
hasSelection,
alternateScreen: isAlternateScreen,
reconnectable: Boolean(isReconnectable),
});
const pluginContributions = usePluginContributions(
{ context: terminalContext },
{ enabled: menuOpen },
);
const pluginMenus = collectOwnedPluginMenus(pluginContributions.snapshot.plugins)
.filter((menu) => menu.location === 'terminal/context' && menu.visible)
.sort(comparePluginMenus);
const isMac = hotkeyScheme === 'mac';
// Tracks the .workspace-pane whose context menu is currently open so we can
// keep its `:focus-within`-driven opacity stable while focus is in the
// menu portal (otherwise the pane dims for the menu's lifetime).
const markedPaneRef = useRef<HTMLElement | null>(null);
const [allowSuppressedMenuContent, setAllowSuppressedMenuContent] = useState(false);
const handleOpenChange = useCallback((open: boolean) => {
setMenuOpen(open);
if (!open) {
markedPaneRef.current?.removeAttribute('data-menu-open');
markedPaneRef.current = null;
setAllowSuppressedMenuContent(false);
}
}, []);
// Helper to get shortcut from keyBindings and format for display
const getShortcut = (bindingId: string): string => {
const binding = keyBindings?.find(b => b.id === bindingId);
if (!binding) return '';
const key = isMac ? binding.mac : binding.pc;
if (!key || key === 'Disabled') return '';
// Replace " + " with space for cleaner display (e.g., "⌘ + Shift + D" → "⌘ Shift D")
return key.replace(/\s*\+\s*/g, ' ').trim();
};
const copyShortcut = getShortcut('copy');
const pasteShortcut = getShortcut('paste');
const pasteSelectionShortcut = getShortcut('paste-selection');
const selectAllShortcut = getShortcut('select-all');
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
const terminalMouseTrackingMode = getMouseTrackingMode?.();
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
// enabled so Shift+Right-Click opens the menu on the first click.
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus. Reconnect is
// still available after disconnect, even if mouse tracking was left on.
const currentMouseTrackingMode = getMouseTrackingMode?.();
const isHistoryPreviewTarget = isHistoryPreviewContextMenuTarget(e.target);
const shouldOpenMenu = shouldOpenTerminalContextMenu({
event: e,
rightClickBehavior,
isAlternateScreen,
terminalMouseTrackingMode: currentMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
isHistoryPreviewTarget,
});
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({
isAlternateScreen,
terminalMouseTrackingMode: currentMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
isHistoryPreviewTarget,
})) {
e.preventDefault();
return;
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (shouldOpenMenu) {
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
if (pane) {
markedPaneRef.current?.removeAttribute('data-menu-open');
pane.setAttribute('data-menu-open', '');
markedPaneRef.current = pane;
}
setAllowSuppressedMenuContent(shouldAllowSuppressedTerminalContextMenuContent({
event: e,
isAlternateScreen,
terminalMouseTrackingMode: currentMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
isHistoryPreviewTarget,
}));
return;
}
// Paste / select-word: intercept and prevent the context menu
e.preventDefault();
if (rightClickBehavior === 'paste') {
onPaste?.();
} else if (rightClickBehavior === 'select-word') {
onSelectWord?.();
}
},
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, getMouseTrackingMode, showReconnectAction, showContextMenuOverFullscreenApps],
);
// Always use ContextMenu wrapper to maintain consistent React tree structure
// This prevents terminal from unmounting when rightClickBehavior changes
return (
<ContextMenu onOpenChange={handleOpenChange}>
<ContextMenuTrigger
asChild
onContextMenu={handleRightClick}
>
{children}
</ContextMenuTrigger>
{shouldRenderTerminalContextMenuContent({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
}) && (
<ContextMenuContent className="w-max">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
{t('terminal.menu.copy')}
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onPaste}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{shouldShowUploadClipboardImageContextMenuAction(onUploadClipboardImage) && (
<ContextMenuItem onClick={onUploadClipboardImage}>
<Upload size={14} className="mr-2" />
{t('terminal.menu.uploadClipboardImage')}
</ContextMenuItem>
)}
{shouldShowAddSelectionToAIContextMenuAction(onAddSelectionToAI) && (
<ContextMenuItem onClick={onAddSelectionToAI} disabled={!hasSelection}>
<Sparkles size={14} className="mr-2" />
{t('terminal.menu.addSelectionToAI')}
</ContextMenuItem>
)}
{onPasteSelection && (
<ContextMenuItem onClick={onPasteSelection} disabled={!hasSelection}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.pasteSelection')}
<ContextMenuShortcut>{pasteSelectionShortcut}</ContextMenuShortcut>
</ContextMenuItem>
)}
<ContextMenuItem onClick={onSelectAll}>
<TerminalIcon size={14} className="mr-2" />
{t('terminal.menu.selectAll')}
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{showReconnectAction && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onReconnect}>
<RefreshCcw size={14} className="mr-2" />
{t('terminal.menu.reconnect')}
</ContextMenuItem>
</>
)}
{(onSendYmodem || onReceiveYmodem) && (
<>
<ContextMenuSeparator />
{onSendYmodem && (
<ContextMenuItem onClick={onSendYmodem}>
<Upload size={14} className="mr-2" />
{t('terminal.menu.sendYmodem')}
</ContextMenuItem>
)}
{onReceiveYmodem && (
<ContextMenuItem onClick={onReceiveYmodem}>
<Download size={14} className="mr-2" />
{t('terminal.menu.receiveYmodem')}
</ContextMenuItem>
)}
</>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={onSplitHorizontal}>
<SplitSquareVertical size={14} className="mr-2" />
{t('terminal.menu.splitHorizontal')}
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSplitVertical}>
<SplitSquareHorizontal size={14} className="mr-2" />
{t('terminal.menu.splitVertical')}
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onClear}>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.clearBuffer')}
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onRename && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onRename}>
<Pencil size={14} className="mr-2" />
{t('terminal.menu.rename')}
</ContextMenuItem>
</>
)}
{onDetach && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDetach}>
<SquareArrowOutUpRight size={14} className="mr-2" />
{t('terminal.menu.detach')}
</ContextMenuItem>
</>
)}
{pluginMenus.length > 0 && (
<>
<ContextMenuSeparator />
{pluginMenus.map((menu) => (
<ContextMenuItem
key={menu.id}
disabled={!menu.enabled}
onClick={(event) => void pluginContributions.executeCommand(event.altKey && menu.alt ? menu.alt : menu.command, undefined, {
...terminalContext,
}).catch(() => {})}
>
<PluginContributionIcon pluginId={menu.pluginId} icon={menu.icon} className="mr-2" />
{menu.title}
{menu.checked && <span className="ml-auto pl-4" aria-hidden="true"></span>}
{menu.shortcut && <ContextMenuShortcut>{menu.shortcut}</ContextMenuShortcut>}
</ContextMenuItem>
))}
</>
)}
{onClose && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.closeTerminal')}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
)}
</ContextMenu>
);
};
export default TerminalContextMenu;

View File

@@ -0,0 +1,120 @@
import { AlertTriangle, Fingerprint } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { HostKeyInfo } from '../../domain/hostKey';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { TerminalConnectionLogList } from './TerminalConnectionProgress';
export type { HostKeyInfo } from '../../domain/hostKey';
export interface TerminalHostKeyVerificationProps {
hostKeyInfo: HostKeyInfo;
showLogs: boolean;
progressLogs: string[];
onClose: () => void;
onContinue: () => void;
onAddAndContinue: () => void;
}
export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationProps> = ({
hostKeyInfo,
showLogs,
progressLogs,
onClose,
onContinue,
onAddAndContinue,
}) => {
const { t } = useI18n();
const isChanged = hostKeyInfo.status === 'changed';
const Icon = isChanged ? AlertTriangle : Fingerprint;
return (
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div
className={cn(
"rounded-xl border px-3 py-2.5",
isChanged
? "border-destructive/25 bg-destructive/8"
: "border-amber-500/20 bg-amber-500/8",
)}
>
<div className="flex items-start gap-2.5">
<div
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
isChanged
? "bg-destructive/15 text-destructive"
: "bg-amber-500/15 text-amber-400",
)}
>
<Icon size={15} />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div
className={cn(
"text-sm font-semibold",
isChanged ? "text-destructive" : "text-amber-400",
)}
>
{isChanged
? t('terminal.hostKey.changedTitle')
: t('terminal.hostKey.unknownTitle')}
</div>
<p className="text-xs leading-5 text-muted-foreground">
{isChanged
? t('terminal.hostKey.changedDescription', { host: hostKeyInfo.hostname })
: t('terminal.hostKey.unknownDescription', { host: hostKeyInfo.hostname })}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-[11px] text-muted-foreground">
{t('terminal.hostKey.fingerprintLabel', { keyType: hostKeyInfo.keyType })}
</div>
<div className="rounded-lg border border-border/50 bg-background/45 p-3">
<code className="block break-all font-mono text-xs leading-5 text-foreground/90">
{hostKeyInfo.fingerprint}
</code>
</div>
{isChanged && hostKeyInfo.knownFingerprint && (
<div className="rounded-lg border border-destructive/25 bg-destructive/8 p-3">
<div className="mb-1 text-[11px] font-medium text-destructive">
{t('terminal.hostKey.savedFingerprintLabel')}
</div>
<code className="block break-all font-mono text-xs leading-5 text-foreground/90">
{hostKeyInfo.knownFingerprint}
</code>
</div>
)}
<p className="text-xs leading-5 text-muted-foreground">
{isChanged
? t('terminal.hostKey.changedHint')
: t('terminal.hostKey.unknownHint')}
</p>
</div>
{showLogs && (
<TerminalConnectionLogList progressLogs={progressLogs} />
)}
<div className="flex justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onClose}>
{t('common.close')}
</Button>
<Button variant="outline" size="sm" className="h-7 px-3 text-[11px]" onClick={onContinue}>
{t('common.continue')}
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onAddAndContinue}>
{isChanged
? t('terminal.hostKey.updateAndContinue')
: t('terminal.hostKey.addAndContinue')}
</Button>
</div>
</div>
);
};
export default TerminalHostKeyVerification;

View File

@@ -0,0 +1,192 @@
/**
* Terminal Search Bar
* Provides search functionality within terminal scrollback buffer
*/
import { ChevronUp, ChevronDown, Search } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface TerminalSearchBarProps {
isOpen: boolean;
/**
* Incremented each time the search hotkey fires while the bar is already
* open. Watched by the focus effect so Cmd/Ctrl+F re-grabs focus when it
* has moved elsewhere (issue #1789). Ignored while `isOpen` is false.
*/
focusToken?: number;
onClose: () => void;
onSearch: (term: string) => boolean;
onFindNext: () => boolean;
onFindPrevious: () => boolean;
matchCount?: { current: number; total: number } | null;
}
export const notifyTerminalSearchTermChange = (
searchTerm: string,
previousSearchTerm: string,
onSearch: (term: string) => boolean,
): string => {
if (searchTerm === previousSearchTerm) return previousSearchTerm;
onSearch(searchTerm);
return searchTerm;
};
export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
isOpen,
focusToken,
onClose,
onSearch,
onFindNext,
onFindPrevious,
matchCount,
}) => {
const { t } = useI18n();
const [searchTerm, setSearchTerm] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const prevSearchTermRef = useRef('');
// Focus input when opened, or when the search hotkey re-fires while open
// (focusToken bumps) so focus returns to the input after it moved elsewhere.
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isOpen, focusToken]);
// Trigger search when term changes. When the term is cleared we still call
// onSearch('') so the underlying search addon clears its highlights;
// otherwise the last match decorations linger after emptying the input.
useEffect(() => {
prevSearchTermRef.current = notifyTerminalSearchTermChange(
searchTerm,
prevSearchTermRef.current,
onSearch,
);
}, [searchTerm, onSearch]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
} else if (e.key === 'F3' || (e.key === 'g' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
if (e.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
}
}, [onClose, onFindNext, onFindPrevious]);
if (!isOpen) return null;
return (
<div
className="flex items-center gap-1.5 px-2 pt-0 pb-2 bg-black/50 backdrop-blur-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 86%, transparent)',
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 40%, transparent)' }}
/>
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
data-terminal-search-input=""
placeholder={t("terminal.search.placeholder")}
className="w-full h-6 pl-7 pr-2 text-[11px] border-none rounded placeholder:opacity-40 focus:outline-none"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 5%, transparent)',
color: 'var(--terminal-ui-fg, #ffffff)',
}}
/>
</div>
{/* Match count indicator - only show when no results */}
{searchTerm.length > 0 && matchCount?.total === 0 && (
<span
className="text-[10px] flex-shrink-0"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 50%, transparent)' }}
>
{t("terminal.search.noResults")}
</span>
)}
{/* Navigation buttons */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindPrevious();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
tabIndex={-1}
>
<ChevronUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.search.prevMatch")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindNext();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
tabIndex={-1}
>
<ChevronDown size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.search.nextMatch")}</TooltipContent>
</Tooltip>
</div>
</div>
);
};

View File

@@ -0,0 +1,270 @@
/**
* Owns selection-driven "Add to AI" chrome so selection change does not re-render
* the whole Terminal / TerminalView tree (common when focus moves to the AI input).
*/
import React, { memo, useEffect, useState, type RefObject } from 'react';
import type { Terminal as XTerm } from '@xterm/xterm';
import { Sparkles } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
createCopyOnSelectUserGestureTracker,
shouldWriteCopyOnSelect,
subscribeCopyOnSelectUserCommand,
subscribeCopyOnSelectUserGesture,
} from './copyOnSelect';
import { getTerminalSelectionForClipboard } from './normalizeTerminalSelection';
import { resolveSelectionOverlayPosition } from './useTerminalEffects';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { shouldShowSelectionAIOverlay } from './TerminalView';
type SelectionOverlayPosition = { left: number; top: number } | null;
const areSelectionOverlayPositionsEqual = (
a: SelectionOverlayPosition,
b: SelectionOverlayPosition,
): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return a.left === b.left && a.top === b.top;
};
type Props = {
termRef: RefObject<XTerm | null>;
containerRef: RefObject<HTMLElement | null>;
showSelectionAIAction?: boolean;
onAddSelectionToAI?: () => void;
copyOnSelect?: boolean;
normalizeTextOnCopy?: boolean;
/**
* True while createXTermRuntime programmatically restores selection
* (preserveSelectionOnInput). Copy-on-select must skip those events.
*/
isRestoringSelectionRef?: RefObject<boolean>;
isVisible?: boolean;
};
function TerminalSelectionAIOverlayInner({
termRef,
containerRef,
showSelectionAIAction,
onAddSelectionToAI,
copyOnSelect,
normalizeTextOnCopy = true,
isRestoringSelectionRef,
isVisible = true,
}: Props) {
const { t } = useI18n();
const [hasSelection, setHasSelection] = useState(false);
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<SelectionOverlayPosition>(null);
useEffect(() => {
if (!isVisible) return;
let disposed = false;
let overlayRafId: number | null = null;
let copyTimer: ReturnType<typeof setTimeout> | null = null;
let waitRafId: number | null = null;
let lastHasSelection: boolean | null = null;
let lastOverlayPosition: SelectionOverlayPosition = null;
let selectionDisposable: { dispose: () => void } | null = null;
let scrollDisposable: { dispose: () => void } | null | undefined = null;
let resizeDisposable: { dispose: () => void } | null | undefined = null;
let resizeObserver: ResizeObserver | null = null;
let userGestureUnsubscribe: (() => void) | null = null;
let userGestureTracker: ReturnType<typeof createCopyOnSelectUserGestureTracker> | null = null;
const requestFrame = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0) as unknown as number;
const cancelFrame = typeof cancelAnimationFrame === 'function'
? cancelAnimationFrame
: (id: number) => clearTimeout(id);
const cleanupListeners = () => {
if (overlayRafId !== null) {
cancelFrame(overlayRafId);
overlayRafId = null;
}
if (copyTimer) {
clearTimeout(copyTimer);
copyTimer = null;
}
selectionDisposable?.dispose();
selectionDisposable = null;
scrollDisposable?.dispose();
scrollDisposable = null;
resizeDisposable?.dispose();
resizeDisposable = null;
resizeObserver?.disconnect();
resizeObserver = null;
userGestureUnsubscribe?.();
userGestureUnsubscribe = null;
userGestureTracker?.dispose();
userGestureTracker = null;
};
const attach = (term: XTerm) => {
cleanupListeners();
userGestureTracker = createCopyOnSelectUserGestureTracker();
const unsubscribePointer = subscribeCopyOnSelectUserGesture(term, userGestureTracker);
const unsubscribeCommand = subscribeCopyOnSelectUserCommand(term, () => {
userGestureTracker?.pulse();
});
userGestureUnsubscribe = () => {
unsubscribePointer();
unsubscribeCommand();
};
const publishSelectionOverlayPosition = () => {
overlayRafId = null;
if (disposed) return;
const nextPosition = resolveSelectionOverlayPosition(term, containerRef.current);
if (areSelectionOverlayPositionsEqual(lastOverlayPosition, nextPosition)) return;
lastOverlayPosition = nextPosition;
setSelectionOverlayPosition(nextPosition);
};
const scheduleSelectionOverlayPosition = () => {
if (lastHasSelection === false) return;
if (overlayRafId !== null) return;
overlayRafId = requestFrame(publishSelectionOverlayPosition);
};
const onSelectionChange = (options?: { allowCopy?: boolean }) => {
if (disposed) return;
const allowCopy = options?.allowCopy !== false;
const rawSelection = term.getSelection();
const hasText = !!rawSelection && rawSelection.length > 0;
if (lastHasSelection !== hasText) {
lastHasSelection = hasText;
setHasSelection(hasText);
}
if (copyTimer) {
clearTimeout(copyTimer);
copyTimer = null;
}
if (!hasText) {
if (lastOverlayPosition !== null) {
lastOverlayPosition = null;
setSelectionOverlayPosition(null);
}
return;
}
scheduleSelectionOverlayPosition();
// Skip programmatic selections: preserveSelectionOnInput restore,
// SearchAddon match highlight (issue #3007), and the initial attach
// snapshot so those writes cannot clobber a user copy.
if (shouldWriteCopyOnSelect({
allowCopy,
hasText,
copyOnSelect: !!copyOnSelect,
isRestoringSelection: !!isRestoringSelectionRef?.current,
isUserSelection: !!userGestureTracker?.isActive(),
})) {
const selection = getTerminalSelectionForClipboard(term, normalizeTextOnCopy);
if (!selection) return;
copyTimer = setTimeout(() => {
void navigator.clipboard.writeText(selection).catch(() => {
/* ignore clipboard failures */
});
}, 80);
}
};
selectionDisposable = term.onSelectionChange(() => onSelectionChange());
scrollDisposable = term.onScroll?.(scheduleSelectionOverlayPosition);
resizeDisposable = term.onResize?.(scheduleSelectionOverlayPosition);
resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(scheduleSelectionOverlayPosition);
if (containerRef.current) {
resizeObserver?.observe(containerRef.current);
}
// Sync UI only; do not write clipboard on reattach.
onSelectionChange({ allowCopy: false });
};
// Child effects run before parent useTerminalEffects assigns termRef.
// Poll until the xterm runtime exists so copy-on-select / overlay attach
// for sessions that mount already visible. Bound the wait so a failed
// runtime never leaves a permanent rAF loop.
const waitStartedAt = Date.now();
const MAX_RUNTIME_WAIT_MS = 15_000;
const tryAttach = () => {
if (disposed) return;
const term = termRef.current;
if (!term) {
if (Date.now() - waitStartedAt >= MAX_RUNTIME_WAIT_MS) {
waitRafId = null;
return;
}
waitRafId = requestFrame(tryAttach);
return;
}
waitRafId = null;
attach(term);
};
tryAttach();
return () => {
disposed = true;
if (waitRafId !== null) cancelFrame(waitRafId);
cleanupListeners();
};
}, [
termRef,
containerRef,
copyOnSelect,
normalizeTextOnCopy,
isRestoringSelectionRef,
isVisible,
]);
if (!shouldShowSelectionAIOverlay({
hasSelection,
selectionOverlayPosition,
onAddSelectionToAI,
showSelectionAIAction,
}) || !onAddSelectionToAI || !selectionOverlayPosition) {
return null;
}
return (
<div
className="absolute z-30 pointer-events-none"
style={{
left: selectionOverlayPosition.left,
top: selectionOverlayPosition.top,
transform: 'translate(-100%, -100%)',
}}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="pointer-events-auto inline-flex h-7 min-w-max items-center gap-1.5 whitespace-nowrap rounded-md border px-2 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-[color:var(--terminal-toolbar-btn-hover)]"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg) 86%, transparent)',
borderColor: 'var(--terminal-ui-border)',
color: 'var(--terminal-ui-fg)',
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={onAddSelectionToAI}
aria-label={t('terminal.selection.addToAI')}
>
<Sparkles size={12} />
<span>{t('terminal.selection.addToAI')}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t('terminal.selection.addToAIDesc')}</TooltipContent>
</Tooltip>
</div>
);
}
export const TerminalSelectionAIOverlay = memo(TerminalSelectionAIOverlayInner);
TerminalSelectionAIOverlay.displayName = 'TerminalSelectionAIOverlay';

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("CPU per-core stats list can scroll when many cores are reported", () => {
const source = readFileSync(new URL("./TerminalServerStats.tsx", import.meta.url), "utf8");
assert.match(
source,
/className="grid gap-1\.5 max-h-\[\d+px\] overflow-y-auto/,
);
});
test("server stats stay subscribed while the terminal is in the background", () => {
const source = readFileSync(new URL("./TerminalServerStats.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /usePaneVisible/);
assert.doesNotMatch(source, /isVisible,/);
});

View File

@@ -0,0 +1,381 @@
import React from 'react';
import { Activity, ArrowDownToLine, ArrowUpFromLine, Cpu, HardDrive, MemoryStick } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { formatNetSpeed } from './terminalHelpers';
import { useServerStats } from '../../application/state/useServerStats';
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
interface TerminalServerStatsProps {
sessionId: string;
enabled: boolean;
refreshInterval: number;
isSupportedOs: boolean;
isConnected: boolean;
}
const formatLatency = (latencyMs: number | null): string => (
typeof latencyMs === 'number' && Number.isFinite(latencyMs) ? `${latencyMs}ms` : '--ms'
);
/**
* Self-contained server-stats (CPU / Memory / Disk / Network) indicator.
*
* Owns the `useServerStats` polling itself so the periodic (~5s) stats refresh
* only re-renders this small widget — previously the hook lived at the top of
* <Terminal> and `serverStats` was threaded through the giant TerminalView ctx,
* so every refresh re-rendered the whole terminal subtree (~45ms each, even
* while idle).
*/
export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
}) => {
const { t } = useI18n();
const { stats: serverStats } = useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
});
const hasNetworkDetails = serverStats.netInterfaces.length > 0;
const hasLatency = serverStats.latencyMs !== null;
const diskSummary = resolveTerminalDiskSummary(serverStats);
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
return (
<div className="terminal-server-stats flex items-center gap-2 ml-1 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0 shrink">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span className="truncate">
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
{serverStats.cpuPerCore.length > 0 ? (
<div className="grid gap-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
{serverStats.cpuPerCore.map((usage, index) => (
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
<div className="text-[10px] text-muted-foreground">Core {index}</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${usage}%` }}
/>
</div>
<div className={cn(
"text-[11px] font-medium",
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{usage}%
</div>
</div>
))}
</div>
) : serverStats.cpu !== null ? (
<div className="flex flex-col gap-1.5 min-w-[160px]">
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${serverStats.cpu}%` }}
/>
</div>
<div className={cn(
"text-center text-[11px] font-medium",
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
</div>
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span className="truncate">
{serverStats.memUsed !== null && serverStats.memTotal !== null
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-3 min-w-[280px]">
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
{/* htop-style memory bar */}
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) — exact value shown in legend below */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
/>
)}
{/* Buffers (blue) */}
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
/>
)}
{/* Cached (amber/orange) */}
{serverStats.memCached !== null && serverStats.memCached > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-blue-500" />
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-amber-500" />
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
</div>
</div>
</div>
)}
{/* Swap bar */}
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
/>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-rose-500" />
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
{serverStats.topProcesses.map((proc, index) => (
<div key={index} className="flex items-center gap-2 text-[10px]">
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</TooltipTrigger>
<TooltipContent>{proc.command}</TooltipContent>
</Tooltip>
</div>
))}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Disk - with HoverCard for disk details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
"truncate",
diskSummary.percent !== null && diskSummary.percent >= 90 && "text-red-400",
diskSummary.percent !== null && diskSummary.percent >= 80 && diskSummary.percent < 90 && "text-amber-400"
)}>
{diskSummary.used !== null && diskSummary.total !== null && diskSummary.percent !== null
? `${formatDiskCapacityRange(diskSummary.used, diskSummary.total)} (${diskSummary.percent}%)`
: diskSummary.percent !== null
? `${diskSummary.percent}%`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
{serverStats.disks.length > 0 ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
{disk.mountPoint}
</span>
</TooltipTrigger>
<TooltipContent>{disk.mountPoint}</TooltipContent>
</Tooltip>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{formatDiskCapacityRange(disk.used, disk.total)} ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${disk.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Network - with HoverCard for per-interface details */}
{(hasNetworkDetails || hasLatency) && (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span className="truncate">{formatNetSpeed(serverStats.netRxSpeed)}</span>
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
<span className="truncate">{formatNetSpeed(serverStats.netTxSpeed)}</span>
<Activity size={9} className="flex-shrink-0 text-violet-400" />
<span className="truncate">{formatLatency(serverStats.latencyMs)}</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
<div className="flex items-center justify-between gap-4 min-w-[200px]">
<span className="text-[10px] text-muted-foreground">
{t("terminal.serverStats.latency")}
</span>
<span className="flex items-center gap-0.5 text-violet-400">
<Activity size={9} />
{formatLatency(serverStats.latencyMs)}
</span>
</div>
{hasNetworkDetails ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.netInterfaces.map((iface, index) => (
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
<span className="text-[10px] text-muted-foreground font-mono">
{iface.name}
</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-0.5 text-emerald-400">
<ArrowDownToLine size={9} />
{formatNetSpeed(iface.rxSpeed)}
</span>
<span className="flex items-center gap-0.5 text-sky-400">
<ArrowUpFromLine size={9} />
{formatNetSpeed(iface.txSpeed)}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
);
};

View File

@@ -0,0 +1,222 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
getTerminalTimestampTypography,
resolveTerminalTimestampGutterRenderSignature,
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
syncTerminalTimestampGutterRows,
} from "./TerminalTimestampGutter.tsx";
test("timestamp gutter uses a bright color from the active terminal theme", () => {
assert.equal(
resolveTerminalTimestampGutterColor({
brightCyan: "#66e8ff",
brightYellow: "#ffe066",
foreground: "#dddddd",
}),
"#66e8ff",
);
});
test("timestamp gutter falls back within the terminal theme palette", () => {
assert.equal(
resolveTerminalTimestampGutterColor({
brightYellow: "#ffe066",
foreground: "#dddddd",
}),
"#ffe066",
);
assert.equal(
resolveTerminalTimestampGutterColor({
foreground: "#dddddd",
}),
"#dddddd",
);
});
test("timestamp gutter width follows measured timestamp text width", () => {
assert.equal(
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 84, fontSize: 14 }),
84 + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
);
assert.equal(
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 1, fontSize: 14 }),
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
);
});
test("timestamp gutter typography follows terminal typography", () => {
assert.deepEqual(
getTerminalTimestampTypography({
fontFamily: '"JetBrains Mono", monospace',
fontSize: 15,
fontWeight: 500,
}),
{
fontFamily: '"JetBrains Mono", monospace',
fontSize: 15,
fontWeight: 500,
},
);
});
test("timestamp gutter uses the terminal background", () => {
const source = readFileSync(new URL("./TerminalTimestampGutter.tsx", import.meta.url), "utf8");
assert.match(source, /backgroundColor: "var\(--terminal-ui-bg\)"/);
assert.doesNotMatch(source, /bg-black\/10/);
assert.match(source, /boxShadow: "inset -0\.5px 0 0 color-mix\(in srgb, var\(--terminal-ui-fg\) 8%, transparent\)"/);
assert.doesNotMatch(source, /border-r/);
});
test("timestamp gutter render signature is stable and changes only for visible inputs", () => {
const base = resolveTerminalTimestampGutterRenderSignature({
screenTop: 8,
cellHeight: 17,
color: "#66e8ff",
fontFamily: "JetBrains Mono",
fontSize: 14,
fontWeight: 500,
rows: [
{ row: 0, label: "10:00:00" },
{ row: 2, label: "10:00:02" },
],
});
assert.equal(
resolveTerminalTimestampGutterRenderSignature({
screenTop: 8,
cellHeight: 17,
color: "#66e8ff",
fontFamily: "JetBrains Mono",
fontSize: 14,
fontWeight: 500,
rows: [
{ row: 0, label: "10:00:00" },
{ row: 2, label: "10:00:02" },
],
}),
base,
);
assert.notEqual(
resolveTerminalTimestampGutterRenderSignature({
screenTop: 8,
cellHeight: 17,
color: "#66e8ff",
fontFamily: "JetBrains Mono",
fontSize: 14,
fontWeight: 500,
rows: [
{ row: 0, label: "10:00:00" },
{ row: 3, label: "10:00:02" },
],
}),
base,
);
});
test("timestamp gutter flood throttle advances even when the paint signature is unchanged", () => {
const source = readFileSync(new URL("./TerminalTimestampGutter.tsx", import.meta.url), "utf8");
// lastFloodRenderAt must move on every render attempt, before the signature early-return.
const renderStart = source.indexOf("const render = () => {");
const signatureReturn = source.indexOf("if (signature === lastRenderSignature) return;", renderStart);
const floodAdvance = source.indexOf("lastFloodRenderAt = performance.now();", renderStart);
assert.notEqual(renderStart, -1);
assert.notEqual(signatureReturn, -1);
assert.notEqual(floodAdvance, -1);
assert.ok(
floodAdvance < signatureReturn,
"flood throttle clock must advance before the unchanged-signature early return",
);
});
test("timestamp gutter throttles output-driven scroll events under flood pressure", () => {
const source = readFileSync(new URL("./TerminalTimestampGutter.tsx", import.meta.url), "utf8");
assert.match(source, /term\.onScroll\?\.\(\(\) => \{/);
assert.match(source, /getTerminalOutputPressure\(term\)/);
// largeOutput only (time-bounded); longLine must not sticky-throttle user scrolls.
assert.match(source, /scheduleRender\(pressure\.largeOutput \? "normal" : "immediate"\)/);
assert.doesNotMatch(
source,
/onScroll[\s\S]{0,200}pressure\.largeOutput \|\| pressure\.longLine/,
);
});
test("timestamp gutter reuses row nodes across paints instead of rebuilding the tree", () => {
const gutter = {
children: [] as Array<Record<string, unknown>>,
appendChild(node: Record<string, unknown>) {
this.children.push(node);
return node;
},
};
const createElement = (tag: string) => {
assert.equal(tag, "div");
return {
textContent: "",
className: "",
style: {} as Record<string, string>,
};
};
const previousCreateElement = globalThis.document?.createElement;
(globalThis as { document?: { createElement: typeof createElement } }).document = {
createElement,
};
try {
const layout = {
screenTop: 0,
cellHeight: 16,
color: "#66e8ff",
fontFamily: "monospace",
fontSize: 14,
fontWeight: 400,
};
syncTerminalTimestampGutterRows(
gutter as never,
[
{ row: 0, label: "10:00:00" },
{ row: 1, label: "10:00:01" },
],
layout,
);
assert.equal(gutter.children.length, 2);
const firstNode = gutter.children[0];
assert.equal(firstNode.textContent, "10:00:00");
syncTerminalTimestampGutterRows(
gutter as never,
[
{ row: 0, label: "10:00:02" },
{ row: 2, label: "10:00:03" },
],
layout,
);
assert.equal(gutter.children.length, 2);
assert.equal(gutter.children[0], firstNode);
assert.equal(firstNode.textContent, "10:00:02");
assert.equal(gutter.children[1].textContent, "10:00:03");
syncTerminalTimestampGutterRows(
gutter as never,
[{ row: 0, label: "10:00:04" }],
layout,
);
assert.equal(gutter.children.length, 2);
assert.equal((gutter.children[1].style as Record<string, string>).display, "none");
} finally {
if (previousCreateElement) {
(globalThis as { document: { createElement: typeof previousCreateElement } }).document = {
createElement: previousCreateElement,
};
}
}
});

View File

@@ -0,0 +1,458 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
getVisibleTerminalLineTimestampRows,
onTerminalLineTimestampsChange,
} from "./runtime/terminalLineTimestamps";
import type { TerminalTimestampGutterRow } from "./runtime/terminalLineTimestamps";
import { getTerminalOutputPressure } from "./runtime/terminalOutputPressure";
export const TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH = 56;
export const TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING = 16;
export const TERMINAL_TIMESTAMP_SAMPLE_LABEL = "88:88:88";
/** Cap gutter paint rate while large-output pressure is active (rAF still coalesces). */
export const TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS = 100;
const GUTTER_ROW_CLASS =
"absolute left-0 right-0 px-2 text-right tabular-nums whitespace-nowrap";
type TerminalTimestampGutterProps = {
termRef: RefObject<XTerm | null>;
containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
top: string;
left?: number;
bottom?: number;
sessionId: string;
color: string;
fontFamily: string;
fontSize: number;
fontWeight: string | number;
width: number;
onWidthChange?: (width: number) => void;
};
type DisposableLike = {
dispose: () => void;
};
type TerminalTimestampTypography = {
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
};
const getTerminalScreen = (container: HTMLElement): HTMLElement => (
container.querySelector<HTMLElement>(".xterm-screen") ?? container
);
const clearElement = (element: HTMLElement) => {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
};
const applyGutterRowStyles = (
item: HTMLElement,
{
row,
label,
screenTop,
cellHeight,
color,
fontFamily,
fontSize,
fontWeight,
}: {
row: number;
label: string;
screenTop: number;
cellHeight: number;
color: string;
fontFamily: string;
fontSize: number;
fontWeight: string | number;
},
) => {
if (item.textContent !== label) {
item.textContent = label;
}
item.style.top = `${screenTop + row * cellHeight}px`;
item.style.height = `${cellHeight}px`;
item.style.lineHeight = `${cellHeight}px`;
item.style.color = color;
item.style.fontFamily = fontFamily;
item.style.fontSize = `${fontSize}px`;
item.style.fontWeight = String(fontWeight);
item.style.fontVariantNumeric = "tabular-nums";
item.style.display = "";
};
/**
* Reuse a fixed pool of row divs instead of clear+create on every paint.
* Returns the number of visible nodes kept after the update.
*/
export const syncTerminalTimestampGutterRows = (
gutter: HTMLElement,
rows: readonly TerminalTimestampGutterRow[],
layout: {
screenTop: number;
cellHeight: number;
color: string;
fontFamily: string;
fontSize: number;
fontWeight: string | number;
},
): number => {
const existing = gutter.children;
let index = 0;
for (; index < rows.length; index += 1) {
const { row, label } = rows[index];
let item = existing[index] as HTMLElement | undefined;
if (!item) {
item = document.createElement("div");
item.className = GUTTER_ROW_CLASS;
gutter.appendChild(item);
}
applyGutterRowStyles(item, {
row,
label,
screenTop: layout.screenTop,
cellHeight: layout.cellHeight,
color: layout.color,
fontFamily: layout.fontFamily,
fontSize: layout.fontSize,
fontWeight: layout.fontWeight,
});
}
// Hide surplus pooled nodes (keep them for the next paint).
for (; index < existing.length; index += 1) {
(existing[index] as HTMLElement).style.display = "none";
}
return rows.length;
};
export const resolveTerminalTimestampGutterColor = (
colors: Partial<Record<"brightCyan" | "brightYellow" | "brightMagenta" | "foreground", string>>,
): string => (
colors.brightCyan
|| colors.brightYellow
|| colors.brightMagenta
|| colors.foreground
|| "currentColor"
);
const normalizeTerminalTimestampFontSize = (fontSize?: number): number => (
Number.isFinite(fontSize) && fontSize && fontSize > 0 ? fontSize : 12
);
export const getTerminalTimestampTypography = ({
fontFamily,
fontSize,
fontWeight,
}: TerminalTimestampTypography) => ({
fontFamily: fontFamily || "monospace",
fontSize: normalizeTerminalTimestampFontSize(fontSize),
fontWeight: fontWeight ?? 400,
});
const estimateTerminalTimestampTextWidth = (
fontSize: number,
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
): number => (
normalizeTerminalTimestampFontSize(fontSize) * label.length * 0.62
);
export const resolveTerminalTimestampGutterWidth = ({
measuredTextWidth,
fontSize,
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
}: {
measuredTextWidth?: number;
fontSize?: number;
label?: string;
}): number => {
const textWidth =
Number.isFinite(measuredTextWidth) && measuredTextWidth !== undefined && measuredTextWidth > 0
? measuredTextWidth
: estimateTerminalTimestampTextWidth(normalizeTerminalTimestampFontSize(fontSize), label);
return Math.ceil(Math.max(
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
textWidth + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
));
};
export const resolveTerminalTimestampGutterRenderSignature = ({
screenTop,
cellHeight,
color,
fontFamily,
fontSize,
fontWeight,
rows,
}: {
screenTop: number;
cellHeight: number;
color: string;
fontFamily: string;
fontSize: number;
fontWeight: string | number;
rows: readonly TerminalTimestampGutterRow[];
}): string => {
let signature = `${screenTop}|${cellHeight}|${color}|${fontFamily}|${fontSize}|${fontWeight}`;
for (const { row, label } of rows) {
signature += `|${row}:${label}`;
}
return signature;
};
export function TerminalTimestampGutter({
termRef,
containerRef,
enabled,
top,
left = 0,
bottom = 0,
sessionId,
color,
fontFamily,
fontSize,
fontWeight,
width,
onWidthChange,
}: TerminalTimestampGutterProps) {
const gutterRef = useRef<HTMLDivElement>(null);
const typography = getTerminalTimestampTypography({ fontFamily, fontSize, fontWeight });
useLayoutEffect(() => {
if (!enabled || !onWidthChange) return;
const gutter = gutterRef.current;
if (!gutter) return;
let disposed = false;
const measure = () => {
if (disposed) return;
const probe = document.createElement("span");
probe.textContent = TERMINAL_TIMESTAMP_SAMPLE_LABEL;
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.pointerEvents = "none";
probe.style.whiteSpace = "nowrap";
probe.style.fontFamily = typography.fontFamily;
probe.style.fontSize = `${typography.fontSize}px`;
probe.style.fontWeight = String(typography.fontWeight);
probe.style.fontVariantNumeric = "tabular-nums";
gutter.appendChild(probe);
const measuredTextWidth = probe.getBoundingClientRect().width;
probe.remove();
onWidthChange(resolveTerminalTimestampGutterWidth({
measuredTextWidth,
fontSize: typography.fontSize,
}));
};
measure();
const fonts = (document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
void fonts?.ready?.then(measure);
return () => {
disposed = true;
};
}, [enabled, onWidthChange, sessionId, typography.fontFamily, typography.fontSize, typography.fontWeight]);
useEffect(() => {
const gutter = gutterRef.current;
if (!gutter) return;
let disposed = false;
let rafId: number | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let floodThrottleTimer: ReturnType<typeof setTimeout> | null = null;
let disposables: DisposableLike[] = [];
let resizeObserver: ResizeObserver | null = null;
let lastRenderSignature = "";
let lastFloodRenderAt = 0;
const clearGutter = () => {
lastRenderSignature = "";
clearElement(gutter);
};
const render = () => {
rafId = null;
// Always advance the flood clock when a render attempt runs — even if the
// signature is unchanged. Otherwise same-second labels during sustained
// output leave lastFloodRenderAt stale and the throttle stops limiting work.
lastFloodRenderAt = performance.now();
const term = termRef.current;
const container = containerRef.current;
if (!enabled || !term || !container) {
clearGutter();
return;
}
const screen = getTerminalScreen(container);
const rows = Math.max(1, term.rows || 1);
const cellHeight = screen.clientHeight / rows;
if (!Number.isFinite(cellHeight) || cellHeight <= 0) {
clearGutter();
return;
}
const screenRect = screen.getBoundingClientRect();
const gutterRect = gutter.getBoundingClientRect();
const screenTop = screenRect.top - gutterRect.top;
const visibleRows = getVisibleTerminalLineTimestampRows(term);
const signature = resolveTerminalTimestampGutterRenderSignature({
screenTop,
cellHeight,
color,
fontFamily: typography.fontFamily,
fontSize: typography.fontSize,
fontWeight: typography.fontWeight,
rows: visibleRows,
});
if (signature === lastRenderSignature) return;
lastRenderSignature = signature;
syncTerminalTimestampGutterRows(gutter, visibleRows, {
screenTop,
cellHeight,
color,
fontFamily: typography.fontFamily,
fontSize: typography.fontSize,
fontWeight: typography.fontWeight,
});
};
const queueRafRender = () => {
if (disposed || rafId !== null) return;
if (typeof requestAnimationFrame === "function") {
rafId = requestAnimationFrame(render);
} else {
render();
}
};
/**
* immediate: user scroll/resize — paint ASAP.
* normal: output/render pressure — throttle while flood pressure is active.
*/
const scheduleRender = (priority: "immediate" | "normal" = "normal") => {
if (disposed) return;
if (priority === "normal") {
const term = termRef.current;
if (term) {
const pressure = getTerminalOutputPressure(term);
if (pressure.largeOutput || pressure.longLine) {
const now = performance.now();
const elapsed = now - lastFloodRenderAt;
if (elapsed < TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS) {
if (floodThrottleTimer === null) {
floodThrottleTimer = setTimeout(() => {
floodThrottleTimer = null;
queueRafRender();
}, TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS - elapsed);
}
return;
}
}
}
} else if (floodThrottleTimer !== null) {
clearTimeout(floodThrottleTimer);
floodThrottleTimer = null;
}
queueRafRender();
};
const attach = () => {
if (disposed) return;
const term = termRef.current;
const container = containerRef.current;
if (!enabled || !term || !container) {
clearGutter();
if (enabled) {
retryTimer = setTimeout(attach, 50);
}
return;
}
disposables = [
// xterm fires onScroll for output-driven scrolling too (not only user
// wheel/trackpad). Throttle only while large-output pressure is active
// (time-bounded). Do not use longLine here — it sticks until the next
// data chunk and would lag genuine user scrolling after output stops.
term.onScroll?.(() => {
const pressure = getTerminalOutputPressure(term);
scheduleRender(pressure.largeOutput ? "normal" : "immediate");
}),
term.onRender?.(() => scheduleRender("normal")),
term.onResize?.(() => scheduleRender("immediate")),
].filter(Boolean) as DisposableLike[];
disposables.push({
dispose: onTerminalLineTimestampsChange(term, () => scheduleRender("normal")),
});
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => scheduleRender("immediate"));
resizeObserver.observe(container);
resizeObserver.observe(getTerminalScreen(container));
}
scheduleRender("immediate");
};
attach();
return () => {
disposed = true;
if (rafId !== null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(rafId);
}
if (retryTimer) {
clearTimeout(retryTimer);
}
if (floodThrottleTimer !== null) {
clearTimeout(floodThrottleTimer);
}
for (const disposable of disposables) {
disposable.dispose();
}
resizeObserver?.disconnect();
clearElement(gutter);
};
}, [
color,
containerRef,
enabled,
bottom,
left,
sessionId,
termRef,
top,
typography.fontFamily,
typography.fontSize,
typography.fontWeight,
]);
if (!enabled) return null;
return (
<div
ref={gutterRef}
aria-hidden="true"
className="pointer-events-none absolute z-[1] overflow-hidden select-none text-[color:var(--terminal-ui-fg)]"
style={{
top,
bottom,
left,
width,
backgroundColor: "var(--terminal-ui-bg)",
boxShadow: "inset -0.5px 0 0 color-mix(in srgb, var(--terminal-ui-fg) 8%, transparent)",
}}
data-section="terminal-timestamp-gutter"
/>
);
}

View File

@@ -0,0 +1,194 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
const sshHost: Host = {
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
};
const serialHost: Host = {
...sshHost,
id: "serial-1",
label: "Serial",
hostname: "/dev/tty.usbserial",
protocol: "serial",
};
const pluginHost: Host = {
...sshHost,
id: "plugin-1",
label: "Plugin",
hostname: "com.example.transport.connection",
protocol: "plugin:com.example.transport.connection",
};
const renderToolbar = (
host: Host,
status: "connecting" | "connected" | "disconnected" = "connected",
props: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(TerminalToolbar, {
sessionId: 'session-1',
status,
host,
onOpenSFTP: () => {},
onOpenScripts: () => {},
onOpenTheme: () => {},
...props,
}),
),
);
test("keeps SFTP visible before the terminal overflow menu for SSH sessions", () => {
const markup = renderToolbar(sshHost);
const sftpIndex = markup.indexOf('aria-label="Open SFTP"');
const moreIndex = markup.indexOf('aria-label="More actions"');
assert.notEqual(sftpIndex, -1);
assert.notEqual(moreIndex, -1);
assert.ok(sftpIndex < moreIndex);
});
test("keeps Scripts visible before the terminal overflow menu", () => {
const markup = renderToolbar(sshHost);
const scriptsIndex = markup.indexOf('aria-label="Scripts"');
const moreIndex = markup.indexOf('aria-label="More actions"');
assert.notEqual(scriptsIndex, -1);
assert.notEqual(moreIndex, -1);
assert.ok(scriptsIndex < moreIndex);
assert.equal(markup.match(/Scripts/g)?.length, 1);
assert.match(markup, /type="button"[^>]*aria-label="Scripts"/);
});
test("shows manual session log button when requested", () => {
const markup = renderToolbar(sshHost, "connected", {
showLogButton: true,
onToggleSessionLog: () => {},
});
const logIndex = markup.indexOf('aria-label="Start session log"');
const scriptsIndex = markup.indexOf('aria-label="Scripts"');
assert.notEqual(logIndex, -1);
assert.notEqual(scriptsIndex, -1);
assert.ok(logIndex < scriptsIndex);
});
test("marks manual session log button active while logging", () => {
const markup = renderToolbar(sshHost, "connected", {
showLogButton: true,
onToggleSessionLog: () => {},
isSessionLogging: true,
});
assert.match(
markup,
/aria-label="Stop session log"[^>]*aria-pressed="true"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
);
});
test("hides SFTP for local terminal sessions", () => {
const markup = renderToolbar({
...sshHost,
id: "local-1",
protocol: "local",
});
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
});
test("hides SSH history for plugin terminal sessions", () => {
const markup = renderToolbar(pluginHost, "connected", {
onOpenHistory: () => {},
});
assert.equal(markup.includes('aria-label="Command history"'), false);
});
test("shows YMODEM send only for connected serial sessions", () => {
const connectedSerial = renderToolbar(serialHost, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const disconnectedSerial = renderToolbar(serialHost, "disconnected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const ssh = renderToolbar(sshHost, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const local = renderToolbar({
...sshHost,
id: "local-1",
protocol: "local",
}, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
assert.equal(connectedSerial.includes('aria-label="Send with YMODEM"'), true);
assert.equal(connectedSerial.includes('aria-label="Receive with YMODEM"'), true);
assert.doesNotMatch(connectedSerial, /aria-label="Send with YMODEM"[^>]*disabled/);
assert.equal(disconnectedSerial.includes('aria-label="Send with YMODEM - Available after connect"'), true);
assert.equal(disconnectedSerial.includes('aria-label="Receive with YMODEM - Available after connect"'), true);
assert.match(disconnectedSerial, /aria-label="Send with YMODEM - Available after connect"[^>]*disabled/);
assert.match(disconnectedSerial, /aria-label="Receive with YMODEM - Available after connect"[^>]*disabled/);
assert.equal(ssh.includes('aria-label="Send with YMODEM"'), false);
assert.equal(ssh.includes('aria-label="Receive with YMODEM"'), false);
assert.equal(local.includes('aria-label="Send with YMODEM"'), false);
assert.equal(local.includes('aria-label="Receive with YMODEM"'), false);
});
test("uses the terminal active button color for pressed toolbar actions", () => {
const markup = renderToolbar(sshHost, "connected", {
isSearchOpen: true,
onToggleSearch: () => {},
});
assert.match(
markup,
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
);
});
test("compact scripts popover hosts bulk-delete confirm outside the popover", () => {
// Dialog focus leaves PopoverContent; if confirm stays inside ScriptsSidePanel,
// the popover closes, isVisible clears pendingDeleteIds, and the prompt dies.
assert.match(toolbarSource, /onBulkDeleteRequest=\{setPendingScriptDeleteIds\}/);
assert.match(toolbarSource, /<VaultDeleteConfirmDialog/);
// Popup vault mutation goes through the prop; the event only clears popover selection.
assert.match(toolbarSource, /onDeleteSnippets\?\.\(new Set\(ids\)\)/);
assert.match(toolbarSource, /netcatty:snippets:delete/);
const scriptsPopoverIdx = toolbarSource.indexOf("open={scriptsPopoverOpen}");
const popoverEndIdx = toolbarSource.indexOf("</Popover>", scriptsPopoverIdx);
const dialogIdx = toolbarSource.indexOf("<VaultDeleteConfirmDialog", popoverEndIdx);
assert.ok(scriptsPopoverIdx >= 0, "scripts popover open binding");
assert.ok(popoverEndIdx > scriptsPopoverIdx, "scripts popover closes");
assert.ok(dialogIdx > popoverEndIdx, "confirm dialog is a sibling after the popover");
assert.equal(
toolbarSource.includes("document.querySelector('[data-vault-delete-confirm=\"true\"]')"),
false,
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("hidden terminal tabs stop server stats polling", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
assert.match(
source,
/<TerminalServerStats[\s\S]*?enabled=\{\(terminalSettings\?\.showServerStats \?\? true\) && isVisible\}/,
);
assert.doesNotMatch(source, /shouldKeepTerminalBackgroundWorkActive/);
});

View File

@@ -0,0 +1,733 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
formatTerminalHostInfoBarTitle,
formatTerminalHostInfoBarTooltip,
formatTerminalTitleConnectionAddress,
focusTerminalFromDisconnectedNotice,
getLineTimestampToggleHostUpdate,
resolveNetworkDeviceTipRightInset,
resolveTerminalRightInset,
resolveTerminalTopOffsets,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
shouldReconnectTerminalOnEnterKey,
shouldShowSelectionAIOverlay,
shouldShowLineTimestampToolbarToggle,
shouldShowStatusBarConnectionControls,
TerminalDisconnectedNotice,
resolveTerminalDisconnectedNoticeMessage,
} from "./TerminalView.tsx";
test("terminal disconnected notice keeps the reason and reconnect hint on one compact row", () => {
const markup = renderToStaticMarkup(React.createElement(TerminalDisconnectedNotice, {
message: "Connection timed out.",
reconnectHint: "Press Enter to reconnect",
bottom: 4,
left: 4,
right: 4,
}));
assert.match(markup, /data-terminal-disconnected-notice="true"/);
assert.match(markup, /role="status"/);
assert.match(markup, /Connection timed out\./);
assert.match(markup, /Press Enter to reconnect/);
assert.match(markup, /h-7/);
assert.doesNotMatch(markup, /pointer-events-none/);
});
test("clicking the disconnected notice preserves terminal keyboard focus", () => {
let prevented = false;
let focused = false;
focusTerminalFromDisconnectedNotice(
{ preventDefault: () => { prevented = true; } },
() => { focused = true; },
);
assert.equal(prevented, true);
assert.equal(focused, true);
});
test("automatic reconnect notice uses explicit lifecycle copy instead of a stale log line", () => {
assert.equal(
resolveTerminalDisconnectedNoticeMessage({
status: "connecting",
error: null,
reconnectMessage: "Auto reconnect attempt 2...",
disconnectedLabel: "Disconnected",
}),
"Auto reconnect attempt 2...",
);
assert.equal(
resolveTerminalDisconnectedNoticeMessage({
status: "disconnected",
error: "Connection timed out.",
reconnectMessage: "Waiting for host key confirmation",
disconnectedLabel: "Disconnected",
}),
"Connection timed out.",
);
assert.equal(
resolveTerminalDisconnectedNoticeMessage({
status: "disconnected",
error: "Connection timed out.",
reconnectMessage: "Reconnecting...",
disconnectedLabel: "Disconnected",
isReconnectActive: true,
}),
"Reconnecting...",
);
});
test("automatic reconnect switches to attempt copy before waking a hibernated terminal", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const startReconnect = source.indexOf('const startReconnect = async');
const updateNotice = source.indexOf('setReconnectNoticeMessage(reconnectAttemptMessage)', startReconnect);
const wakeHibernated = source.indexOf('if (!termRef.current && hibernatedRef.current)', startReconnect);
assert.ok(startReconnect >= 0);
assert.ok(updateNotice > startReconnect);
assert.ok(wakeHibernated > updateNotice);
});
test("hibernated manual reconnect can continue after its runtime wakes", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const startReconnect = source.indexOf("const startReconnect = async");
const markManualReconnect = source.indexOf(
'setManualReconnectActive(mode === "manual")',
startReconnect,
);
const initialGuard = source.slice(startReconnect, markManualReconnect);
const clearWakeInFlight = source.indexOf(
"reconnectWakeInFlightRef.current = false",
markManualReconnect,
);
const continueReconnect = source.indexOf("startReconnectRef.current?.(mode)", clearWakeInFlight);
assert.ok(startReconnect >= 0);
assert.ok(markManualReconnect > startReconnect);
assert.match(initialGuard, /reconnectPreparationTokenRef\.current !== null/);
assert.match(initialGuard, /reconnectWakeInFlightRef\.current/);
assert.doesNotMatch(initialGuard, /manualReconnectActive/);
assert.ok(clearWakeInFlight > markManualReconnect);
assert.ok(continueReconnect > clearWakeInFlight);
});
test("manual reconnect publishes preparation without starting the connection timeout", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const startReconnect = source.indexOf("const startReconnect = async");
const publishPreparation = source.indexOf(
"terminalReconnectRegistry.setActive(sessionId, true)",
startReconnect,
);
const cleanupSession = source.indexOf(
"await cleanupSession({ retainOwnership: true })",
startReconnect,
);
const restoreDisconnected = source.indexOf(
'updateStatus("disconnected")',
startReconnect,
);
assert.ok(startReconnect >= 0);
assert.ok(publishPreparation > startReconnect);
assert.ok(cleanupSession > publishPreparation);
assert.equal(
source.indexOf('if (mode === "manual") updateStatus("connecting")', startReconnect),
-1,
);
assert.ok(restoreDisconnected > startReconnect);
assert.ok(restoreDisconnected < cleanupSession);
});
test("programmatic input during hibernated reconnect does not clear reconnect presentation", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const scrollStart = source.indexOf("const scrollToBottomAfterProgrammaticInput");
const scrollEnd = source.indexOf("useEffect(() =>", scrollStart);
const scrollBody = source.slice(scrollStart, scrollEnd);
assert.notEqual(scrollStart, -1);
assert.notEqual(scrollEnd, -1);
assert.doesNotMatch(scrollBody, /manualReconnectActiveRef|setReconnectNoticeMessage/);
});
test("line timestamp toggle creates a persistent host update", () => {
const host = {
id: "host-1",
label: "Host",
showLineTimestamps: false,
theme: "default",
};
assert.deepEqual(getLineTimestampToggleHostUpdate(host), {
id: "host-1",
showLineTimestamps: true,
});
assert.deepEqual(getLineTimestampToggleHostUpdate({ ...host, showLineTimestamps: true }), {
id: "host-1",
showLineTimestamps: false,
});
});
test("line timestamp toolbar toggle is hidden when timestamps are unavailable", () => {
assert.equal(shouldShowLineTimestampToolbarToggle(false, () => {}), false);
assert.equal(shouldShowLineTimestampToolbarToggle(true, () => {}), true);
assert.equal(shouldShowLineTimestampToolbarToggle(undefined, () => {}), true);
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("selection AI overlay honors the visibility preference", () => {
const overlayPosition = { left: 120, top: 80 };
const addSelection = () => {};
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
}),
true,
);
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
showSelectionAIAction: true,
}),
true,
);
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
showSelectionAIAction: false,
}),
false,
);
});
test("disconnected terminal reconnects on plain Enter when input is not claimed elsewhere", () => {
assert.equal(
shouldReconnectTerminalOnEnterKey({
key: "Enter",
status: "disconnected",
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
}),
true,
);
});
test("terminal enter reconnect ignores active controls and non-disconnected states", () => {
const base = {
key: "Enter",
status: "disconnected" as const,
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
};
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
// Open search must not globally suppress Enter reconnect / the hint (#2546).
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
});
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
assert.equal(
shouldBlockTerminalReconnectForTarget({
isWithinXterm: false,
hasInteractiveAncestor: true,
}),
true,
);
assert.equal(
shouldBlockTerminalReconnectForTarget({
isWithinXterm: true,
hasInteractiveAncestor: true,
}),
false,
);
assert.equal(
shouldBlockTerminalReconnectForTarget({
isWithinXterm: false,
hasInteractiveAncestor: false,
}),
false,
);
// An open terminal search input is interactive, but disconnected Enter must
// still reconnect rather than find-next (#2546).
assert.equal(
shouldBlockTerminalReconnectForTarget({
isWithinXterm: false,
hasInteractiveAncestor: true,
isTerminalSearchInput: true,
}),
false,
);
});
test("terminal title formats the connection address for remote sessions", () => {
assert.equal(
formatTerminalTitleConnectionAddress({
protocol: "ssh",
username: "root",
hostname: "10.1.2.34",
port: 2222,
}),
"root@10.1.2.34:2222",
);
assert.equal(formatTerminalTitleConnectionAddress({ protocol: "local", hostname: "localhost" }), null);
assert.equal(formatTerminalTitleConnectionAddress({
protocol: "plugin:com.example.transport.connection",
hostname: "com.example.transport.connection",
port: 22,
}), null);
});
test("host info bar title follows address or label mode", () => {
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "prod-web",
connectionAddress: "root@10.1.2.34:2222",
mode: "address",
}),
"root@10.1.2.34:2222",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "prod-web",
connectionAddress: "root@10.1.2.34:2222",
mode: "label",
}),
"prod-web",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: " prod-web ",
connectionAddress: " root@10.1.2.34:2222 ",
mode: "label",
}),
"prod-web",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "",
connectionAddress: "root@10.1.2.34:2222",
mode: "label",
}),
"root@10.1.2.34:2222",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "Local Terminal",
connectionAddress: null,
mode: "address",
}),
"Local Terminal",
);
assert.equal(
formatTerminalHostInfoBarTooltip({
serverName: "prod-web",
connectionAddress: "root@10.1.2.34:2222",
}),
"prod-web · root@10.1.2.34:2222",
);
});
test("terminal title row does not render a status dot beside the address", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const titleStart = source.indexOf("data-terminal-detach-drag-handle");
const titleEnd = source.indexOf("shouldShowLineTimestampToolbarToggle", titleStart);
assert.notEqual(titleStart, -1);
assert.notEqual(titleEnd, -1);
assert.doesNotMatch(source.slice(titleStart, titleEnd), /statusDotTone/);
});
test("terminal title keeps the copy host action beside the address", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const titleStart = source.indexOf("data-terminal-detach-drag-handle");
const copyAction = source.indexOf('aria-label={t("terminal.statusbar.copyHostname.label")}', titleStart);
const timestampToggle = source.indexOf("shouldShowLineTimestampToolbarToggle", titleStart);
assert.notEqual(titleStart, -1);
assert.notEqual(copyAction, -1);
assert.notEqual(timestampToggle, -1);
assert.ok(copyAction < timestampToggle);
});
test("focus mode and temporary pane magnification use separate toolbar actions", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const focusAction = source.indexOf("onClick={onExpandToFocus}");
const magnifyAction = source.indexOf("onClick={onTogglePaneMagnification}");
assert.notEqual(focusAction, -1);
assert.notEqual(magnifyAction, -1);
assert.ok(focusAction < magnifyAction);
assert.match(source.slice(focusAction, magnifyAction), /terminal\.toolbar\.focusMode/);
assert.match(source.slice(magnifyAction), /terminal\.paneMagnification\.(restore|magnify)/);
});
test("popup terminals disable line timestamp controls", () => {
const source = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
assert.match(source, /lineTimestampsAvailable=\{false\}/);
});
test("terminal body keeps a slight inset from the surrounding chrome", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
assert.match(source, /const terminalBodyInset = 4/);
assert.match(source, /left: activeLineTimestampGutterWidth \+ terminalBodyInset/);
assert.match(source, /right: terminalRightInset/);
assert.match(source, /const terminalBottomInset = terminalBodyInset \+ \(showDisconnectedTerminalNotice \? 28 : 0\)/);
assert.match(source, /bottom: terminalBottomInset/);
assert.match(source, /left=\{terminalBodyInset\}/);
assert.match(source, /bottom=\{terminalBottomInset\}/);
});
test("hidden host information bar gives its vertical space back to the terminal", () => {
assert.deepEqual(
resolveTerminalTopOffsets({ showHostInfoBar: false, isSearchOpen: false }),
{ toolbarOffset: 0, contentTop: "4px" },
);
assert.deepEqual(
resolveTerminalTopOffsets({ showHostInfoBar: true, isSearchOpen: false }),
{ toolbarOffset: 30, contentTop: "34px" },
);
});
test("terminal search keeps enough space when host information is hidden", () => {
assert.deepEqual(
resolveTerminalTopOffsets({ showHostInfoBar: false, isSearchOpen: true }),
{ toolbarOffset: 64, contentTop: "68px" },
);
});
test("network device tip reserves extra top space below the toolbar", () => {
// Tip stacks below the toolbar: content shifts down by the tip height, but
// the toolbar offset itself is unchanged.
assert.deepEqual(
resolveTerminalTopOffsets({ showHostInfoBar: true, isSearchOpen: false, networkDeviceTipHeight: 28 }),
{ toolbarOffset: 30, contentTop: "62px" },
);
assert.deepEqual(
resolveTerminalTopOffsets({ showHostInfoBar: false, isSearchOpen: false, networkDeviceTipHeight: 28 }),
{ toolbarOffset: 0, contentTop: "32px" },
);
});
test("network device tip clears the compact speed-dial toggle only when it is present", () => {
// Speed dial only renders when host info is hidden and search is closed;
// reserve right-side room there so the tip cannot cover its click target.
assert.equal(resolveNetworkDeviceTipRightInset({ showHostInfoBar: false, isSearchOpen: false }), 40);
assert.equal(resolveNetworkDeviceTipRightInset({ showHostInfoBar: true, isSearchOpen: false }), 0);
assert.equal(resolveNetworkDeviceTipRightInset({ showHostInfoBar: false, isSearchOpen: true }), 0);
});
test("hidden host information does not reserve a side gutter for its floating action button", () => {
// Speed-dial overlays the terminal; scrollbar stays at the pane edge.
assert.equal(resolveTerminalRightInset({ showHostInfoBar: false, isSearchOpen: false }), 4);
assert.equal(resolveTerminalRightInset({ showHostInfoBar: true, isSearchOpen: false }), 4);
assert.equal(resolveTerminalRightInset({ showHostInfoBar: false, isSearchOpen: true }), 4);
});
test("hidden host information keeps terminal actions rendered", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const hostInfoStart = source.indexOf("{showHostInfoBar && <div");
const hostInfoEnd = source.indexOf("</div>}", hostInfoStart);
const copyAction = source.indexOf('aria-label={t("terminal.statusbar.copyHostname.label")}');
const timestampAction = source.indexOf("shouldShowLineTimestampToolbarToggle", copyAction);
const systemAction = source.indexOf('aria-label={t("terminal.layer.system")}', timestampAction);
const disconnectAction = source.indexOf('aria-label={t("terminal.statusbar.disconnect.label")}', systemAction);
const reconnectAction = source.indexOf('aria-label={t("terminal.statusbar.reconnect.label")}', disconnectAction);
const actionsStart = source.indexOf('className="flex items-center gap-0.5 flex-shrink-0"');
const controls = source.indexOf("{renderControls({ showClose: inWorkspace, restorePaneLayout: isPaneMagnified })}");
const compactDragHandle = source.indexOf('data-terminal-detach-drag-handle="true"');
assert.notEqual(hostInfoStart, -1);
assert.notEqual(hostInfoEnd, -1);
assert.notEqual(copyAction, -1);
assert.notEqual(timestampAction, -1);
assert.notEqual(systemAction, -1);
assert.notEqual(disconnectAction, -1);
assert.notEqual(reconnectAction, -1);
assert.notEqual(actionsStart, -1);
assert.notEqual(controls, -1);
assert.notEqual(compactDragHandle, -1);
// Compact drag handle uses GripVertical, not the old radial-dot “chessboard”.
assert.match(source, /GripVertical/);
assert.ok(!source.includes("backgroundSize: '4px 4px'"));
assert.ok(hostInfoStart < hostInfoEnd);
assert.ok(hostInfoEnd < copyAction);
assert.ok(copyAction < timestampAction);
assert.ok(timestampAction < systemAction);
assert.ok(systemAction < disconnectAction);
assert.ok(disconnectAction < reconnectAction);
assert.ok(reconnectAction < actionsStart);
assert.ok(actionsStart < controls);
assert.ok(compactDragHandle < hostInfoStart);
});
test("status bar disconnect stays enabled while connected or connecting", () => {
assert.equal(shouldEnableStatusBarDisconnect("connected"), true);
assert.equal(shouldEnableStatusBarDisconnect("connecting"), true);
assert.equal(shouldEnableStatusBarDisconnect("disconnected"), false);
assert.equal(shouldEnableStatusBarDisconnect(undefined), false);
});
test("status bar reconnect matches tab-menu reconnect gating", () => {
assert.equal(shouldEnableStatusBarReconnect("connected"), true);
assert.equal(shouldEnableStatusBarReconnect("disconnected"), true);
assert.equal(shouldEnableStatusBarReconnect("connecting"), false);
assert.equal(shouldEnableStatusBarReconnect(undefined), false);
});
test("status bar connection controls require an owned session surface", () => {
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: true,
hasDisconnectHandler: true,
hasReconnectHandler: true,
}),
true,
);
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: false,
hasDisconnectHandler: true,
hasReconnectHandler: true,
}),
false,
);
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: true,
hasDisconnectHandler: false,
hasReconnectHandler: false,
}),
false,
);
});
test("manual disconnect keeps the session pane for reconnect", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const disconnectStart = source.indexOf("const handleDisconnect = () => {");
const disconnectEnd = source.indexOf("const handleDismissDisconnectedDialog", disconnectStart);
assert.notEqual(disconnectStart, -1);
assert.notEqual(disconnectEnd, -1);
const body = source.slice(disconnectStart, disconnectEnd);
assert.match(body, /clearAutoReconnect\(\{ stopLoop: true \}\)/);
assert.match(body, /reconnectWakeTokenRef\.current = null/);
assert.match(body, /reconnectWakeInFlightRef\.current = false/);
assert.match(body, /netcatty:terminal-session-disconnected/);
assert.match(body, /invalidateBootEpochForClose\(\)/);
assert.match(body, /isBootActiveRef\.current = false/);
assert.match(body, /setIsCancelling\(true\)/);
assert.match(body, /updateStatus\("disconnected"\)/);
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
assert.match(source, /trackSessionCleanup/);
assert.doesNotMatch(body, /onCloseSession/);
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
assert.match(source, /showConnectionControls: !attachExistingSession && !compactToolbar/);
assert.match(source, /setTerminalBootEpoch/);
const startersSource = readFileSync(
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
"utf8",
);
assert.match(startersSource, /createBootAttemptGuard\(ctx\)/);
assert.match(startersSource, /setTerminalBootEpoch\(ctx\.sessionId, bootEpoch\)/);
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
assert.match(
effectsSource,
/!isBootActiveRef\.current[\s\S]*statusRef\.current === "disconnected"[\s\S]*bootEpochMismatch/,
);
assert.match(
effectsSource,
/respondHostKeyVerification\?\.\(request\.requestId, false\)/,
);
assert.match(effectsSource, /request\.bootEpoch/);
assert.match(
startersSource,
/if \(!isCurrentAttempt\(\)\) return;/,
);
assert.match(startersSource, /bootEpoch,/);
});
test("cancel connect invalidates the boot epoch like disconnect", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const cancelStart = source.indexOf("const handleCancelConnect = () => {");
const cancelEnd = source.indexOf("const handleDisconnect = () => {", cancelStart);
assert.notEqual(cancelStart, -1);
assert.notEqual(cancelEnd, -1);
const body = source.slice(cancelStart, cancelEnd);
assert.match(body, /invalidateBootEpochForClose\(\)/);
assert.match(body, /isBootActiveRef\.current = false/);
// Both must land before cleanupSession so the close targets the pre-bump epoch.
assert.ok(
body.indexOf("invalidateBootEpochForClose()") < body.indexOf("void cleanupSession()"),
"cancel must invalidate the boot epoch before cleanupSession",
);
assert.ok(
body.indexOf("isBootActiveRef.current = false") < body.indexOf("void cleanupSession()"),
"cancel must clear boot-active before cleanupSession",
);
});
test("terminal boot is cancelable and closes eagerly on cleanup", () => {
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const startersSource = readFileSync(
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
"utf8",
);
assert.match(effectsSource, /const bootAbort = new AbortController\(\)/);
assert.match(effectsSource, /const bootStartOptions = \{ signal: bootAbort\.signal \}/);
assert.match(
effectsSource,
/queueMicrotask\(\(\) => \{\s*\n\s*if \(disposed\) return;\s*\n\s*void boot\(\);/,
"backend boot must defer past StrictMode's synchronous re-invoke",
);
for (const starter of [
"startPluginConnection",
"startSerial",
"startLocal",
"startTelnet",
"startMosh",
"startEt",
"startSSH",
]) {
assert.match(
effectsSource,
new RegExp(`sessionStarters\\.${starter}\\(term, bootStartOptions\\)`),
`${starter} must receive the boot abort signal`,
);
}
// Cleanup order: abort, then eager close + sync dispose for never-connected
// boots (StrictMode remount), else the async capture/teardown path.
const cleanupStart = effectsSource.indexOf(" disposed = true;");
assert.notEqual(cleanupStart, -1);
const cleanup = effectsSource.slice(cleanupStart);
const abortAt = cleanup.indexOf("bootAbort.abort()");
const neverConnectedAt = cleanup.indexOf("if (!hasConnectedRef.current)");
assert.ok(abortAt !== -1 && neverConnectedAt !== -1);
assert.ok(abortAt < neverConnectedAt, "cleanup must abort before the never-connected close branch");
const neverConnectedBranch = cleanup.slice(
neverConnectedAt,
cleanup.indexOf("const persistCloseCapture", neverConnectedAt),
);
const closeAt = neverConnectedBranch.indexOf("terminalBackend.closeSession(");
const syncDisposeAt = neverConnectedBranch.indexOf("disposeOwnedRuntime();");
const earlyReturnAt = neverConnectedBranch.indexOf("return;");
assert.ok(closeAt !== -1 && syncDisposeAt !== -1 && earlyReturnAt !== -1);
assert.ok(closeAt < syncDisposeAt, "eager close must run before sync runtime dispose");
assert.ok(
syncDisposeAt < earlyReturnAt,
"never-connected boots must sync-dispose before leaving cleanup",
);
// Owner panes close the pending backend; attach popups only dispose xterm.
assert.match(neverConnectedBranch, /if \(!attachExistingSession\)/);
assert.match(effectsSource, /let ownedRuntime:/);
assert.match(
cleanup,
/void completeClose\(\)/,
"connected boots still use the async capture path",
);
// An aborted boot must stop counting as the current attempt so the existing
// orphan-close / attach-refusal guards cover cancellation too.
assert.match(
startersSource,
/options\?\.signal\?\.aborted !== true && isBootEpochCurrent\(\)/,
);
for (const starter of [
"startSSH",
"startTelnet",
"startMosh",
"startEt",
"startPluginConnection",
"startLocal",
"startSerial",
]) {
assert.match(
startersSource,
new RegExp(
`const ${starter} = async \\(term: XTerm, options\\?: TerminalSessionStartOptions\\)`,
),
`${starter} must accept an abort signal`,
);
}
// The plugin path holds its own in-flight request controller; the boot abort
// has to reach it or the extension request outlives the pane.
assert.match(startersSource, /options\?\.signal\?\.addEventListener\("abort", onBootAborted/);
assert.match(startersSource, /options\?\.signal\?\.removeEventListener\("abort", onBootAborted\)/);
});
test("hidden host information reveals actions without permanently covering terminal content", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
assert.match(source, /aria-label=\{t\("terminal\.toolbar\.showActions"\)\}/);
assert.match(source, /aria-expanded=\{compactActionsOpen\}/);
assert.match(source, /aria-controls=\{`terminal-actions-\$\{sessionId\}`\}/);
assert.match(source, /id=\{`terminal-actions-\$\{sessionId\}`\}/);
assert.match(source, /onClick=\{\(\) => setCompactActionsOpen/);
assert.match(source, /right: terminalRightInset/);
// Compact mode is a circular speed-dial: tray springs left via 0fr→1fr grid
// (must not use .terminal-topbar — container-type collapses content width).
assert.match(source, /flex flex-row-reverse items-center/);
assert.match(source, /rounded-full/);
assert.match(source, /grid-cols-\[1fr\]/);
assert.match(source, /grid-cols-\[0fr\]/);
assert.match(source, /ChevronsLeft/);
assert.match(source, /h-7/);
assert.match(source, /Do NOT use `\.terminal-topbar`|container-type:inline-size|container-type collapses/);
assert.match(source, /document\.addEventListener\("pointerdown", handlePointerDown\)/);
assert.match(source, /closest\('\[data-radix-popper-content-wrapper\]'\)/);
assert.match(source, /event\.key !== "Escape"/);
assert.match(source, /compactActionsButtonRef\.current\?\.focus\(\)/);
});
test("compact action toggle preserves terminal focus like the visible toolbar", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const overlayStart = source.indexOf('ref={compactActionsRef}');
const toggleStart = source.indexOf('ref={compactActionsButtonRef}', overlayStart);
assert.notEqual(overlayStart, -1);
assert.notEqual(toggleStart, -1);
assert.ok(overlayStart < toggleStart);
assert.match(source.slice(overlayStart, toggleStart), /onMouseDownCapture=\{handleTopOverlayMouseDownCapture\}/);
});
test("terminal theme updates force xterm renderer to repaint immediately", () => {
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const schedulerSource = readFileSync(new URL("./terminalThemeScheduler.ts", import.meta.url), "utf8");
assert.match(source, /applyTerminalThemeSync\(term, effectiveTheme\)/);
assert.match(schedulerSource, /term\.options\.theme = \{/);
assert.match(schedulerSource, /forceSyncRenderAfterResize\(term\)/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,814 @@
/**
* Terminal Theme Customize Modal
* Left-right split design: list on left, large preview on right
* Uses React Portal to render at document root for proper z-index
*
* Features:
* - Real-time preview: changes are applied immediately to the terminal
* - Save: persists the current settings
* - Cancel: reverts to the original settings when modal was opened
* - Custom themes: create, edit, delete, import .itermcolors
*/
import React, { useEffect, useMemo, useState, useCallback, useRef, memo } from 'react';
import { createPortal } from 'react-dom';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
import { CustomThemeModal } from './CustomThemeModal';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models';
type TabType = 'theme' | 'font' | 'custom';
// Memoized theme item component to prevent unnecessary re-renders
const ThemeItem = memo(({
theme,
isSelected,
onSelect,
onEdit,
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
onEdit?: (id: string) => void;
}) => (
<div
role="button"
tabIndex={0}
onClick={() => onSelect(theme.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all group cursor-pointer',
isSelected
? 'bg-primary/15 ring-1 ring-primary'
: 'hover:bg-muted'
)}
>
{/* Color swatch */}
<div
className="w-8 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-5 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-2 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-xs font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">
{theme.type}
{theme.isCustom && ' • custom'}
</div>
</div>
{onEdit && (
<div
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
className="w-6 h-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
>
<Pencil size={11} />
</div>
)}
{isSelected && !onEdit && (
<Check size={14} className="text-primary flex-shrink-0" />
)}
</div>
));
ThemeItem.displayName = 'ThemeItem';
// Memoized font item component
const FontItem = memo(({
font,
isSelected,
onSelect
}: {
font: TerminalFont;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(font.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
isSelected
? 'bg-primary/15 ring-1 ring-primary'
: 'hover:bg-muted'
)}
>
<div className="flex-1 min-w-0">
<div
className={cn('text-sm truncate', isSelected ? 'text-primary' : 'text-foreground')}
style={{ fontFamily: font.family }}
>
{font.name}
</div>
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
</div>
{isSelected && (
<Check size={14} className="text-primary flex-shrink-0" />
)}
</button>
));
FontItem.displayName = 'FontItem';
interface ThemeCustomizeModalProps {
open: boolean;
onClose: () => void;
currentThemeId?: string;
displayThemeId?: string;
currentFontFamilyId?: string;
currentFontSize?: number;
/** Called immediately when user selects a theme (for real-time preview) */
onThemeChange?: (themeId: string) => void;
/** Called when the theme should return to inherited/default state */
onThemeReset?: () => void;
/** Called immediately when user selects a font (for real-time preview) */
onFontFamilyChange?: (fontFamilyId: string) => void;
/** Called immediately when user changes font size (for real-time preview) */
onFontSizeChange?: (fontSize: number) => void;
/** Called when user clicks Save to persist settings */
onSave?: () => void;
/** Optional live preview callback for consumers that render outside this modal */
onPreviewThemeChange?: (theme: TerminalTheme | null) => void;
}
// Memoized preview component to avoid re-rendering on every state change
const TerminalPreview = memo(({
theme,
font,
fontSize
}: {
theme: TerminalThemeConfig;
font: TerminalFont;
fontSize: number;
}) => (
<div
className="flex-1 rounded-xl overflow-hidden border border-border flex flex-col"
style={{ backgroundColor: theme.colors.background }}
>
{/* Fake title bar */}
<div
className="flex items-center gap-2 px-3 py-2 border-b shrink-0"
style={{
backgroundColor: theme.colors.background,
borderColor: `${theme.colors.foreground}15`
}}
>
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500/80" />
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
<div className="w-3 h-3 rounded-full bg-green-500/80" />
</div>
<div
className="flex-1 text-center text-xs"
style={{ color: theme.colors.foreground, opacity: 0.5, fontFamily: font.family }}
>
user@server bash
</div>
</div>
{/* Terminal content */}
<div
className="flex-1 p-4 font-mono overflow-auto"
style={{
color: theme.colors.foreground,
fontFamily: font.family,
fontSize: `${fontSize}px`,
lineHeight: 1.5,
}}
>
<div className="space-y-1">
<div>
<span style={{ color: theme.colors.green }}>user@server</span>
<span style={{ color: theme.colors.foreground }}>:</span>
<span style={{ color: theme.colors.blue }}>~</span>
<span style={{ color: theme.colors.foreground }}>$ </span>
<span>neofetch</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{' _,met$$$$$gg. '}
</div>
<div style={{ color: theme.colors.cyan }}>
{' ,g$$$$$$$$$$$$$$$P. '}
<span style={{ color: theme.colors.foreground }}>user</span>
<span style={{ color: theme.colors.yellow }}>@</span>
<span style={{ color: theme.colors.foreground }}>server</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{' ,g$$P" """Y$$."". '}
<span style={{ color: theme.colors.foreground }}>-----------</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{` ,$$P' $$$. `}
<span style={{ color: theme.colors.blue }}>OS</span>
<span style={{ color: theme.colors.foreground }}>: Ubuntu 22.04 LTS</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{`'', $$P, ggs. $$b: `}
<span style={{ color: theme.colors.blue }}>Kernel</span>
<span style={{ color: theme.colors.foreground }}>: 5.15.0-generic</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{`d$$' ,$P"' . $$$ `}
<span style={{ color: theme.colors.blue }}>Uptime</span>
<span style={{ color: theme.colors.foreground }}>: 42 days, 3 hours</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{` $$P d$' , $$P `}
<span style={{ color: theme.colors.blue }}>Shell</span>
<span style={{ color: theme.colors.foreground }}>: bash 5.1.16</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{` $$: $$. - ,d$$' `}
<span style={{ color: theme.colors.blue }}>Memory</span>
<span style={{ color: theme.colors.foreground }}>: 4.2G / 16G (26%)</span>
</div>
<div>&nbsp;</div>
{/* ANSI color palette preview row */}
<div className="flex gap-0.5 mt-1">
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div className="flex gap-0.5">
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div>&nbsp;</div>
<div>
<span style={{ color: theme.colors.green }}>user@server</span>
<span style={{ color: theme.colors.foreground }}>:</span>
<span style={{ color: theme.colors.blue }}>~</span>
<span style={{ color: theme.colors.foreground }}>$ </span>
<span
style={{
backgroundColor: theme.colors.cursor || theme.colors.foreground,
color: theme.colors.background
}}
></span>
</div>
</div>
</div>
</div>
));
TerminalPreview.displayName = 'TerminalPreview';
const cloneTheme = (theme: TerminalTheme): TerminalTheme => ({
...theme,
colors: { ...theme.colors },
isCustom: true,
});
const serializeTheme = (theme: TerminalTheme): string => JSON.stringify(theme);
export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
open,
onClose,
currentThemeId,
displayThemeId,
currentFontFamilyId = 'menlo',
currentFontSize = DEFAULT_FONT_SIZE,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontSizeChange,
onSave,
onPreviewThemeChange,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const resolvedThemeId = currentThemeId ?? displayThemeId ?? TERMINAL_THEMES[0].id;
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [selectedTheme, setSelectedTheme] = useState(resolvedThemeId);
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
const [fontSize, setFontSize] = useState(currentFontSize);
const [draftCustomThemes, setDraftCustomThemes] = useState<TerminalTheme[]>(() => customThemes.map(cloneTheme));
// Custom theme editor state
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
const [isNewTheme, setIsNewTheme] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Store original values when modal opens (for cancel/revert)
const originalValuesRef = useRef({
theme: currentThemeId,
font: currentFontFamilyId,
fontSize: currentFontSize,
});
const originalCustomThemesRef = useRef<TerminalTheme[]>([]);
const wasOpenRef = useRef(false);
// Combine built-in + custom themes
const allThemes = useMemo(
() => [...TERMINAL_THEMES, ...draftCustomThemes],
[draftCustomThemes]
);
// Sync state when modal opens
useEffect(() => {
if (open && !wasOpenRef.current) {
// Store original values for potential cancel
originalValuesRef.current = {
theme: currentThemeId,
font: currentFontFamilyId,
fontSize: currentFontSize,
};
originalCustomThemesRef.current = customThemes.map((theme) => ({
...cloneTheme(theme),
}));
// Initialize selected values
setSelectedTheme(resolvedThemeId);
setSelectedFont(currentFontFamilyId);
setFontSize(currentFontSize);
setDraftCustomThemes(customThemes.map(cloneTheme));
setEditingTheme(null);
setIsNewTheme(false);
}
wasOpenRef.current = open;
}, [open, currentThemeId, resolvedThemeId, currentFontFamilyId, currentFontSize, customThemes]);
const currentFont = useMemo(
(): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0],
[selectedFont, availableFonts]
);
const currentTheme = useMemo(
() => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
[selectedTheme, allThemes, editingTheme]
);
const hiddenSelectedTheme = useMemo(
() => (isUiMatchTerminalThemeId(selectedTheme)
? TERMINAL_THEMES.find((theme) => theme.id === selectedTheme) || null
: null),
[selectedTheme]
);
useEffect(() => {
onPreviewThemeChange?.(open ? currentTheme : null);
}, [currentTheme, onPreviewThemeChange, open]);
// Handle theme selection - apply immediately for real-time preview
const handleThemeSelect = useCallback((themeId: string) => {
setSelectedTheme(themeId);
setEditingTheme(null);
onThemeChange?.(themeId); // Apply immediately
}, [onThemeChange]);
// Handle font selection - apply immediately for real-time preview
const handleFontSelect = useCallback((fontId: string) => {
setSelectedFont(fontId);
onFontFamilyChange?.(fontId); // Apply immediately
}, [onFontFamilyChange]);
// Handle font size change - apply immediately for real-time preview
const handleFontSizeChange = useCallback((delta: number) => {
setFontSize(prev => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, prev + delta));
onFontSizeChange?.(newSize); // Apply immediately
return newSize;
});
}, [onFontSizeChange]);
// ---- Custom Theme Actions ----
const handleNewTheme = useCallback(() => {
// Clone current theme as starting point
const base = allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0];
const newTheme: TerminalTheme = {
...base,
id: `custom-${Date.now()}`,
name: `${base.name} (Custom)`,
isCustom: true,
colors: { ...base.colors },
};
setEditingTheme(newTheme);
setIsNewTheme(true);
}, [selectedTheme, allThemes]);
const handleImportFile = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
const parsed = parseItermcolors(xml, name);
if (parsed) {
setDraftCustomThemes((prev) => [...prev, cloneTheme(parsed)]);
setSelectedTheme(parsed.id);
onThemeChange?.(parsed.id);
setActiveTab('theme');
} else {
console.error('[ThemeCustomize] Failed to parse .itermcolors file:', file.name);
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.');
}
};
reader.onerror = () => {
console.error('[ThemeCustomize] Failed to read file:', file.name, reader.error);
};
reader.readAsText(file);
// Reset file input so the same file can be re-imported
e.target.value = '';
}, [onThemeChange, t]);
const handleEditTheme = useCallback((themeId: string) => {
const theme = draftCustomThemes.find(t => t.id === themeId);
if (theme) {
setEditingTheme({ ...theme, colors: { ...theme.colors } });
setIsNewTheme(false);
setActiveTab('custom');
}
}, [draftCustomThemes]);
const handleEditorBack = useCallback(() => {
setEditingTheme(null);
setIsNewTheme(false);
}, []);
const handleEditorDelete = useCallback((themeId: string) => {
setDraftCustomThemes((prev) => prev.filter((theme) => theme.id !== themeId));
if (selectedTheme === themeId) {
const originalThemeId = originalValuesRef.current.theme;
const fallbackThemeId = originalThemeId && originalThemeId !== themeId
? originalThemeId
: (displayThemeId && displayThemeId !== themeId ? displayThemeId : USER_VISIBLE_TERMINAL_THEMES[0].id);
setSelectedTheme(fallbackThemeId);
if (originalThemeId == null && displayThemeId && displayThemeId !== themeId) {
onThemeReset?.();
} else {
onThemeChange?.(fallbackThemeId);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}, [displayThemeId, onThemeChange, onThemeReset, selectedTheme]);
// Save: just close (changes are already applied)
const handleSave = useCallback(() => {
const originalThemes = originalCustomThemesRef.current;
const originalMap = new Map(originalThemes.map((theme) => [theme.id, theme]));
const draftMap = new Map(draftCustomThemes.map((theme) => [theme.id, theme]));
for (const [id, originalTheme] of originalMap) {
if (!draftMap.has(id)) {
deleteTheme(id);
continue;
}
const nextTheme = draftMap.get(id)!;
if (serializeTheme(originalTheme) !== serializeTheme(nextTheme)) {
updateTheme(id, nextTheme);
}
}
for (const [id, draftTheme] of draftMap) {
if (!originalMap.has(id)) {
addTheme(draftTheme);
}
}
onSave?.();
onClose();
}, [addTheme, deleteTheme, draftCustomThemes, onClose, onSave, updateTheme]);
// Cancel: revert to original values
const handleCancel = useCallback(() => {
const original = originalValuesRef.current;
// Revert all changes
if (original.theme) {
onThemeChange?.(original.theme);
} else {
onThemeReset?.();
}
onFontFamilyChange?.(original.font);
onFontSizeChange?.(original.fontSize);
onClose();
}, [onThemeChange, onThemeReset, onFontFamilyChange, onFontSizeChange, onClose]);
// Handle ESC key - same as cancel, but skip when child editor is open
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !editingTheme) handleCancel();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, handleCancel, editingTheme]);
// Handle backdrop click - same as cancel
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleCancel();
}, [handleCancel]);
if (!open) return null;
// Separate built-in and custom themes for display in the theme list
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
const modalContent = (
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60"
onClick={handleBackdropClick}
>
<div
className="w-[800px] h-[560px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
<Palette size={16} className="text-primary" />
</div>
<h2 className="text-sm font-semibold text-foreground">{t('terminal.themeModal.title')}</h2>
</div>
<button
onClick={handleCancel}
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X size={16} />
</button>
</div>
{/* Main Content - Left/Right Split */}
<div className="flex-1 flex min-h-0">
{/* Left Panel - List */}
<div className="w-[280px] border-r border-border flex flex-col shrink-0">
{/* Tab Bar */}
<div className="flex p-2 gap-1 shrink-0 border-b border-border">
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
activeTab === 'theme'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Palette size={13} />
{t('terminal.themeModal.tab.theme')}
</button>
<button
onClick={() => setActiveTab('font')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
activeTab === 'font'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Type size={13} />
{t('terminal.themeModal.tab.font')}
</button>
<button
onClick={() => setActiveTab('custom')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
activeTab === 'custom'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Sparkles size={13} />
{t('terminal.themeModal.tab.custom')}
</button>
</div>
{/* List Content */}
<>
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{activeTab === 'theme' && (
<div className="space-y-1">
{hiddenSelectedTheme && (
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 mb-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
{t('terminal.hiddenTheme.title')}
</div>
<div className="text-xs font-medium text-foreground">{hiddenSelectedTheme.name}</div>
<div className="text-[10px] text-muted-foreground mt-1">
{t('terminal.hiddenTheme.desc')}
</div>
</div>
)}
{/* Built-in themes */}
{builtinThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedTheme === theme.id && !editingTheme}
onSelect={handleThemeSelect}
/>
))}
{/* Custom themes section */}
{draftCustomThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1.5 px-1 font-semibold">
{t('terminal.customTheme.section')}
</div>
{draftCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedTheme === theme.id && !editingTheme}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
{activeTab === 'font' && (
<div className="space-y-1">
{availableFonts.map(font => (
<FontItem
key={font.id}
font={font}
isSelected={selectedFont === font.id}
onSelect={handleFontSelect}
/>
))}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
<div className="space-y-2">
{/* Actions */}
<button
onClick={handleNewTheme}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
>
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-primary/10 text-primary">
<Plus size={16} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
</div>
</button>
<button
onClick={handleImportFile}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
>
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500">
<Download size={16} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept=".itermcolors"
onChange={handleFileSelected}
className="hidden"
/>
{/* Custom themes list */}
{draftCustomThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1 px-1 font-semibold">
{t('terminal.customTheme.yourThemes')}
</div>
{draftCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedTheme === theme.id}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
</div>
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-3 border-t border-border shrink-0">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold">
{t('terminal.themeModal.fontSize')}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-2">
<button
onClick={() => handleFontSizeChange(-1)}
disabled={fontSize <= MIN_FONT_SIZE}
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground hover:text-accent-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Minus size={14} />
</button>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold text-foreground tabular-nums">{fontSize}</span>
<span className="text-[10px] text-muted-foreground">px</span>
</div>
<button
onClick={() => handleFontSizeChange(1)}
disabled={fontSize >= MAX_FONT_SIZE}
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground hover:text-accent-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Plus size={14} />
</button>
</div>
</div>
)}
</>
</div>
{/* Right Panel - Large Preview */}
<div className="flex-1 flex flex-col min-w-0 p-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{t('terminal.themeModal.livePreview')}
</div>
<TerminalPreview theme={currentTheme} font={currentFont} fontSize={fontSize} />
{/* Info line */}
<div className="mt-3 text-xs text-muted-foreground flex items-center justify-between">
<span>
{currentTheme.name} {currentFont.name} {fontSize}px
</span>
<span className="text-[10px] uppercase">
{t('terminal.themeModal.themeType', { type: currentTheme.type })}
</span>
</div>
</div>
</div>
{/* Footer */}
<div className="flex gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
<Button
variant="ghost"
onClick={handleCancel}
className="flex-1 h-10"
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1 h-10"
>
{t('common.save')}
</Button>
</div>
</div>
</div>
);
// Use Portal to render at document root
return (
<>
{createPortal(modalContent, document.body)}
{editingTheme && (
<CustomThemeModal
open={!!editingTheme}
theme={editingTheme}
isNew={isNewTheme}
onSave={(theme) => {
setDraftCustomThemes((prev) => {
if (isNewTheme) {
return [...prev, cloneTheme(theme)];
}
return prev.map((entry) => entry.id === theme.id ? cloneTheme(theme) : entry);
});
if (isNewTheme) {
setSelectedTheme(theme.id);
onThemeChange?.(theme.id);
} else {
if (selectedTheme === theme.id) {
onThemeChange?.(theme.id);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}}
onDelete={isNewTheme ? undefined : handleEditorDelete}
onCancel={handleEditorBack}
/>
)}
</>
);
};
export default ThemeCustomizeModal;

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./ThemeSidePanel.tsx", import.meta.url), "utf8");
test("theme side panel keeps theme selection visible while following app theme", () => {
assert.doesNotMatch(source, /const \[activeTab, setActiveTab\] = useState<TabType>\(followAppTerminalTheme \? 'font' : 'theme'\)/);
assert.doesNotMatch(source, /!\s*themeEditingLocked\s*&&\s*\(\s*<button[\s\S]*?terminal\.themeModal\.tab\.theme/);
});
test("hidden selected theme uses the normal theme item row", () => {
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
});
test("theme side panel tabs are vertically centered in the header", () => {
assert.match(
source,
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
);
});

View File

@@ -0,0 +1,626 @@
/**
* ThemeSidePanel - Theme/Font customization panel for the terminal side panel
*
* Adapted from ThemeCustomizeModal's left panel content.
* No preview - the actual terminal behind serves as a live preview.
* Changes apply in real-time.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, getBuiltinTerminalThemeById, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { terminalAppearanceThemePanelVars } from '../../infrastructure/theme/terminalAppearanceTokens';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
import { CustomThemeModal } from './CustomThemeModal';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models';
import { ScrollArea } from '../ui/scroll-area';
import { isFollowAppTerminalThemeId } from '../../domain/terminalAppearance';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
type TabType = 'theme' | 'font' | 'custom';
// Memoized theme item component
const ThemeItem = memo(({
theme,
isSelected,
onSelect,
onEdit,
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
onEdit?: (id: string) => void;
}) => (
<div
role="button"
tabIndex={0}
onClick={() => onSelect(theme.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer'
)}
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{/* Color swatch */}
<div
className="h-6 w-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border-[0.5px]"
style={{ backgroundColor: theme.colors.background, borderColor: 'var(--terminal-panel-border)' }}
>
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-0.5 w-1.5 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{theme.name}
</div>
<div className="text-[10px] capitalize" style={{ color: 'var(--terminal-panel-muted)' }}>
{theme.type}
{theme.isCustom && ' • custom'}
</div>
</div>
{onEdit && (
<div
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
className="w-5 h-5 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
style={{ color: 'var(--terminal-panel-muted)' }}
>
<Pencil size={10} />
</div>
)}
{isSelected && !onEdit && (
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
)}
</div>
));
ThemeItem.displayName = 'ThemeItem';
// Memoized font item component
const FontItem = memo(({
font,
isSelected,
onSelect
}: {
font: TerminalFont;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(font.id)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors'
)}
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<div className="flex-1 min-w-0">
<div
className="text-xs font-medium truncate"
style={{ fontFamily: font.family }}
>
{font.name}
</div>
<div className="text-[10px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>{font.description}</div>
</div>
{isSelected && (
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
)}
</button>
));
FontItem.displayName = 'FontItem';
interface ThemeSidePanelProps {
followAppTerminalTheme?: boolean;
currentThemeId: string;
globalThemeId: string;
currentFontFamilyId: string;
globalFontFamilyId: string;
currentFontSize: number;
currentFontWeight: number;
canResetTheme?: boolean;
canResetFontFamily?: boolean;
canResetFontSize?: boolean;
canResetFontWeight?: boolean;
onThemeChange: (themeId: string) => void;
onThemeReset?: () => void;
onFontFamilyChange: (fontFamilyId: string) => void;
onFontFamilyReset?: () => void;
onFontSizeChange: (fontSize: number) => void;
onFontSizeReset?: () => void;
onFontWeightChange: (fontWeight: number) => void;
onFontWeightReset?: () => void;
isVisible?: boolean;
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
followAppTerminalTheme = false,
currentThemeId,
globalThemeId,
currentFontFamilyId,
globalFontFamilyId,
currentFontSize,
currentFontWeight,
canResetTheme = false,
canResetFontFamily = false,
canResetFontSize = false,
canResetFontWeight = false,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontFamilyReset,
onFontSizeChange,
onFontSizeReset,
onFontWeightChange,
onFontWeightReset,
isVisible = true,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
const [isNewTheme, setIsNewTheme] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (followAppTerminalTheme && activeTab === 'custom') {
setActiveTab('theme');
setEditingTheme(null);
}
}, [activeTab, followAppTerminalTheme]);
const customThemeById = useMemo(
() => new Map(customThemes.map((theme) => [theme.id, theme])),
[customThemes],
);
const fontById = useMemo(
() => new Map(availableFonts.map((font) => [font.id, font])),
[availableFonts],
);
const getThemeById = useCallback((themeId: string): TerminalTheme | undefined =>
getBuiltinTerminalThemeById(themeId) ?? customThemeById.get(themeId),
[customThemeById]);
const globalTheme = useMemo(
() => getThemeById(globalThemeId) || TERMINAL_THEMES[0],
[getThemeById, globalThemeId],
);
const hiddenSelectedTheme = useMemo(
() => (isUiMatchTerminalThemeId(currentThemeId)
? getBuiltinTerminalThemeById(currentThemeId) || null
: null),
[currentThemeId],
);
const globalFont = useMemo(
() => fontById.get(globalFontFamilyId) || availableFonts[0],
[availableFonts, fontById, globalFontFamilyId],
);
const builtinThemes = useMemo(
() => (followAppTerminalTheme
? TERMINAL_THEMES.filter((theme) => isFollowAppTerminalThemeId(theme.id))
: USER_VISIBLE_TERMINAL_THEMES),
[followAppTerminalTheme],
);
const handleThemeSelect = useCallback((themeId: string) => {
setEditingTheme(null);
onThemeChange(themeId);
}, [onThemeChange]);
const handleFontSelect = useCallback((fontId: string) => {
onFontFamilyChange(fontId);
}, [onFontFamilyChange]);
const handleFontSizeChange = useCallback((delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, currentFontSize + delta));
onFontSizeChange(newSize);
}, [currentFontSize, onFontSizeChange]);
const handleNewTheme = useCallback(() => {
const base = getThemeById(currentThemeId) || TERMINAL_THEMES[0];
const newTheme: TerminalTheme = {
...base,
id: `custom-${Date.now()}`,
name: `${base.name} (Custom)`,
isCustom: true,
colors: { ...base.colors },
};
setEditingTheme(newTheme);
setIsNewTheme(true);
}, [currentThemeId, getThemeById]);
const handleImportFile = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
const parsed = parseItermcolors(xml, name);
if (parsed) {
addTheme(parsed);
onThemeChange(parsed.id);
setActiveTab('theme');
} else {
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file.');
}
};
reader.readAsText(file);
e.target.value = '';
}, [addTheme, onThemeChange, t]);
const handleEditTheme = useCallback((themeId: string) => {
const theme = customThemeById.get(themeId);
if (theme) {
setEditingTheme({ ...theme, colors: { ...theme.colors } });
setIsNewTheme(false);
}
}, [customThemeById]);
const handleEditorDelete = useCallback((themeId: string) => {
deleteTheme(themeId);
if (currentThemeId === themeId) {
onThemeChange(TERMINAL_THEMES[0].id);
}
setEditingTheme(null);
setIsNewTheme(false);
}, [deleteTheme, currentThemeId, onThemeChange]);
if (!isVisible) return null;
const footerThemeName = getThemeById(currentThemeId)?.name ?? currentThemeId;
const footerFontName = fontById.get(currentFontFamilyId)?.name ?? currentFontFamilyId;
const footerLabel = `${footerThemeName}${footerFontName}${currentFontSize}px • ${currentFontWeight}`;
const panelVars = terminalAppearanceThemePanelVars;
return (
<>
<div
className="h-full flex flex-col overflow-hidden"
style={{
...panelVars,
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
{/* Tab Bar */}
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
<button
onClick={() => setActiveTab('font')}
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Type size={12} />
{t('terminal.themeModal.tab.font')}
</button>
{!followAppTerminalTheme && (
<button
onClick={() => setActiveTab('custom')}
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
</button>
)}
</div>
{/* List Content */}
<ScrollArea className="flex-1 min-h-0">
<div className="py-1">
{activeTab === 'theme' && (
<div>
{!followAppTerminalTheme && hiddenSelectedTheme && (
<ThemeItem
theme={hiddenSelectedTheme}
isSelected={currentThemeId === hiddenSelectedTheme.id && !editingTheme}
onSelect={handleThemeSelect}
/>
)}
{builtinThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id && !editingTheme}
onSelect={handleThemeSelect}
/>
))}
{!followAppTerminalTheme && customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id && !editingTheme}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
{canResetTheme && (
<>
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalTheme')}
</div>
<ThemeItem
theme={globalTheme}
isSelected={!canResetTheme}
onSelect={() => onThemeReset?.()}
/>
</>
)}
</div>
)}
{activeTab === 'font' && (
<div>
{availableFonts.map(font => (
<FontItem
key={font.id}
font={font}
isSelected={currentFontFamilyId === font.id}
onSelect={handleFontSelect}
/>
))}
{canResetFontFamily && (
<>
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalFont')}
</div>
<FontItem
font={globalFont}
isSelected={!canResetFontFamily}
onSelect={() => onFontFamilyReset?.()}
/>
</>
)}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
<div>
<button
onClick={handleNewTheme}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-panel-fg) 10%, transparent)',
color: 'var(--terminal-panel-fg)',
}}
>
<Plus size={12} />
</div>
<div>
<div className="text-xs font-medium">{t('terminal.customTheme.new')}</div>
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.newDesc')}</div>
</div>
</button>
<button
onClick={handleImportFile}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
<Download size={12} />
</div>
<div>
<div className="text-xs font-medium">{t('terminal.customTheme.import')}</div>
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.importDesc')}</div>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept=".itermcolors"
onChange={handleFileSelected}
className="hidden"
/>
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
</div>
</ScrollArea>
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.fontSize')}
</div>
{canResetFontSize && (
<button
onClick={onFontSizeReset}
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
<button
onClick={() => handleFontSizeChange(-1)}
disabled={currentFontSize <= MIN_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<Minus size={12} />
</button>
<div className="flex items-baseline gap-1">
<span className="text-lg font-bold tabular-nums">{currentFontSize}</span>
<span className="text-[9px]" style={{ color: 'var(--terminal-panel-muted)' }}>px</span>
</div>
<button
onClick={() => handleFontSizeChange(1)}
disabled={currentFontSize >= MAX_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<Plus size={12} />
</button>
</div>
</div>
)}
{/* Font Weight Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.fontWeight')}
</div>
{canResetFontWeight && (
<button
onClick={onFontWeightReset}
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
<Select
value={String(currentFontWeight)}
onValueChange={(v) => onFontWeightChange(Number(v))}
>
<SelectTrigger
className="flex-1 h-7 text-xs"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="100">100 Thin</SelectItem>
<SelectItem value="200">200 ExtraLight</SelectItem>
<SelectItem value="300">300 Light</SelectItem>
<SelectItem value="400">400 Normal</SelectItem>
<SelectItem value="500">500 Medium</SelectItem>
<SelectItem value="600">600 SemiBold</SelectItem>
<SelectItem value="700">700 Bold</SelectItem>
<SelectItem value="800">800 ExtraBold</SelectItem>
<SelectItem value="900">900 Black</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Current selection info */}
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
{footerLabel}
</div>
</div>
</div>
{/* Custom Theme Editor Modal */}
{editingTheme && (
<CustomThemeModal
open={!!editingTheme}
theme={editingTheme}
isNew={isNewTheme}
onSave={(theme) => {
if (isNewTheme) {
addTheme(theme);
onThemeChange(theme.id);
} else {
updateTheme(theme.id, theme);
if (currentThemeId === theme.id) {
onThemeChange(theme.id);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}}
onDelete={isNewTheme ? undefined : handleEditorDelete}
onCancel={() => { setEditingTheme(null); setIsNewTheme(false); }}
/>
)}
</>
);
};
export const ThemeSidePanel = memo(ThemeSidePanelInner);
ThemeSidePanel.displayName = 'ThemeSidePanel';

View File

@@ -0,0 +1,33 @@
import React, { useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
import { Button } from "../ui/button";
interface Props {
filename: string;
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
}
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
const { t } = useI18n();
const [applyToRest, setApplyToRest] = useState(false);
return (
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground break-all">{filename}</p>
<label className="flex items-center gap-2 text-sm mt-2">
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
{t("zmodem.overwrite.applyToRest")}
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,100 @@
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface ZmodemProgressIndicatorProps {
transferType: 'upload' | 'download' | null;
filename: string | null;
transferred: number;
total: number;
fileIndex: number;
fileCount: number;
finalizing: boolean;
bytesPerSecond: number | null;
onCancel: () => void;
}
function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatSpeed(bytesPerSecond: number | null): string | null {
if (!bytesPerSecond || bytesPerSecond <= 0) return null;
return `${formatBytes(bytesPerSecond)}/s`;
}
export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = ({
transferType,
filename,
transferred,
total,
fileIndex,
fileCount,
finalizing,
bytesPerSecond,
onCancel,
}) => {
const { t } = useI18n();
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
const label = finalizing
? t('zmodem.waitingForRemote')
: transferType === 'upload'
? t('zmodem.uploading')
: t('zmodem.downloading');
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
const speed = formatSpeed(bytesPerSecond);
return (
<div
className="flex items-center gap-2.5 px-3 py-2 rounded-lg shadow-lg backdrop-blur-sm min-w-[240px] max-w-[360px]"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 90%, transparent)',
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 15%, var(--terminal-ui-bg, #000000))',
color: 'var(--terminal-ui-fg, #ffffff)',
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Icon className="h-4 w-4 flex-shrink-0 opacity-60" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-medium truncate">
{filename || label}{fileInfo}
</span>
<span className="text-[10px] opacity-60 flex-shrink-0">{percent}%</span>
</div>
<div className="w-full h-1 rounded-full overflow-hidden" style={{ backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 10%, transparent)' }}>
<div
className="h-full rounded-full transition-all duration-150"
style={{
width: `${percent}%`,
backgroundColor: transferType === 'upload' ? '#3b82f6' : '#22c55e',
}}
/>
</div>
<div className="text-[10px] opacity-50 mt-0.5">
{finalizing
? label
: `${formatBytes(transferred)} / ${formatBytes(total)}${speed ? ` · ${speed}` : ''}`}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onCancel}
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
>
<X className="h-3.5 w-3.5 opacity-60" />
</button>
</TooltipTrigger>
<TooltipContent>{t('zmodem.cancelTransfer')}</TooltipContent>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import { alignTerminalViewportScroll } from "./terminalHelpers";
type ViewportSpy = {
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
};
const createTerm = (
viewportY: number,
viewport?: ViewportSpy | null,
): XTerm => ({
buffer: { active: { viewportY } },
_core: viewport === null ? undefined : { _viewport: viewport },
}) as unknown as XTerm;
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const order: string[] = [];
const viewport: ViewportSpy = {
_sync: () => {
order.push("sync");
},
scrollToLine: (line, disableSmoothScroll) => {
order.push("scrollToLine");
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(order, ["sync", "scrollToLine"]);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
_sync: () => {
throw new Error("boom");
},
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
const term = createTerm(10, null);
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: { _viewport: {} },
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: {
_viewport: {
scrollToLine: () => {
throw new Error("boom");
},
},
},
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});

View File

@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const assertRecoverTerminalOnAppResumeOrder = (source: string): void => {
const handlerIndex = source.indexOf("const recoverTerminalOnAppResume = () => {");
assert.notEqual(handlerIndex, -1, "recoverTerminalOnAppResume must exist");
const bodyStart = source.indexOf("{", handlerIndex);
assert.notEqual(bodyStart, -1, "recoverTerminalOnAppResume must have a body");
let depth = 0;
let bodyEnd = -1;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
if (char === "{") depth += 1;
if (char === "}") {
depth -= 1;
if (depth === 0) {
bodyEnd = index + 1;
break;
}
}
}
assert.notEqual(bodyEnd, -1, "recoverTerminalOnAppResume body must close");
const handlerSource = source.slice(handlerIndex, bodyEnd);
const flushIndex = handlerSource.indexOf("flushPendingTerminalWritesOnResume(term)");
const scrollIndex = handlerSource.indexOf("flushPendingOutputScroll()");
const recoveryIndex = handlerSource.indexOf("recoverWebglRendererOnAppResume()");
const refitIndex = handlerSource.indexOf("scheduleLayoutRecoveryRefit([0, 100, 300])");
assert.notEqual(flushIndex, -1, "recoverTerminalOnAppResume must flush pending writes");
assert.notEqual(scrollIndex, -1, "recoverTerminalOnAppResume must flush pending scroll");
assert.notEqual(recoveryIndex, -1, "recoverTerminalOnAppResume must recover WebGL");
assert.notEqual(refitIndex, -1, "recoverTerminalOnAppResume must schedule layout recovery");
assert.ok(flushIndex < scrollIndex, "flush pending writes before pending scroll");
assert.ok(scrollIndex < recoveryIndex, "flush pending scroll before WebGL recovery");
assert.ok(recoveryIndex < refitIndex, "recover WebGL before layout recovery");
};
test("app resume handlers flush backlog and recover the terminal renderer before refit", () => {
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const resumeEffectIndex = source.indexOf("const recoverWebglRendererOnAppResume = () => {");
const resumeEffectEnd = source.indexOf("// Only register the snippet executor", resumeEffectIndex);
const resumeEffectSource = source.slice(resumeEffectIndex, resumeEffectEnd);
assert.ok(resumeEffectIndex >= 0);
assert.ok(resumeEffectEnd > resumeEffectIndex);
assertRecoverTerminalOnAppResumeOrder(source);
assert.match(
resumeEffectSource,
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
);
assert.match(
resumeEffectSource,
/const handleWindowFocus = \(\) => \{\s*recoverTerminalOnAppResume\(\);\s*\};/,
);
assert.match(
resumeEffectSource,
/const unsubscribeWindowShown = terminalBackend\.onWindowShown\?\.\(\(\) => \{\s*recoverTerminalOnAppResume\(\);\s*\}\);/,
);
assert.doesNotMatch(resumeEffectSource, /\binWorkspace\b/);
assert.doesNotMatch(resumeEffectSource, /\bisFocusMode\b/);
assert.doesNotMatch(resumeEffectSource, /\bisFocused\b/);
assert.doesNotMatch(source, /shouldRecoverOnAppResume/);
});
test("useTerminalBackend exposes onWindowShown so the resume hook actually fires", () => {
const source = readFileSync(
new URL("../../application/state/useTerminalBackend.ts", import.meta.url),
"utf8",
);
assert.match(source, /const onWindowShown = useCallback\(\(cb: \(\) => void\) => \{\s*const bridge = netcattyBridge\.get\(\);\s*return bridge\?\.onWindowShown\?\.\(cb\);/);
const returnIndex = source.indexOf("useMemo(");
assert.notEqual(returnIndex, -1);
assert.match(source.slice(returnIndex), /onWindowShown,/);
});

View File

@@ -0,0 +1,613 @@
/**
* Popup autocomplete menu for terminal.
* Renders a floating list of completion suggestions near the terminal cursor.
* Shows a detail tooltip for the selected/hovered item with full description.
* Colors are derived from the active terminal theme for visual consistency.
*/
import React, { useEffect, useLayoutEffect, useRef, useState, memo } from "react";
import { Folder, File, Link } from "lucide-react";
import type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
import {
clampAutocompletePopupGeometry,
computeAutocompletePopupPlacement,
resolveAutocompleteClampViewport,
} from "./terminalAutocompleteLayout";
export interface AutocompleteThemeColors {
background: string;
foreground: string;
selection: string;
cursor: string;
}
export interface SubDirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
export interface SubDirPanel {
entries: SubDirEntry[];
selectedIndex: number;
dirPath: string;
}
interface AutocompletePopupProps {
suggestions: CompletionSuggestion[];
selectedIndex: number;
/** Cursor anchor in viewport coordinates */
anchorViewport: { left: number; top: number; bottom: number };
visible: boolean;
expandUpward?: boolean;
themeColors?: AutocompleteThemeColors;
onSelect: (suggestion: CompletionSuggestion) => void;
maxHeight?: number;
subDirPanels?: SubDirPanel[];
subDirFocusLevel?: number;
/** Reference to the terminal container for calculating fixed position */
containerRef?: React.RefObject<HTMLDivElement | null>;
/** Ask the autocomplete controller to recompute cursor-relative popup position */
onRequestReposition?: () => void;
/** Offset from top of container to terminal content area (toolbar + search bar) */
searchBarOffset?: number;
/** Called when user clicks outside the popup to dismiss it */
onDismiss?: () => void;
}
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
history: { label: "h", fullLabel: "History", fallbackColor: "#FBBF24" },
command: { label: "c", fullLabel: "Command", fallbackColor: "#34D399" },
subcommand: { label: "s", fullLabel: "Subcommand", fallbackColor: "#60A5FA" },
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
snippet: { label: "{}", fullLabel: "Snippet", fallbackColor: "#C084FC" },
plugin: { label: "P", fullLabel: "Plugin", fallbackColor: "#F472B6" },
};
/** Lucide icon components for file types in path suggestions */
const FILE_TYPE_CONFIG: Record<string, { Icon: React.FC<{ size?: number; color?: string }>; color: string }> = {
directory: { Icon: Folder, color: "#38BDF8" },
file: { Icon: File, color: "#94A3B8" },
symlink: { Icon: Link, color: "#A78BFA" },
};
const FileTypeIcon: React.FC<{ fileType: string }> = ({ fileType }) => {
const cfg = FILE_TYPE_CONFIG[fileType] ?? FILE_TYPE_CONFIG.file;
return (
<span
style={{
width: "18px",
height: "18px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<cfg.Icon size={14} color={cfg.color} />
</span>
);
};
/** Chevron indicator for expandable directory items */
const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => (
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}></span>
);
/** Small key-cap badge shown on the selected row to hint the actionable key. */
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box",
height: "16px",
minWidth: "16px",
padding: "0 4px",
fontSize: "11px",
lineHeight: 1,
borderRadius: "4px",
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
flexShrink: 0,
fontFamily:
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
}}
>
{label}
</span>
);
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
suggestions,
selectedIndex,
anchorViewport,
visible,
expandUpward = false,
themeColors,
onSelect,
maxHeight = 240,
subDirPanels = [],
subDirFocusLevel = -1,
containerRef,
onRequestReposition,
searchBarOffset: _searchBarOffset = 30,
onDismiss,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
useEffect(() => {
if (selectedRef.current && listRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "instant" as ScrollBehavior,
});
}
}, [selectedIndex]);
// Reset hover when suggestions change
useEffect(() => {
setHoveredIndex(-1);
}, [suggestions]);
useEffect(() => {
if (!visible || !onRequestReposition) return;
let frameId = 0;
const requestReposition = () => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
frameId = 0;
onRequestReposition();
});
};
const container = containerRef?.current;
const observer = container ? new ResizeObserver(requestReposition) : null;
observer?.observe(container);
window.addEventListener("resize", requestReposition);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer?.disconnect();
window.removeEventListener("resize", requestReposition);
};
}, [containerRef, onRequestReposition, visible]);
useEffect(() => {
if (!visible || !onRequestReposition || suggestions.length === 0) return;
let firstFrame = 0;
let secondFrame = 0;
firstFrame = requestAnimationFrame(() => {
onRequestReposition();
secondFrame = requestAnimationFrame(onRequestReposition);
});
return () => {
if (firstFrame) cancelAnimationFrame(firstFrame);
if (secondFrame) cancelAnimationFrame(secondFrame);
};
}, [onRequestReposition, subDirPanels.length, suggestions, visible]);
useLayoutEffect(() => {
if (!visible || suggestions.length === 0) {
setMeasuredSize((current) => (current === null ? current : null));
return;
}
let frameId = 0;
const measure = () => {
const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return;
setMeasuredSize((current) => {
if (
current &&
Math.abs(current.width - rect.width) < 0.5 &&
Math.abs(current.height - rect.height) < 0.5
) {
return current;
}
return { width: rect.width, height: rect.height };
});
};
measure();
const wrapper = wrapperRef.current;
const observer = wrapper ? new ResizeObserver(measure) : null;
observer?.observe(wrapper);
frameId = requestAnimationFrame(measure);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer?.disconnect();
};
}, [hoveredIndex, selectedIndex, subDirPanels, suggestions, visible]);
// Dismiss popup when clicking outside
useEffect(() => {
if (!visible || !onDismiss) return;
const handlePointerDown = (e: PointerEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
onDismiss();
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [visible, onDismiss]);
if (!visible || suggestions.length === 0) return null;
const bg = themeColors?.background ?? "#1e1e2e";
const fg = themeColors?.foreground ?? "#cdd6f4";
// Accent comes from the active terminal theme's cursor/selection colors,
// which already track the user's accent setting (custom accent rewrites them
// in applyCustomAccentToTerminalTheme). Falling back to selection, then a
// neutral fg-mix, keeps older/partial theme payloads working. This is what
// makes the popup's highlight follow the accent instead of a hardcoded blue.
const accent = themeColors?.cursor || themeColors?.selection || fg;
const popupBg = `color-mix(in srgb, ${bg} 92%, ${fg} 8%)`;
const popupBorder = `color-mix(in srgb, ${bg} 75%, ${fg} 25%)`;
const selectedBg = `color-mix(in srgb, ${accent} 26%, ${bg} 74%)`;
const selectedBorderAccent = `color-mix(in srgb, ${accent} 60%, ${bg} 40%)`;
const hoverBg = `color-mix(in srgb, ${accent} 12%, ${bg} 88%)`;
const textColor = fg;
const dimTextColor = `color-mix(in srgb, ${fg} 50%, ${bg} 50%)`;
// Determine which item to show the detail tooltip for
const detailIndex = hoveredIndex >= 0 ? hoveredIndex : selectedIndex;
const detailItem = detailIndex >= 0 ? suggestions[detailIndex] : null;
const showDetail = detailItem?.description && detailItem.description.length > 0;
// Whether ANY item in the current set can open the detail tooltip (non-path
// row with a description). Placement reserves space from this set-level flag
// rather than the hovered item, so moving the mouse between rows can't change
// totalWidth/height and shift the popup out from under the pointer.
const setMayShowDetailPanel = suggestions.some(
(s) => s.source !== "path" && Boolean(s.description && s.description.length > 0),
);
const fixedLeft = anchorViewport.left;
const fixedLineTop = anchorViewport.top;
const fixedLineBottom = anchorViewport.bottom;
const viewportPadding = 8;
const anchorGap = 8;
const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null);
const estimatedPopupHeight = Math.min(maxHeight, suggestions.length * 28 + 8);
// Reserve the detail height for the whole set (not the hovered row) so the
// chosen direction/height stays stable while hovering.
const estimatedDetailHeight = setMayShowDetailPanel ? 96 : 0;
const desiredContentHeight = Math.max(estimatedPopupHeight, estimatedDetailHeight);
// Total horizontal extent so the WHOLE assembly is clamped inside the
// viewport — not just the main list. Mirrors the rendered maxWidths:
// main list (400) + each cascading sub-dir panel (240) + the detail
// tooltip (280), separated by the flex gap (4). Without this, expanding a
// directory near the right edge pushed the sub-panels off-screen (#1202).
const FLEX_GAP = 4;
const MAIN_LIST_MAX_WIDTH = 400;
const SUBDIR_PANEL_MAX_WIDTH = 240;
const DETAIL_PANEL_MAX_WIDTH = 280;
const totalWidth =
MAIN_LIST_MAX_WIDTH +
subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH) +
(setMayShowDetailPanel ? FLEX_GAP + DETAIL_PANEL_MAX_WIDTH : 0);
const clampWidth =
MAIN_LIST_MAX_WIDTH +
subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH);
const placement = computeAutocompletePopupPlacement({
anchorTop: fixedLineTop,
anchorBottom: fixedLineBottom,
anchorLeft: fixedLeft,
viewportWidth: clampViewport.width,
viewportHeight: clampViewport.height,
clampViewport,
desiredHeight: desiredContentHeight,
totalWidth,
clampWidth,
maxHeight,
anchorGap,
viewportPadding,
expandUpwardHint: expandUpward,
forceExpandUpward: expandUpward,
});
const renderUpward = placement.renderUpward;
const effectiveMaxHeight = placement.maxHeight;
const anchoredTop = placement.top;
const clampedLeft = placement.left;
const finalGeometry = measuredSize
? clampAutocompletePopupGeometry({
left: clampedLeft,
top: anchoredTop,
width: measuredSize.width,
height: measuredSize.height,
clampViewport,
viewportPadding,
})
: { left: clampedLeft, top: anchoredTop };
const sharedBoxStyle = {
// border-box so each panel's maxWidth is its true outer width (padding +
// border included). The horizontal clamp's totalWidth sums these maxWidths,
// so this keeps the off-screen math exact even for the padded detail panel.
boxSizing: "border-box" as const,
backgroundColor: popupBg,
border: `1px solid ${popupBorder}`,
borderRadius: "6px",
boxShadow: renderUpward
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
: "0 2px 6px rgba(0, 0, 0, 0.15)",
fontFamily: "inherit",
fontSize: "13px",
color: textColor,
};
return (
<div
ref={wrapperRef}
style={{
position: "fixed",
left: `${finalGeometry.left}px`,
top: `${finalGeometry.top}px`,
zIndex: 10000,
display: "flex",
alignItems: renderUpward ? "flex-end" : "flex-start",
gap: "4px",
pointerEvents: "auto", // Re-enable on popup itself (parent is pointer-events-none)
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{/* Main suggestion list */}
<div
ref={listRef}
className="xterm-autocomplete-popup"
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "180px",
maxWidth: "400px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
}}
>
{suggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const isHovered = index === hoveredIndex;
const sourceInfo = SOURCE_LABELS[suggestion.source];
return (
<div
key={`${suggestion.text}-${index}`}
ref={isSelected ? selectedRef : undefined}
style={{
display: "flex",
alignItems: "center",
padding: "5px 10px",
cursor: "pointer",
backgroundColor: isSelected ? selectedBg : isHovered ? hoverBg : "transparent",
// Accent rail on the active row so the highlight reads as the
// theme accent. Inset shadow avoids shifting row layout.
boxShadow: isSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined,
gap: "8px",
lineHeight: "1.4",
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(suggestion);
}}
>
{/* Source / file type indicator */}
{suggestion.source === "path" && suggestion.fileType ? (
<FileTypeIcon fileType={suggestion.fileType} />
) : (
<span
role="img"
aria-label={sourceInfo.fullLabel}
title={sourceInfo.fullLabel}
style={{
width: "18px",
height: "18px",
borderRadius: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
fontWeight: 600,
color: sourceInfo.fallbackColor,
backgroundColor: `${sourceInfo.fallbackColor}15`,
flexShrink: 0,
}}
>
{sourceInfo.label}
</span>
)}
{/* Command text */}
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: textColor,
fontWeight: isSelected ? 500 : 400,
}}
>
{suggestion.displayText}
</span>
{/* Inline description (truncated). Snippets show only their label
in the row — the full command lives in the detail preview. */}
{suggestion.source !== "snippet" && suggestion.description && (
<span
style={{
fontSize: "11px",
color: dimTextColor,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "160px",
flexShrink: 0,
}}
>
{suggestion.description}
</span>
)}
{/* Frequency badge for history */}
{suggestion.frequency && suggestion.frequency > 1 && (
<span
style={{
fontSize: "10px",
color: dimTextColor,
flexShrink: 0,
}}
>
×{suggestion.frequency}
</span>
)}
{/* Expand indicator for directories */}
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
)}
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
{isSelected && (
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
)}
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
</span>
)}
</div>
);
})}
</div>
{/* Cascading sub-directory panels */}
{subDirPanels.map((panel, level) => (
<div
key={panel.dirPath}
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "150px",
maxWidth: "240px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
alignSelf: "flex-start",
}}
>
{panel.entries.map((entry, idx) => {
const isFocused = level === subDirFocusLevel;
const isSubSelected = isFocused && idx === panel.selectedIndex;
return (
<div
key={entry.name}
ref={isSubSelected ? (el) => { el?.scrollIntoView({ block: "nearest" }); } : undefined}
style={{
display: "flex",
alignItems: "center",
padding: "4px 10px",
cursor: "pointer",
backgroundColor: isSubSelected ? selectedBg
: (idx === panel.selectedIndex && level < subDirFocusLevel) ? hoverBg
: "transparent",
boxShadow: isSubSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined,
gap: "8px",
lineHeight: "1.4",
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<FileTypeIcon fileType={entry.type} />
<span style={{
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
whiteSpace: "nowrap", color: textColor,
}}>
{entry.name}{entry.type === "directory" ? "/" : ""}
</span>
{entry.type === "directory" && (
<DirExpandIndicator visible={isSubSelected || (idx === panel.selectedIndex && level < subDirFocusLevel)} color={dimTextColor} />
)}
</div>
);
})}
</div>
))}
{/* Detail tooltip panel — shows full description for non-path items */}
{showDetail && detailItem && detailItem.source !== "path" && (
<div
style={{
...sharedBoxStyle,
padding: "10px 12px",
maxWidth: "280px",
minWidth: "160px",
// Bound the tooltip too: a long multi-line snippet description must
// scroll, not push the panel past the viewport edge (#1202).
maxHeight: `${effectiveMaxHeight}px`,
overflowY: "auto",
alignSelf: renderUpward ? "flex-end" : "flex-start",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
<span style={{
fontSize: "10px",
color: SOURCE_LABELS[detailItem.source].fallbackColor,
padding: "1px 5px",
borderRadius: "3px",
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
}}>
{SOURCE_LABELS[detailItem.source].fullLabel}
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.source === "snippet" ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--terminal-font, monospace)",
fontSize: "11px",
lineHeight: 1.4,
}}
>
{detailItem.description}
</pre>
) : (
detailItem.description
)}
</div>
</div>
)}
</div>
);
};
export default memo(AutocompletePopup);

View File

@@ -0,0 +1,537 @@
/**
* Ghost Text addon for xterm.js.
* Renders inline suggestion text after the cursor in a dimmed style,
* similar to fish shell's autosuggestions.
*
* Uses a CSS overlay positioned relative to the terminal cursor,
* avoiding modification of the terminal buffer.
*/
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
import { stringCellWidth } from "./terminalStringCellWidth";
function commonPrefixLength(a: string, b: string): number {
const max = Math.min(a.length, b.length);
let i = 0;
while (i < max && a[i] === b[i]) i += 1;
return i;
}
/** Longest prefix of `input` that is already a suffix of `beforeCursor`. */
function echoedInputPrefixLength(beforeCursor: string, input: string): number {
let n = Math.min(beforeCursor.length, input.length);
while (n > 0 && !beforeCursor.endsWith(input.slice(0, n))) {
n -= 1;
}
return n;
}
function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean {
if (!ghostText || !afterCursor) return false;
const visibleAfterCursor = afterCursor.trimEnd();
const overlap = commonPrefixLength(ghostText, visibleAfterCursor);
if (overlap <= 0) return false;
if (ghostText.slice(0, overlap).trim().length === 0) return false;
return (
overlap === ghostText.length ||
overlap === visibleAfterCursor.length ||
afterCursor[overlap] === " "
);
}
type BufferLineLike = {
isWrapped?: boolean;
translateToString?: (
trimRight?: boolean,
startColumn?: number,
endColumn?: number,
) => string;
};
type ActiveBufferLike = {
baseY: number;
cursorY: number;
cursorX: number;
getLine?: (y: number) => BufferLineLike | undefined;
};
/**
* Text before the cursor across wrapped physical rows. `getLine` only returns
* one row, so a wrapped command's current row cannot end with the full
* `currentInput` — callers must reconstruct the logical line or they will
* treat already-echoed text as unechoed.
*/
function readBeforeCursorAcrossWraps(
buf: ActiveBufferLike,
cols: number,
): string | null {
if (typeof buf.getLine !== "function") return null;
const absY = buf.baseY + buf.cursorY;
let line = buf.getLine(absY);
if (!line || typeof line.translateToString !== "function") return null;
// cursorX is a cell column, not a UTF-16 offset — slice() breaks on
// wide / multi-code-unit graphemes (emoji prompts, CJK).
let beforeCursor = line.translateToString(false, 0, buf.cursorX);
let y = absY;
while (line.isWrapped && y > 0) {
y -= 1;
line = buf.getLine(y);
if (!line || typeof line.translateToString !== "function") break;
// Keep wrap seams aligned with the terminal width (do not trimRight).
const rowCols = cols > 0 ? cols : undefined;
const rowText = rowCols === undefined
? line.translateToString(false)
: line.translateToString(false, 0, rowCols);
beforeCursor = rowText + beforeCursor;
}
return beforeCursor;
}
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
private hintElement: HTMLSpanElement | null = null;
private hintActive = false;
private containerElement: HTMLDivElement | null = null;
private currentSuggestion: string = "";
private currentInput: string = "";
/** Cursor column captured at show() time — the anchor the ghost was painted from. */
private anchorCursorX = 0;
/** Cursor row captured at show() time. */
private anchorCursorY = 0;
/** Length of currentInput at show() time — lets adjustToInput shift left
* by (newInput.length - anchorInputLength) cells without having to
* re-read xterm's cursorX (which hasn't advanced yet at keystroke time). */
private anchorInputLength = 0;
private disposed = false;
private disposables: IDisposable[] = [];
private lastLeft = -1;
private lastTop = -1;
activate(term: XTerm): void {
this.term = term;
const termElement = term.element;
if (!termElement) return;
this.containerElement = document.createElement("div");
this.containerElement.className = "xterm-ghost-text-container";
Object.assign(this.containerElement.style, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
// Sit above xterm's canvas — xterm's default renderer paints its
// theme.background across every cell including empty ones, so a
// ghost placed beneath the canvas would be completely occluded.
zIndex: "1",
});
this.ghostElement = document.createElement("span");
this.ghostElement.className = "xterm-ghost-text";
Object.assign(this.ghostElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.ghostElement);
// Read-only inline hint (e.g. sudo "press Enter to paste password"). Shown
// independently of autocomplete suggestions and never accepted as input.
this.hintElement = document.createElement("span");
this.hintElement.className = "xterm-inline-hint";
Object.assign(this.hintElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.hintElement);
const screenEl = termElement.querySelector(".xterm-screen");
if (screenEl) {
screenEl.appendChild(this.containerElement);
} else {
termElement.appendChild(this.containerElement);
}
this.disposables.push(
term.onRender(() => {
if (this.hintActive) this.updateHintPosition();
if (!this.isVisible()) return;
// Fail-safe: if the device echoed input we didn't track (some bastion
// hosts / network OS, #1013/#1060), hide rather than draw the ghost
// over already-visible text. Done here (post-echo render) rather than
// in show()/adjustToInput so it never fights the keystroke-time path.
if (this.realLineHasUntrackedInput()) {
this.hide();
return;
}
this.updatePosition();
}),
);
// Invalidate cell dimension cache on resize so measurements stay
// accurate, and force a pixel-coord recompute on the next render —
// otherwise the lastLeft/lastTop short-circuit in updatePosition
// would keep the ghost at stale pixel coordinates until the user
// typed again.
this.disposables.push(
term.onResize(() => {
invalidateCellDimensionCache();
this.lastLeft = -1;
this.lastTop = -1;
if (this.isVisible()) this.updatePosition();
if (this.hintActive) this.updateHintPosition();
}),
);
}
/**
* Show ghost text suggestion.
* @param fullSuggestion The complete suggested command
* @param currentInput The text the user has typed so far
*/
show(fullSuggestion: string, currentInput: string): void {
if (this.disposed || !this.ghostElement || !this.term) return;
const ghostText = fullSuggestion.startsWith(currentInput)
? fullSuggestion.substring(currentInput.length)
: "";
if (!ghostText) {
this.hide();
return;
}
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
const buf = this.term.buffer.active;
const liveX = buf.cursorX;
// When show() runs before the shell echoes `currentInput` (CJK IME /
// high-latency SSH), live cursorX is still at the prompt. Advance the
// anchor by the pending input's cell width so the ghost sits after it
// instead of painting over it. Skip the probe when getLine is unavailable
// (unit fakes) so those tests keep the legacy "cursor already at end"
// contract.
let anchorX = liveX;
if (
currentInput.length > 0 &&
typeof buf.getLine === "function"
) {
const beforeCursor = readBeforeCursorAcrossWraps(
buf as ActiveBufferLike,
this.term.cols,
);
if (beforeCursor !== null && !beforeCursor.endsWith(currentInput)) {
// Shell may have echoed only a prefix (e.g. "$ doc" while
// currentInput is "docker"). Advance by the unechoed suffix only —
// adding the full input width on top of a partially-advanced liveX
// overshoots and Math.max self-heal cannot move the ghost left.
const unechoed = currentInput.slice(
echoedInputPrefixLength(beforeCursor, currentInput),
);
anchorX = liveX + stringCellWidth(unechoed, this.term);
}
}
this.anchorCursorX = anchorX;
this.anchorCursorY = buf.cursorY;
this.anchorInputLength = currentInput.length;
// Force position recalc since the text also changed.
this.lastLeft = -1;
this.lastTop = -1;
this.updatePosition();
this.ghostElement.textContent = ghostText;
this.ghostElement.style.display = "block";
// Set font properties once per show (not per frame in updatePosition)
this.ghostElement.style.fontSize = `${this.term.options.fontSize}px`;
this.ghostElement.style.fontFamily = this.term.options.fontFamily || "inherit";
}
hide(): void {
if (this.ghostElement) {
this.ghostElement.style.display = "none";
this.ghostElement.textContent = "";
}
this.currentSuggestion = "";
this.currentInput = "";
this.anchorInputLength = 0;
}
/** Show a read-only inline hint at the cursor (e.g. a sudo password prompt
* hint). Independent of autocomplete suggestions; never accepted as input. */
showHint(text: string): void {
if (this.disposed || !this.hintElement || !this.term) return;
this.hintActive = true;
this.hintElement.textContent = text;
this.hintElement.style.display = "block";
this.hintElement.style.fontSize = `${this.term.options.fontSize}px`;
this.hintElement.style.fontFamily = this.term.options.fontFamily || "inherit";
this.updateHintPosition();
}
hideHint(): void {
this.hintActive = false;
if (this.hintElement) {
this.hintElement.style.display = "none";
this.hintElement.textContent = "";
}
}
isHintActive(): boolean {
return this.hintActive;
}
private updateHintPosition(): void {
if (!this.term || !this.hintElement) return;
const dims = getXTermCellDimensions(this.term);
const buf = this.term.buffer.active;
this.hintElement.style.left = `${buf.cursorX * dims.width}px`;
this.hintElement.style.top = `${buf.cursorY * dims.height}px`;
this.hintElement.style.lineHeight = `${dims.height}px`;
this.hintElement.style.height = `${dims.height}px`;
}
/**
* Re-align the ghost against a freshly-updated user input synchronously.
* Called from handleInput on every keystroke that mutates the typed
* buffer so ghost text never falls out of sync with what the user has
* actually typed.
*
* Implementation relies on the predict-anchor-shift trick rather than
* re-reading xterm's live cursorX: xterm hasn't echoed the triggering
* keystroke yet at this point, so cursorX still points at the
* pre-keystroke column. Instead we track the cursor column captured
* at show() time and advance the ghost's left by the number of chars
* typed since — so the tail aligns with where the real cursor *will*
* land once the echo arrives, even across SSH round-trip latency.
*/
adjustToInput(newInput: string): void {
if (this.disposed || !this.ghostElement || !this.currentSuggestion) return;
if (!this.currentSuggestion.startsWith(newInput)) {
this.hide();
return;
}
this.currentInput = newInput;
const ghostText = this.currentSuggestion.substring(newInput.length);
if (!ghostText) {
this.hide();
return;
}
// Force position recomputation — updatePosition skips DOM writes
// when the left/top cache hasn't changed, but we also need the new
// textContent to flush.
this.lastLeft = -1;
this.lastTop = -1;
this.ghostElement.textContent = ghostText;
this.updatePosition();
this.ghostElement.style.display = "block";
}
/**
* Apply a single keystroke's effect to the ghost without consulting the
* outer typed-input buffer. Used when that buffer's reliability flag is
* off (post-Tab, history recall, cursor moves) — without this hook the
* gate at handleInput's adjustToInput call would freeze the ghost at
* the previous show()'s tail, and a subsequent → -accept would paste
* that stale tail on top of the chars typed in the meantime
* (sttop/dduplicate-glyph bug, issue #906).
*
* Only forwards events the ghost can locally re-derive: a printable
* char appends, Backspace/DEL slices off one char, Ctrl-W performs
* the same trailing-word erase as zsh/bash. Anything else (escape
* sequences, other control codes) is treated as a no-op — those
* paths already clearState() in handleInput, so by the time the user
* could trigger an accept, the ghost is gone.
*/
applyKeystroke(data: string): void {
if (this.disposed || !this.currentSuggestion || !data) return;
let nextInput: string;
if (data === "\x7f" || data === "\b") {
if (this.currentInput.length === 0) return;
nextInput = this.currentInput.slice(0, -1);
} else if (data === "\x17") {
const erased = this.currentInput.replace(/\s*\S+\s*$/, "");
if (erased === this.currentInput) return;
nextInput = erased;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
nextInput = this.currentInput + data;
} else {
return;
}
this.adjustToInput(nextInput);
}
getSuggestion(): string {
return this.currentSuggestion;
}
isVisible(): boolean {
return !!(this.ghostElement && this.ghostElement.style.display !== "none" &&
this.currentSuggestion);
}
/**
* True when the ghost has a live suggestion even if it's momentarily
* shown underneath the real text while the user keeps typing within
* the prediction. Accept-path gates should use this instead of
* isVisible() so the suggestion remains available even while its
* leading characters are fully covered by real glyphs.
*/
isActive(): boolean {
return !this.disposed && !!this.currentSuggestion;
}
getGhostText(): string {
if (!this.currentSuggestion) return "";
return this.currentSuggestion.startsWith(this.currentInput)
? this.currentSuggestion.substring(this.currentInput.length)
: "";
}
getNextWord(): string {
const ghost = this.getGhostText();
if (!ghost) return "";
const trimmed = ghost.replace(/^\s+/, "");
const leadingSpace = ghost.length - trimmed.length;
if (trimmed.length === 0) return ghost; // Only whitespace
// Search for word boundary starting from index 1 (skip leading separator chars like /)
const wordEnd = trimmed.substring(1).search(/[\s/\\-]/);
if (wordEnd < 0) return ghost; // Single word, accept all
// Include leading whitespace + the word up to (and including) the separator
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
}
/**
* True when the real terminal line has input we did not track, or already
* visible text exactly matches the ghost we are about to paint. See
* ./ghostTextConsistency and issues #1013 and #1060. Returns false on
* hosts/inputs we can't judge (non-ASCII, echo still catching up), so the
* ghost only gets suppressed when corruption is actually imminent.
*/
private realLineHasUntrackedInput(): boolean {
if (!this.term) return false;
const buf = this.term.buffer.active;
if (typeof buf?.getLine !== "function") return false;
const line = buf.getLine(buf.baseY + buf.cursorY);
if (!line || typeof line.translateToString !== "function") return false;
const lineText = line.translateToString(false);
const beforeCursor = lineText.slice(0, buf.cursorX);
const afterCursor = lineText.slice(buf.cursorX);
const ghostText = this.getGhostText();
if (hasVisibleGhostPrefix(ghostText, afterCursor)) return true;
if (!this.currentInput) return false;
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
}
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
// Self-heal a stale anchor: when show() fired during the SSH
// keystroke→echo gap without a line probe, cursorX may still be the
// pre-echo column. While no adjustToInput has moved us from the
// show-time baseline, adopt a live cursor that has advanced (echo
// caught up). Use max on the same row so a cell-width-predicted
// pre-echo anchor is not collapsed back onto the prompt before echo
// arrives. When the live row advances, the predicted X may already
// encode a wrap (column >= cols); adopting the live X/Y pair avoids
// counting that wrap again in the modulo math below.
// When the predicted wrap happens on the bottom row, the echo scrolls
// the buffer and Y stays put — adopt live X/Y once it matches the
// normalized wrap column so Math.max cannot keep the unnormalized X.
if (this.currentInput.length === this.anchorInputLength) {
const liveX = this.term.buffer.active.cursorX;
const liveY = this.term.buffer.active.cursorY;
const cols = Math.max(1, this.term.cols);
if (liveY !== this.anchorCursorY) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else if (
this.anchorCursorX >= cols &&
liveX === this.anchorCursorX % cols
) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else {
this.anchorCursorX = Math.max(this.anchorCursorX, liveX);
}
}
const dims = getXTermCellDimensions(this.term);
// Advance (or walk back) the anchor column by the cell width of
// whatever the user has typed since show() was called. Using cell
// width (not code-unit length) lets CJK / emoji / fullwidth glyphs
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
// negative delta by shrinking currentInput below anchorInputLength.
const cellDelta = this.currentInput.length >= this.anchorInputLength
? stringCellWidth(this.currentInput.slice(this.anchorInputLength), this.term)
: -stringCellWidth(
// currentSuggestion[0..anchorInputLength] equals what was typed
// when show() fired (prefix-match invariant), so its slice gives
// the correct cell widths for the deleted glyphs.
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
this.term,
);
const cols = Math.max(1, this.term.cols);
const targetCol = this.anchorCursorX + cellDelta;
// Wrap the predicted cursor position across line boundaries in both
// directions — the real xterm cursor wraps to the next row once it
// crosses cols forward, and to the previous row when a deletion
// crosses back past column 0. JS `%` returns negative for negative
// dividends, so normalize both col and rowOffset explicitly.
let col = targetCol % cols;
let rowOffset = Math.floor(targetCol / cols);
if (col < 0) {
col += cols;
}
// Clamp to the visible top row so a runaway negative delta (e.g.
// deleted past the prompt) doesn't render above the terminal.
const top = Math.max(0, this.anchorCursorY + rowOffset) * dims.height;
const left = col * dims.width;
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
if (left === this.lastLeft && top === this.lastTop) return;
this.lastLeft = left;
this.lastTop = top;
this.ghostElement.style.left = `${left}px`;
this.ghostElement.style.top = `${top}px`;
this.ghostElement.style.lineHeight = `${dims.height}px`;
this.ghostElement.style.height = `${dims.height}px`;
}
dispose(): void {
this.disposed = true;
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.containerElement?.remove();
this.containerElement = null;
this.ghostElement = null;
this.hintElement = null;
this.term = null;
}
}

View File

@@ -0,0 +1,439 @@
/**
* Persistent command history store for terminal autocomplete.
* Stores commands per host with frequency tracking and timestamp ordering.
* Uses localStorageAdapter as the persistence layer (works in renderer process).
*/
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
const STORAGE_KEY = "netcatty:commandHistory";
const MAX_ENTRIES = 10000;
const MAX_ENTRIES_PER_HOST = 5000;
export interface HistoryEntry {
command: string;
hostId: string;
/** OS type for cross-host matching */
os: "linux" | "windows" | "macos";
/** Number of times this exact command was executed */
frequency: number;
/** Timestamp of last execution */
lastUsedAt: number;
/** Timestamp of first execution */
createdAt: number;
}
interface HistoryStore {
entries: HistoryEntry[];
version: number;
}
let cachedStore: HistoryStore | null = null;
function loadStore(): HistoryStore {
if (cachedStore) return cachedStore;
try {
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (parsed) {
cachedStore = parsed;
return parsed;
}
} catch {
// Corrupted data, reset
}
cachedStore = { entries: [], version: 1 };
return cachedStore;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function persistStoreNow(store: HistoryStore): boolean {
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
return localStorageAdapter.write(STORAGE_KEY, store);
}
function saveStore(store: HistoryStore): void {
cachedStore = store;
// Debounce saves to avoid excessive writes
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
persistStoreNow(store);
saveTimer = null;
}, 500);
}
/**
* Flush any pending debounced history write immediately.
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
* flag is not persisted before the imported commands land in storage.
* Returns false when the write could not be persisted.
*/
export function flushCommandHistoryStore(): boolean {
if (!cachedStore) return true;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
return persistStoreNow(cachedStore);
}
/**
* Record a command execution. Updates frequency if the command already exists
* for this host, otherwise creates a new entry.
*/
export function recordCommand(
command: string,
hostId: string,
os: "linux" | "windows" | "macos" = "linux",
): void {
const trimmed = command.trim();
if (!trimmed || trimmed.length > 2000) return;
const store = loadStore();
const now = Date.now();
// Find existing entry for same command + host
const existingIdx = store.entries.findIndex(
(e) => e.command === trimmed && e.hostId === hostId,
);
if (existingIdx >= 0) {
store.entries[existingIdx].frequency++;
store.entries[existingIdx].lastUsedAt = now;
} else {
store.entries.push({
command: trimmed,
hostId,
os,
frequency: 1,
lastUsedAt: now,
createdAt: now,
});
}
// Enforce per-host limit (evict by score, not insertion order)
const hostEntries = store.entries.filter((e) => e.hostId === hostId);
if (hostEntries.length > MAX_ENTRIES_PER_HOST) {
hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now));
const toRemove = new Set(
hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command),
);
store.entries = store.entries.filter(
(e) => e.hostId !== hostId || !toRemove.has(e.command),
);
}
// Enforce global limit
if (store.entries.length > MAX_ENTRIES) {
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, MAX_ENTRIES);
}
saveStore(store);
}
/** Remove one command from autocomplete history for a specific host. */
export function removeCommandHistoryEntry(command: string, hostId: string): boolean {
const trimmed = command.trim();
if (!trimmed) return false;
const store = loadStore();
const nextEntries = store.entries.filter(
(entry) => entry.command !== trimmed || entry.hostId !== hostId,
);
if (nextEntries.length === store.entries.length) return false;
store.entries = nextEntries;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
return persistStoreNow(store);
}
/**
* Score an entry for ranking at a specific timestamp.
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
*/
function scoreEntryAt(entry: HistoryEntry, now: number): number {
const ageMs = now - entry.lastUsedAt;
const ageHours = ageMs / (1000 * 60 * 60);
// Exponential decay: halve relevance every 24 hours
const recencyScore = Math.pow(0.5, ageHours / 24);
return entry.frequency * recencyScore;
}
export interface HistoryQueryOptions {
/** Filter by host ID (strict isolation — only this host's history) */
hostId?: string;
/** Maximum number of results */
limit?: number;
}
export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
/** Base command name, e.g. `cd` or `ls` */
commandName: string;
/** Exact command text to exclude from results */
excludeCommand?: string;
/** Optional path prefix to require on the current argument */
argumentPrefix?: string;
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
*/
export function queryHistory(
prefix: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 20 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const filtered = store.entries.filter((entry) => {
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
// Must not be identical to prefix
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Fuzzy query: matches commands containing all characters of the query
* in order (not necessarily contiguous). Used as a fallback when prefix
* matching yields few results.
*/
export function fuzzyQueryHistory(
query: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 10 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
for (const entry of store.entries) {
// Host filtering
if (hostId) {
if (entry.hostId !== hostId) continue;
}
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
scored.push({ entry, matchScore });
}
}
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Query the most recently used history entries for the same command name.
* Useful when the user is currently completing a path argument and wants
* a few recent command-line examples (e.g. recent `cd ...` commands).
*/
export function queryRecentHistoryByCommand(
options: RecentHistoryQueryOptions,
): HistoryEntry[] {
const {
commandName,
excludeCommand,
argumentPrefix,
hostId,
limit = 3,
} = options;
if (!commandName || limit <= 0) return [];
const store = loadStore();
const trimmedCommandName = commandName.trim().toLowerCase();
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const filtered = store.entries.filter((entry) => {
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
return false;
}
if (excludeCommand && entry.command === excludeCommand) return false;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
return false;
}
}
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
function getCurrentCommandToken(command: string): string {
const tokens = tokenizeShellLike(command);
return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : "";
}
function normalizeArgumentToken(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ")
.toLowerCase();
}
function tokenizeShellLike(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
tokens.push(current);
return tokens;
}
/**
* Compute a fuzzy match score. Returns 0 for no match.
* Higher score = better match quality.
* Rewards: first-char match, consecutive matches, word-boundary matches.
*/
function fuzzyScore(query: string, target: string): number {
if (query.length === 0) return 0;
if (query.length > target.length) return 0;
let score = 0;
let queryIdx = 0;
let prevMatchIdx = -2;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
}
}
// All query characters must be matched
return queryIdx === query.length ? score : 0;
}
/**
* Clear all history for a specific host, or all history if no hostId given.
*/
export function clearHistory(hostId?: string): void {
const store = loadStore();
if (hostId) {
store.entries = store.entries.filter((e) => e.hostId !== hostId);
} else {
store.entries = [];
}
saveStore(store);
}

View File

@@ -0,0 +1,761 @@
/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Context-aware path completions and @withfig/autocomplete specs
* 2. Command history
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
* option, or argument position) and provides appropriate suggestions.
*/
import {
queryHistory,
queryRecentHistoryByCommand,
fuzzyQueryHistory,
type HistoryQueryOptions,
} from "./commandHistoryStore";
import {
loadSpec,
hasSpec,
getAvailableSpecs,
normalizeCommandName,
resolveNames,
type FigSpec,
type FigSubcommand,
type FigOption,
} from "./figSpecLoader";
import {
shouldDoPathCompletion,
getPathSuggestions,
resolvePathComponents,
} from "./remotePathCompleter";
import { getSnippetSuggestions } from "./snippetCompleter";
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
/** Source indicator for where a suggestion came from */
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path" | "snippet" | "plugin";
export interface CompletionSuggestion {
/** The text to insert */
text: string;
/** Display text (may differ from insert text) */
displayText: string;
/** Optional description */
description?: string;
/** Source of this suggestion */
source: SuggestionSource;
/** Relevance score (higher = more relevant) */
score: number;
/** For history entries: execution frequency */
frequency?: number;
/** Matching rule used by recent history surfaced during path completion. */
historyMatch?: "path-argument";
/** For path suggestions: file type */
fileType?: "file" | "directory" | "symlink";
/** For snippet suggestions: the source snippet (used by the accept path). */
snippet?: Snippet;
/** For plugin suggestions: the owning Provider contribution. */
providerId?: string;
}
export interface CompletionContext {
/** Full command line text */
commandLine: string;
/** Current word being typed */
currentWord: string;
/** Index of the current word in the parsed tokens */
wordIndex: number;
/** Parsed command tokens */
tokens: string[];
/** The base command name (first token) */
commandName: string;
/** Whether the current position is after a recognized option that expects an argument */
isOptionArg: boolean;
}
/**
* Soft wait for remote/local path listings. History, fig specs, and snippets are
* local and should paint without waiting on high-latency SSH exec (#2830).
* Timed-out listings still finish in the background: cacheable paths warm the
* shared cache, and cache-bypassed relative SSH paths notify via onLateResult
* so the UI can merge path suggestions when the listing finally resolves.
*/
export const PATH_COMPLETION_BUDGET_MS = 150;
type PathSuggestionEntry = { name: string; type: "file" | "directory" | "symlink" };
/** @internal Exported for unit tests covering the soft path-listing budget. */
export async function getPathSuggestionsWithinBudget(
pathPromise: Promise<PathSuggestionEntry[]>,
budgetMs: number,
onLateResult?: (entries: PathSuggestionEntry[]) => void,
): Promise<PathSuggestionEntry[]> {
if (!Number.isFinite(budgetMs) || budgetMs < 0) {
return pathPromise;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
const raced = await Promise.race([
// Settle rejections here so a late failure after timeout cannot surface
// as an unhandled rejection from the losing Promise.race branch.
pathPromise.then(
(entries) => ({ kind: "entries" as const, entries }),
() => ({ kind: "entries" as const, entries: [] as PathSuggestionEntry[] }),
),
new Promise<{ kind: "timeout" }>((resolve) => {
timeoutId = setTimeout(() => resolve({ kind: "timeout" }), budgetMs);
}),
]);
if (raced.kind === "entries") return raced.entries;
// Keep the listing in flight. Cacheable paths warm the shared cache for a
// later keystroke; bypassed relative SSH paths have no cache, so surface
// the late result to the caller instead of discarding it.
void pathPromise.then(
(entries) => {
if (entries.length > 0) onLateResult?.(entries);
},
() => {},
);
return [];
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
}
function buildPathCompletionSuggestions(
ctx: CompletionContext,
pathEntries: PathSuggestionEntry[],
cwd: string | undefined,
): CompletionSuggestion[] {
if (pathEntries.length === 0) return [];
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, cwd);
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
const suggestions: CompletionSuggestion[] = [];
for (const entry of pathEntries) {
const insertName = isQuotedPath || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name
: shellEscape(entry.name);
const suffix = entry.type === "directory" ? "/" : "";
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, fullPath),
displayText: entry.name + suffix,
source: "path",
score: 750,
fileType: entry.type,
});
}
return suggestions;
}
interface SpecSuggestionResult {
suggestions: CompletionSuggestion[];
pathArgs?: FigSubcommand["args"];
}
export function shellEscape(name: string): string {
if (!name) return name;
if (/[\\$'"|!<>;#~` ]/.test(name)) {
return `'${name.replace(/'/g, "'\\''")}'`;
}
return name;
}
/**
* Parse a command line string into tokens, handling quoting.
*/
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
// Always include the last token (even if empty, to indicate trailing space)
tokens.push(current);
return tokens;
}
/**
* Parse the current command line into a CompletionContext.
*/
export function parseCommandLine(input: string): CompletionContext {
const tokens = tokenize(input);
const wordIndex = tokens.length - 1;
const currentWord = tokens[wordIndex] || "";
const commandName = tokens.length > 0 ? normalizeCommandName(tokens[0]) : "";
return {
commandLine: input,
currentWord,
wordIndex,
tokens,
commandName,
isOptionArg: false,
};
}
/**
* Main completion function. Returns sorted suggestions from all sources.
* Ghost text should use completions[0].text instead of a separate query.
*/
export async function getCompletions(
input: string,
options: {
hostId?: string;
hostGroup?: string;
os?: "linux" | "windows" | "macos";
maxResults?: number;
/** Session ID for remote path completion */
sessionId?: string;
/** Connection protocol (ssh, local, telnet, serial) */
protocol?: string;
/** Current working directory (from OSC 7) */
cwd?: string;
cwdSource?: AutocompleteCwdSource;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
/** Which history pool to query (default: current host only). */
historyScope?: AutocompleteHistoryScope;
/**
* Soft budget for path listings (ms). Local suggestions return when this
* elapses even if remote `find` is still running. Use `Infinity` in tests
* that need the full remote listing.
*/
pathBudgetMs?: number;
/**
* Invoked when a path listing finishes after the soft budget elapsed.
* Needed for cache-bypassed relative SSH cwd lookups, which cannot warm
* the shared directory cache for a later keystroke.
*/
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15, historyScope = "host" } = options;
const pathBudgetMs = options.pathBudgetMs ?? PATH_COMPLETION_BUDGET_MS;
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
? await getSpecSuggestions(ctx)
: { suggestions: [] };
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, specResult.pathArgs)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
// History queries honor historyScope; snippets still stay host-scoped.
const historyHostId = historyScope === "global" ? undefined : hostId;
// 1. History suggestions (full command line prefix match)
// Cap history to leave room for spec suggestions in the popup
const historyOpts: HistoryQueryOptions = {
hostId: historyHostId,
limit: preferPathSuggestions ? 0 : 5,
};
const historyMatches = queryHistory(input, historyOpts);
for (const entry of historyMatches) {
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 1000 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
if (preferPathSuggestions && ctx.commandName) {
// When path completion is active (file-related commands like cat, vim, cd),
// recent history is still useful but should rank below actual path matches
// from the current directory.
const recentHistory = queryRecentHistoryByCommand({
commandName: ctx.commandName,
excludeCommand: input,
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
hostId: historyHostId,
limit: 5,
});
for (let index = 0; index < recentHistory.length; index++) {
const entry = recentHistory[index];
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 720 - index,
frequency: entry.frequency,
historyMatch: "path-argument",
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const pathEntries = canQueryPaths && pathCheck.shouldComplete
? await getPathSuggestionsWithinBudget(
getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
os: options.os,
cwd: options.cwd,
cwdSource: options.cwdSource,
foldersOnly: pathCheck.foldersOnly,
}),
pathBudgetMs,
(lateEntries) => {
if (!options.onLatePathSuggestions) return;
const latePathSuggestions = buildPathCompletionSuggestions(
ctx,
lateEntries,
options.cwd,
);
if (latePathSuggestions.length > 0) {
options.onLatePathSuggestions(latePathSuggestions);
}
},
)
: [];
for (const suggestion of specResult.suggestions) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
for (const suggestion of buildPathCompletionSuggestions(ctx, pathEntries, options.cwd)) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
// 3. Fuzzy history fallback while typing the command name. Once arguments
// are present, history completion is prefix-only: fuzzy matching the whole
// line can borrow characters from later paths and keep an incompatible
// middle argument visible (issue #3088).
if (
ctx.wordIndex === 0 &&
!preferPathSuggestions &&
suggestions.length < 3 &&
input.length >= 2
) {
const fuzzyMatches = fuzzyQueryHistory(input, {
...historyOpts,
limit: 5,
});
for (const entry of fuzzyMatches) {
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 500 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
// Snippets: only at the command position (typing the command name).
// Push without the early seen-text skip: snippets score above history, so if
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
hostId,
hostGroup: options.hostGroup,
})) {
suggestions.push(snippetSuggestion);
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score);
// Deduplicate
const seen = new Set<string>();
const unique: CompletionSuggestion[] = [];
for (const s of suggestions) {
if (seen.has(s.text)) continue;
seen.add(s.text);
unique.push(s);
if (unique.length >= resultLimit) break;
}
return unique;
}
function normalizeHistoryPathPrefix(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ");
}
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
}
return { suggestions };
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return { suggestions };
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return { suggestions };
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
for (const sub of spec.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.currentWord + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
return { suggestions };
}
// Navigate the spec tree based on typed tokens
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
// and show its children as preview (e.g., "git commit" shows commit's options)
if (currentToken && resolved.subcommands) {
const exactMatch = resolved.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(currentToken);
});
if (exactMatch) {
// Navigate into the matched subcommand and show its children
const childResolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex + 1));
// Show child subcommands
if (childResolved.subcommands) {
for (const sub of childResolved.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.commandLine + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
// Show child options
appendOptionPreviewSuggestions(
suggestions,
ctx.commandLine,
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return { suggestions };
}
}
// Suggest subcommands (prefix match, excluding exact matches)
if (resolved.subcommands) {
for (const sub of resolved.subcommands) {
const names = resolveNames(sub.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: sub.description,
source: "subcommand",
score: 800,
});
}
}
}
}
// Suggest options
const hasDirectOptionSuggestions = appendOptionSuggestions(
suggestions,
ctx,
currentToken,
resolved.options,
);
if (!hasDirectOptionSuggestions) {
appendOptionSuggestions(suggestions, ctx, currentToken, resolved.fallbackOptions);
}
// Suggest argument values from suggestions in the spec
if (resolved.args) {
const args = Array.isArray(resolved.args) ? resolved.args : [resolved.args];
for (const arg of args) {
if (arg.suggestions) {
for (const sug of arg.suggestions) {
const sugName = typeof sug === "string" ? sug : (Array.isArray(sug.name) ? sug.name[0] : sug.name);
const sugDesc = typeof sug === "string" ? undefined : sug.description;
if (sugName.startsWith(currentToken) && sugName !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, sugName),
displayText: sugName,
description: sugDesc,
source: "arg",
score: 600,
});
}
}
}
}
}
return {
suggestions,
pathArgs: resolved.args,
};
}
/**
* Get command name suggestions by matching against available specs.
* Uses the already-imported getAvailableSpecs directly (no dynamic self-import).
*/
async function getCommandNameSuggestions(prefix: string): Promise<CompletionSuggestion[]> {
const specs = await getAvailableSpecs();
const lower = prefix.toLowerCase();
const suggestions: CompletionSuggestion[] = [];
for (const name of specs) {
// Skip sub-path specs like "aws/s3", "dotnet/dotnet-build" — not direct shell commands
if (name.includes("/")) continue;
if (name.startsWith(lower) && name !== lower) {
suggestions.push({
text: name,
displayText: name,
source: "command",
score: 600,
});
if (suggestions.length >= 10) break;
}
}
return suggestions;
}
interface ResolvedContext {
subcommands?: FigSubcommand[];
options?: FigOption[];
fallbackOptions?: FigOption[];
args?: FigSubcommand["args"];
}
/**
* Walk the spec tree following the typed tokens to find the current context.
* Handles options with arguments (e.g., --name value) by skipping the value token.
*/
function resolveSpecContext(spec: FigSpec, consumedTokens: string[]): ResolvedContext {
let current: FigSubcommand = spec;
let inheritedOptions: FigOption[] = [];
let skipNext = false;
let lastOptionArgs: FigSubcommand["args"] | undefined;
for (const token of consumedTokens) {
// Skip this token if it's the argument value of a previous option
if (skipNext) {
skipNext = false;
lastOptionArgs = undefined;
continue;
}
// Handle option flags
if (token.startsWith("-")) {
// Check if this option expects an argument
const opt = [...(current.options ?? []), ...inheritedOptions].find((candidate) => {
const names = resolveNames(candidate.name);
return names.includes(token);
});
if (opt?.args) {
// This option expects an argument — the next token is its value
const args = Array.isArray(opt.args) ? opt.args : [opt.args];
if (args.length > 0 && !args[0].isOptional) {
skipNext = true;
lastOptionArgs = opt.args; // Track for the case where next token is currentWord
}
}
continue;
}
// Try to find a matching subcommand
if (current.subcommands) {
const sub = current.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(token);
});
if (sub) {
inheritedOptions = mergeOptionLists(inheritedOptions, current.options);
current = sub;
continue;
}
}
// If no subcommand matched, we're at the args level
break;
}
// If skipNext is still true, the currentWord is an option's arg value
// (e.g., "git archive --format |" — currentWord is the format value)
// Return the option's args instead of the subcommand's args.
if (skipNext && lastOptionArgs) {
return {
subcommands: undefined,
options: undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: lastOptionArgs,
};
}
return {
subcommands: current.subcommands,
options: current.options ? [...current.options] : undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: current.args,
};
}
function mergeOptionLists(
left: FigOption[] | undefined,
right: FigOption[] | undefined,
): FigOption[] {
const merged: FigOption[] = [];
const seen = new Set<string>();
for (const option of [...(left ?? []), ...(right ?? [])]) {
const key = resolveNames(option.name).sort().join("\0");
if (seen.has(key)) continue;
seen.add(key);
merged.push(option);
}
return merged;
}
function appendOptionSuggestions(
suggestions: CompletionSuggestion[],
ctx: CompletionContext,
currentToken: string,
options: FigOption[] | undefined,
): boolean {
if (!options || options.length === 0) return false;
let added = false;
for (const opt of options) {
const names = resolveNames(opt.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: opt.description,
source: "option",
score: 700,
});
added = true;
}
}
}
return added;
}
function appendOptionPreviewSuggestions(
suggestions: CompletionSuggestion[],
commandLine: string,
options: FigOption[] | undefined,
limit: number,
): void {
if (!options || options.length === 0 || suggestions.length >= limit) return;
for (const opt of options) {
const names = resolveNames(opt.name);
suggestions.push({
text: commandLine + " " + names[0],
displayText: names[0],
description: opt.description,
source: "option",
score: 700,
});
if (suggestions.length >= limit) break;
}
}
/**
* Rebuild the full command text with a replacement at a specific token index.
*/
function rebuildCommand(tokens: string[], replaceIndex: number, replacement: string): string {
const rebuilt = [...tokens];
rebuilt[replaceIndex] = replacement;
return rebuilt.join(" ");
}

View File

@@ -0,0 +1,203 @@
/**
* Loader for @withfig/autocomplete command specifications.
* Loads specs via Electron main process IPC (Node.js require),
* which reliably accesses node_modules in both dev and production.
*/
/** Minimal Fig spec types — mirrors @withfig/autocomplete-types */
export interface FigOption {
name: string | string[];
description?: string;
args?: FigArg | FigArg[];
isRequired?: boolean;
isPersistent?: boolean;
exclusiveOn?: string[];
}
export interface FigArg {
name?: string;
description?: string;
suggestions?: (string | FigSuggestion)[];
template?: string | string[];
isOptional?: boolean;
isVariadic?: boolean;
generators?: unknown;
}
export interface FigSuggestion {
name: string | string[];
description?: string;
icon?: string;
type?: string;
priority?: number;
}
export interface FigSubcommand {
name: string | string[];
description?: string;
subcommands?: FigSubcommand[];
options?: FigOption[];
args?: FigArg | FigArg[];
}
export interface FigSpec extends FigSubcommand {
// Top-level spec may include additional metadata
}
// Bridge type augmentation
interface FigSpecBridge {
listFigSpecs?: () => Promise<string[]>;
loadFigSpec?: (commandName: string) => Promise<FigSpec | null>;
}
function getBridge(): FigSpecBridge | undefined {
if (typeof window === "undefined") return undefined;
return (window as Window & { netcatty?: FigSpecBridge }).netcatty;
}
// Cache loaded specs
const specCache = new Map<string, FigSpec | null>();
// In-flight loading promises to avoid duplicate loads
const inFlightLoads = new Map<string, Promise<FigSpec | null>>();
// All available spec names
let availableSpecs: string[] | null = null;
let availableSpecsSet: Set<string> | null = null;
/**
* Get the list of all available command specs via IPC.
*/
export async function getAvailableSpecs(): Promise<string[]> {
// Only return cache if it has actual specs (not an empty failure)
if (availableSpecs && availableSpecs.length > 0) return availableSpecs;
try {
const bridge = getBridge();
if (bridge?.listFigSpecs) {
const specs = await bridge.listFigSpecs();
if (Array.isArray(specs) && specs.length > 0) {
availableSpecs = specs;
availableSpecsSet = new Set(specs);
return specs;
}
}
} catch (err) {
console.warn("[Autocomplete] figspec bridge error:", err);
}
// Don't cache empty — allow retry on next call
return [];
}
/**
* Load a command specification by name via IPC.
* Uses in-flight deduplication to avoid loading the same spec twice concurrently.
*/
export async function loadSpec(commandName: string): Promise<FigSpec | null> {
if (specCache.has(commandName)) {
return specCache.get(commandName) ?? null;
}
const existing = inFlightLoads.get(commandName);
if (existing) return existing;
const loadPromise = (async (): Promise<FigSpec | null> => {
try {
const bridge = getBridge();
if (!bridge?.loadFigSpec) {
// Don't cache — bridge may not be ready yet (dev reload, non-Electron preview)
return null;
}
const spec = await bridge.loadFigSpec(commandName);
if (spec) {
specCache.set(commandName, spec);
}
// Don't cache null — the load may have failed transiently (bridge not ready, etc.)
// Only cache null when we're confident the spec doesn't exist (hasSpec returned false)
return spec;
} catch {
// Don't cache failures — allow retry on next request
return null;
} finally {
inFlightLoads.delete(commandName);
}
})();
inFlightLoads.set(commandName, loadPromise);
return loadPromise;
}
/**
* Check if a spec exists for a given command name (without loading it).
*/
export async function hasSpec(commandName: string): Promise<boolean> {
// Only trust positive cache hits (spec loaded successfully).
// Null entries may be stale failures from preload — ignore them.
const cached = specCache.get(commandName);
if (cached) return true;
await getAvailableSpecs();
return availableSpecsSet?.has(commandName) ?? false;
}
/**
* Common shell commands preloaded when autocomplete is enabled.
* Includes local overrides under electron/specs/ (e.g. yum, dnf, awk).
*/
export const COMMON_FIG_SPECS = [
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
"curl", "wget", "tar", "zip", "unzip", "make",
"python", "python3", "pip", "pip3", "node",
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
"vim", "nano", "less", "head", "tail", "sort",
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
] as const;
/**
* Preload commonly used specs in batches to avoid overwhelming IPC.
* Only call this when autocomplete is enabled.
*/
export function preloadCommonSpecs(): void {
const BATCH_SIZE = 8;
let offset = 0;
const loadBatch = () => {
const batch = COMMON_FIG_SPECS.slice(offset, offset + BATCH_SIZE);
if (batch.length === 0) return;
for (const name of batch) {
loadSpec(name).catch(() => {});
}
offset += BATCH_SIZE;
if (offset < COMMON_FIG_SPECS.length) {
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => loadBatch());
} else {
setTimeout(loadBatch, 100);
}
}
};
setTimeout(loadBatch, 200);
}
/**
* Get normalized name variants (e.g., "git" from "/usr/bin/git").
*/
export function normalizeCommandName(rawCommand: string): string {
const parts = rawCommand.split("/");
let name = parts[parts.length - 1];
name = name.replace(/\.(exe|cmd|bat|sh|bash|zsh|fish)$/i, "");
return name.toLowerCase();
}
/**
* Resolve names from a Fig spec name field (which can be string or string[]).
*/
export function resolveNames(name: string | string[]): string[] {
return Array.isArray(name) ? name : [name];
}

View File

@@ -0,0 +1,24 @@
export type GhostSuggestionDecision =
| { type: "keep" }
| { type: "show"; suggestion: string }
| { type: "hide" };
/**
* Prefer a stable ghost suggestion while the user's typed input still
* falls within the currently shown prediction. This avoids a "jitter"
* effect where freshly fetched suggestions keep replacing the same
* visual prediction one character at a time.
*/
export function decideGhostSuggestion(
activeSuggestion: string | null,
input: string,
nextSuggestion: string | null,
): GhostSuggestionDecision {
if (activeSuggestion && activeSuggestion.startsWith(input)) {
return { type: "keep" };
}
if (nextSuggestion && nextSuggestion.startsWith(input)) {
return { type: "show", suggestion: nextSuggestion };
}
return { type: "hide" };
}

View File

@@ -0,0 +1,42 @@
/**
* Fail-safe consistency check for inline (ghost-text) suggestions.
*
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
* cursor, where `trackedInput` is what the client thinks the user has typed.
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
* of sync with what is actually on the terminal line, and the ghost ends up
* painted over characters the user already typed (`int` + ghost `terface` →
* `intterface`).
*
* This detects the one direction that produces visible corruption: the real
* line being AHEAD of the tracked input (it contains the tracked input
* followed by more, untracked characters). SSH echo latency is the opposite
* case — the line is a prefix-behind of the tracked input — and is
* intentionally NOT flagged, so the ghost stays responsive on slow links.
*
* Returns true when the caller should hide the ghost.
*/
export function lineHasUntrackedTrailingInput(
trackedInput: string,
lineBeforeCursor: string,
): boolean {
// Single chars match too loosely to judge reliably; let them through.
if (trackedInput.length < 2) return false;
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
// existing wide-char (CJK / emoji) handling is left untouched.
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
// Use the last occurrence so a prompt or command that repeats the same token
// earlier on the line doesn't shadow the freshly-typed input.
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
if (idx < 0) {
// Tracked input isn't on screen yet — the echo is still catching up
// (latency). Keep the ghost; reality being behind never corrupts.
return false;
}
// Non-whitespace characters between the tracked input and the cursor mean the
// device echoed input we never tracked → the ghost would overlap real text.
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
}

View File

@@ -0,0 +1,6 @@
export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTerminalAutocomplete";
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
export { shellEscape } from "./completionEngine";

View File

@@ -0,0 +1,55 @@
/**
* Compute the keystrokes to send so the terminal input line becomes exactly
* `candidate`, given what is currently on the line. Drives the popup
* autocomplete live-preview (#1005): moving the selection renders the chosen
* suggestion into the command line, and switching / reverting rewrites it.
*
* - Forward prefix (candidate continues the line): append only the new tail.
* - Otherwise: clear the current input, then write the full candidate. POSIX
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
* sized to the current line length.
*/
/**
* Live-preview rewrites inject Ctrl-U / backspaces into the PTY. Vendor
* bastion and network-device CLIs treat those bytes as session-kill, so
* network-device sessions keep the popup but skip the rewrite (#1193).
*/
export function shouldWriteAutocompleteLivePreview(
livePreviewEnabled: boolean,
isNetworkDevice = false,
): boolean {
return livePreviewEnabled && !isNetworkDevice;
}
export function isWindowsShellLineInput(
os: string,
promptText?: string | null,
): boolean {
if (os === "windows") return true;
// Hosts default to os:"linux" and the flag is easy to leave wrong. Windows
// shells do not kill the line on Ctrl-U; PSReadLine renders the raw byte
// literally (e.g. `tkn^Uuv run ...`), so every highlighted suggestion piles
// onto the command line (#3184). The detected prompt is authoritative when
// the flag disagrees: a drive-letter path with a backslash (`PS C:\Users>`,
// `C:\Windows>`) only occurs in a Windows shell prompt.
return typeof promptText === "string" && /(?:^|\s)[A-Za-z]:\\/.test(promptText);
}
export function computeLivePreviewWrite(input: {
currentLine: string;
candidate: string;
os: string;
/** Detected prompt text; lets a mislabeled host OS flag still clear the line (#3184). */
promptText?: string;
}): string {
const { currentLine, candidate, os } = input;
if (candidate === currentLine) return "";
if (candidate.startsWith(currentLine)) {
return candidate.slice(currentLine.length);
}
const clear = isWindowsShellLineInput(os, input.promptText)
? "\b".repeat(currentLine.length)
: "\x15";
return clear + candidate;
}

View File

@@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import test from "node:test";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const files = new Map<string, string>();
let bridgeEnabled = true;
const bridge = {
getHomeDir: async () => (bridgeEnabled ? "/Users/demo" : Promise.reject(new Error("no bridge"))),
readLocalFile: async (path: string, options?: { maxBytes?: number }) => {
if (!bridgeEnabled) throw new Error("no bridge");
const text = files.get(path);
if (text === undefined) throw new Error(`ENOENT: ${path}`);
let bytes = new TextEncoder().encode(text);
if (options?.maxBytes && bytes.byteLength > options.maxBytes) {
bytes = bytes.subarray(bytes.byteLength - options.maxBytes);
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
},
};
Object.defineProperty(globalThis, "window", {
value: { electron: bridge, netcatty: bridge },
configurable: true,
});
const { clearHistory, queryHistory } = await import("./commandHistoryStore.ts");
const { seedLocalShellHistoryFromHistfiles } = await import("./localShellHistorySeed.ts");
const { getCompletions } = await import("./completionEngine.ts");
test.beforeEach(() => {
localStorage.clear();
clearHistory();
files.clear();
bridgeEnabled = true;
(window as Window & { netcatty?: unknown }).netcatty = bridge;
});
test("seedLocalShellHistoryFromHistfiles imports zsh history for autocomplete prefix match", async () => {
const hostId = "local-terminal";
files.set(
"/Users/demo/.zsh_history",
": 1700000000:0;sudo xattr -rd com.apple.quarantine /Applications/ClashX\\ Meta.app\n",
);
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.ok(seeded > 0);
const matches = queryHistory("sudo xattr", { hostId, limit: 5 });
assert.equal(matches.length, 1);
assert.match(matches[0].command, /ClashX/);
const completions = await getCompletions("sudo xattr", {
hostId,
os: "macos",
protocol: "local",
});
assert.ok(
completions.some((c) => c.source === "history" && c.text.includes("ClashX")),
`expected history completion, got ${JSON.stringify(completions.map((c) => ({ s: c.source, t: c.text })))}`,
);
});
test("seedLocalShellHistoryFromHistfiles is idempotent for the same host after a successful import", async () => {
const hostId = "local-terminal";
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n: 1700000001:0;ls\n");
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(first, 2);
assert.equal(second, 0);
});
test("seedLocalShellHistoryFromHistfiles retries when histfiles were empty", async () => {
const hostId = "local-terminal";
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(first, 0);
files.set("/Users/demo/.zsh_history", ": 1700000000:0;echo later\n");
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(second, 1);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 1);
});
test("seedLocalShellHistoryFromHistfiles no-ops without a bridge and stays retryable", async () => {
const hostId = "local-terminal";
(window as Window & { netcatty?: unknown }).netcatty = undefined;
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(first, 0);
(window as Window & { netcatty?: unknown }).netcatty = bridge;
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.equal(second, 1);
});
test("seedLocalShellHistoryFromHistfiles dedupes concurrent calls for the same host", async () => {
const hostId = "local-terminal";
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
// Start the first seed without awaiting so the second call overlaps in-flight.
const firstPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
const secondPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
const [a, b] = await Promise.all([firstPromise, secondPromise]);
assert.equal(a, 1);
assert.equal(b, 1);
assert.equal(queryHistory("pw", { hostId, limit: 5 }).length, 1);
});
test("seedLocalShellHistoryFromHistfiles drops a partial first line from a full-budget histfile tail", async () => {
const hostId = "local-terminal";
// Simulate a main-process maxBytes tail: exactly 512KiB ending mid-command,
// then a complete command on the next line.
const maxBytes = 512 * 1024;
const complete = ": 1700000001:0;echo complete\n";
const partialPrefix = "PARTIAL_TRUNCATED_COMMAND_WITHOUT_NEWLINE";
const overhead = Buffer.byteLength(`${partialPrefix}\n\n${complete}`, "utf8");
const filler = "x".repeat(maxBytes - overhead);
const tail = `${partialPrefix}\n${filler}\n${complete}`;
assert.equal(Buffer.byteLength(tail, "utf8"), maxBytes);
files.set("/Users/demo/.zsh_history", tail);
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
assert.ok(seeded >= 1);
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo complete");
assert.equal(queryHistory("PARTIAL", { hostId, limit: 5 }).length, 0);
});
test("seedLocalShellHistoryFromHistfiles joins Windows home paths for fish history", async () => {
const hostId = "local-terminal";
const previousHome = bridge.getHomeDir;
bridge.getHomeDir = async () => "C:\\Users\\demo";
try {
files.set(
"C:\\Users\\demo\\.config\\fish\\fish_history",
"- cmd: echo fish\n when: 1700000000\n",
);
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "windows");
assert.equal(seeded, 1);
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo fish");
} finally {
bridge.getHomeDir = previousHome;
}
});

View File

@@ -0,0 +1,163 @@
/**
* Seed autocomplete command history from the local machine's shell histfiles.
*
* Local Terminal sessions previously used a per-session hostId (`local-${sessionId}`),
* so Netcatty's autocomplete history never accumulated across opens. Even with a
* stable hostId, a fresh install / new machine has an empty store until the user
* types commands inside Netcatty — while Ghostty (and similar terminals) surface
* suggestions from ~/.zsh_history / ~/.bash_history immediately.
*
* This module imports those histfiles once per hostId into commandHistoryStore
* so prefix autocomplete can match them.
*/
import {
isNetcattyAiHistoryCommand,
isNetcattyManagedStartupHistoryCommand,
mergeRemoteHistory,
parseBashHistory,
parseFishHistory,
parseZshHistory,
} from "../../../domain/remoteHistory";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { flushCommandHistoryStore, recordCommand } from "./commandHistoryStore";
const SEED_FLAG_PREFIX = "netcatty:localHistSeeded:";
const MAX_SEED_COMMANDS = 500;
/** Cap histfile reads so a multi-MB history does not stall Local Terminal mount. */
const MAX_HISTFILE_BYTES = 512 * 1024;
type LocalFsBridge = {
getHomeDir?: () => Promise<string>;
readLocalFile?: (
path: string,
options?: { maxBytes?: number },
) => Promise<ArrayBuffer | Buffer | Uint8Array | string>;
};
const inFlightSeeds = new Map<string, Promise<number>>();
function getBridge(): LocalFsBridge | undefined {
return (window as Window & { netcatty?: LocalFsBridge }).netcatty;
}
function joinHomePath(home: string, relativeUnix: string): string {
const normalizedHome = home.replace(/[/\\]+$/, "");
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
const relative = sep === "\\" ? relativeUnix.replace(/\//g, "\\") : relativeUnix;
return `${normalizedHome}${sep}${relative}`;
}
function decodeHistfileBytes(bytes: Uint8Array): string {
// Main-process reads already return at most MAX_HISTFILE_BYTES. A buffer that
// fills the budget is treated as a truncated tail, so drop the first
// (possibly partial) line before parsing.
let text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
if (bytes.byteLength >= MAX_HISTFILE_BYTES) {
const firstNewline = text.indexOf("\n");
if (firstNewline >= 0) text = text.slice(firstNewline + 1);
}
return text;
}
async function readTextFile(bridge: LocalFsBridge, path: string): Promise<string | null> {
if (!bridge.readLocalFile) return null;
try {
// Ask the main process to return only the trailing bytes so multi-MB
// histfiles never cross the IPC boundary in full.
const raw = await bridge.readLocalFile(path, { maxBytes: MAX_HISTFILE_BYTES });
if (typeof raw === "string") {
// Bridge returned a string (tests / alternate adapters). Cap by UTF-8
// byte length so this path matches the binary branch.
const encoded = new TextEncoder().encode(raw);
return decodeHistfileBytes(encoded);
}
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
return decodeHistfileBytes(bytes);
} catch {
return null;
}
}
function alreadySeeded(hostId: string): boolean {
return localStorageAdapter.readBoolean(`${SEED_FLAG_PREFIX}${hostId}`) === true;
}
function markSeeded(hostId: string): void {
localStorageAdapter.writeBoolean(`${SEED_FLAG_PREFIX}${hostId}`, true);
}
async function seedLocalShellHistoryFromHistfilesOnce(
hostId: string,
os: "linux" | "windows" | "macos",
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
const bridge = getBridge();
if (!bridge?.getHomeDir || !bridge.readLocalFile) return 0;
let home: string;
try {
home = await bridge.getHomeDir();
} catch {
return 0;
}
if (!home) return 0;
const [zshText, bashText, fishText, fishAltText] = await Promise.all([
readTextFile(bridge, joinHomePath(home, ".zsh_history")),
readTextFile(bridge, joinHomePath(home, ".bash_history")),
readTextFile(bridge, joinHomePath(home, ".local/share/fish/fish_history")),
readTextFile(bridge, joinHomePath(home, ".config/fish/fish_history")),
]);
const lists = [
zshText ? parseZshHistory(zshText) : [],
bashText ? parseBashHistory(bashText) : [],
fishText ? parseFishHistory(fishText) : [],
!fishText && fishAltText ? parseFishHistory(fishAltText) : [],
];
const merged = mergeRemoteHistory(lists, MAX_SEED_COMMANDS);
let recorded = 0;
// mergeRemoteHistory returns newest-first; record oldest-first so frequency /
// lastUsedAt ordering stays sensible if the same command appears later.
for (const entry of [...merged].reverse()) {
const command = entry.command.trim();
if (!command) continue;
if (isNetcattyAiHistoryCommand(command)) continue;
if (isNetcattyManagedStartupHistoryCommand(command)) continue;
recordCommand(command, hostId, os);
recorded += 1;
}
// Only persist the seeded flag after we actually imported commands and
// flushed the store. An empty/missing histfile must remain retryable so a
// later Local Terminal open can pick up history once it exists (#2037).
if (recorded > 0 && flushCommandHistoryStore()) {
markSeeded(hostId);
}
return recorded;
}
/**
* Import local shell histfiles into the autocomplete history store for `hostId`.
* Returns the number of commands newly recorded. No-ops when already seeded for
* this hostId, when the local FS bridge is unavailable, or when histfiles are
* empty/missing (those cases stay retryable on the next Local Terminal open).
*/
export async function seedLocalShellHistoryFromHistfiles(
hostId: string,
os: "linux" | "windows" | "macos" = "macos",
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
const existing = inFlightSeeds.get(hostId);
if (existing) return existing;
const pending = seedLocalShellHistoryFromHistfilesOnce(hostId, os).finally(() => {
inFlightSeeds.delete(hostId);
});
inFlightSeeds.set(hostId, pending);
return pending;
}

View File

@@ -0,0 +1,973 @@
/**
* Prompt detector for terminal autocomplete.
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
* Uses xterm.js buffer analysis to identify common prompt patterns.
*
* Strategy: scan prompt-looking boundaries ($ # % >, Powerline/Nerd Font glyphs,
* etc.) and choose the most reliable split for prompt text vs. user input.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { COMMON_SHELL_COMMANDS, NON_PROMPT_PATTERNS, PROMPT_CHARS } from "./promptDetectorPatterns";
export interface PromptDetectionResult {
/** Whether a prompt is detected on the current line */
isAtPrompt: boolean;
/** The detected prompt text (everything before user input) */
promptText: string;
/** The user's current input (after the prompt) */
userInput: string;
/** The cursor column position within the user input */
cursorOffset: number;
}
const NO_PROMPT: PromptDetectionResult = {
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
};
export function isNonPromptLine(lineText: string): boolean {
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
}
function isSpecificShellPromptCandidate(
promptText: string,
options: { allowGreaterThanTerminator?: boolean } = {},
): boolean {
const trimmed = promptText.trim();
if (
!options.allowGreaterThanTerminator &&
(trimmed.endsWith(">") || trimmed.endsWith(""))
) {
return false;
}
return trimmed.length >= 6 && /[@:\\/~\])]/.test(trimmed);
}
function isLikelyNoSpaceShellPromptText(promptText: string): boolean {
const trimmed = promptText.trim();
if (/^root[#%$]$/.test(trimmed)) return true;
if (trimmed.length < 3) return false;
const marker = trimmed[trimmed.length - 1];
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return false;
const prev = trimmed[trimmed.length - 2] ?? "";
return /[~:/\\\])]/.test(prev);
}
export interface AlignedPromptResult {
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
prompt: PromptDetectionResult;
/**
* The keystroke buffer, but only when it's both marked reliable AND
* can be validated against the live terminal line. Returns null
* otherwise - the single signal downstream uses to decide whether
* to record it as the executed command.
*/
alignedTyped: string | null;
/**
* When false, `prompt.userInput` was filled from the keystroke buffer
* before any shell echo. Empty echo is also what echo-disabled password
* prompts look like, so callers must not surface or accept completions
* (built-in or external) and must not authorize history recording
* (`alignedTyped`). Omitted/true means the live line validated input.
*/
allowExternalProviders?: boolean;
}
function getCursorLinePrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return null;
const lineText = line.translateToString(false);
return sliceStringByCellColumns(lineText, 0, Math.max(0, buffer.cursorX), term);
}
function getWrappedCursorPrefix(term: XTerm): string | null {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
const line = buffer.getLine(cursorY);
if (!line?.isWrapped) return null;
let promptRow = cursorY - 1;
while (promptRow >= 0) {
const prevLine = buffer.getLine(promptRow);
if (!prevLine) return null;
if (!prevLine.isWrapped) break;
promptRow--;
}
const promptLine = buffer.getLine(promptRow);
if (!promptLine) return null;
let prefix = promptLine.translateToString(false);
for (let row = promptRow + 1; row < cursorY; row++) {
const rowLine = buffer.getLine(row);
if (!rowLine) return null;
prefix += rowLine.translateToString(false);
}
const cursorRowText = line.translateToString(false);
return prefix + sliceStringByCellColumns(cursorRowText, 0, Math.max(0, cursorX), term);
}
function inferPromptTextBeforeTypedInput(
cursorPrefix: string,
typedBuffer: string,
allowPartialEcho: boolean,
): string | null {
if (cursorPrefix.endsWith(typedBuffer)) {
const promptText = cursorPrefix.slice(0, cursorPrefix.length - typedBuffer.length);
return promptText.length > 0 ? promptText : null;
}
if (!allowPartialEcho) return null;
const maxEchoLength = Math.min(cursorPrefix.length, typedBuffer.length);
const minPartialEchoLength = Math.max(6, typedBuffer.length - 2);
for (let echoLength = maxEchoLength - 1; echoLength >= minPartialEchoLength; echoLength--) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!cursorPrefix.endsWith(echoedInput)) continue;
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
if (promptText.length > 0) return promptText;
}
const noSpacePromptMinEchoLength = typedBuffer.trim().length <= 2 ? 1 : 3;
for (
let echoLength = Math.min(maxEchoLength - 1, minPartialEchoLength - 1);
echoLength >= noSpacePromptMinEchoLength;
echoLength--
) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!cursorPrefix.endsWith(echoedInput)) continue;
const hasReliablePartialEcho =
typedBuffer.trim().length <= 2 ||
echoedInput.endsWith(" ") ||
(echoedInput.includes(" ") && echoedInput.length >= 4);
if (!hasReliablePartialEcho) continue;
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
}
return null;
}
function hasSwallowedCommandAfterPrompt(promptText: string, promptBoundary: number): boolean {
const candidate = promptText.slice(0, promptBoundary).trimEnd();
const finalIndex = candidate.length - 1;
const finalChar = finalIndex >= 0 ? candidate[finalIndex] : "";
for (let i = 0; i < finalIndex; i++) {
const ch = candidate[i];
if (!PROMPT_CHARS.has(ch) && !isPuaChar(ch)) continue;
const nextChar = i + 1 < candidate.length ? candidate[i + 1] : null;
if (nextChar === null || nextChar === " ") continue;
const earlierPrompt = candidate.slice(0, i + 1);
if (isLikelyNoSpaceShellPromptText(earlierPrompt)) return true;
if (isEmbeddedPromptMarkerAt(candidate, i)) continue;
if (!isSpecificShellPromptCandidate(earlierPrompt)) continue;
if (PROMPT_CHARS.has(nextChar) || isPuaChar(nextChar)) return true;
if (startsWithCommonShellCommand(candidate.slice(i + 1))) return true;
if (finalChar !== "$") return true;
}
return false;
}
function canUseInferredPromptText(promptText: string, rawIsAtPrompt: boolean): boolean {
if (promptText.length === 0) return false;
if (rawIsAtPrompt) return true;
const promptBoundary = findPromptBoundary(promptText);
const promptEndsAtBoundary =
promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
return (
promptEndsAtBoundary &&
!hasSwallowedCommandAfterPrompt(promptText, promptBoundary) &&
isSpecificShellPromptCandidate(promptText)
);
}
function isThemedPromptText(promptText: string): boolean {
for (const ch of promptText) {
if (isPuaChar(ch)) return true;
}
return /[❯❮→➜➤⟩»›]/.test(promptText);
}
function isPromptPathDecoration(trimmed: string): boolean {
return (
trimmed === "~" ||
trimmed.startsWith("~/") ||
trimmed.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(trimmed) ||
trimmed.includes("\\")
);
}
function isPromptBareDirectoryText(trimmed: string): boolean {
if (trimmed.startsWith("./") || trimmed.startsWith("../")) return false;
return /^[\w.-]+$/.test(trimmed);
}
function isPromptStatusToken(token: string): boolean {
return (
/^git:\([^)]*\)$/.test(token) ||
/^[+$#%>!?*]$/.test(token) ||
token === "✗" ||
token === "✔"
);
}
function isPromptStatusText(trimmed: string): boolean {
const [first = "", ...rest] = trimmed.split(/\s+/);
if (rest.length === 0) return false;
if (!isPromptBareDirectoryText(first) && !isPromptPathDecoration(first)) return false;
return rest.every(isPromptStatusToken);
}
function isPromptStatusDecoration(extra: string): boolean {
if (!/^\s+/.test(extra) || !/\s+$/.test(extra)) return false;
return isPromptStatusText(extra.trim());
}
function isPromptDecorationExtra(extra: string, promptText: string): boolean {
const trimmed = extra.trim();
if (trimmed.length === 0) return false;
if (!isThemedPromptText(promptText)) return false;
if (startsWithCommonShellCommand(extra)) return false;
if (/^\s*\S+\s+$/.test(extra)) {
return isPromptPathDecoration(trimmed) || (
isPromptBareDirectoryText(trimmed) &&
!startsWithCommonShellCommand(trimmed)
);
}
if (isPromptStatusDecoration(extra)) return true;
for (const ch of extra) {
if (isPuaChar(ch)) return true;
}
return false;
}
function getFinalPromptBoundary(promptText: string): number {
const trimmedEnd = promptText.trimEnd().length;
if (trimmedEnd === 0) return -1;
const markerIndex = trimmedEnd - 1;
const marker = promptText[markerIndex];
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return -1;
const nextChar = markerIndex + 1 < promptText.length ? promptText[markerIndex + 1] : null;
if (nextChar !== null && nextChar !== " ") return -1;
return nextChar === " " ? markerIndex + 2 : markerIndex + 1;
}
function endsAtFinalPromptBoundary(promptText: string): boolean {
const promptBoundary = getFinalPromptBoundary(promptText);
return promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
}
function getLeadingShellCommandWord(text: string): string | null {
return text.trimStart().match(/^[\w.-]+(?=\s|$)/)?.[0] ?? null;
}
function startsWithCommonShellCommand(text: string): boolean {
const command = getLeadingShellCommandWord(text);
return command !== null && COMMON_SHELL_COMMANDS.has(command);
}
function isCompleteSpecificPrompt(promptText: string): boolean {
const promptBoundary = getFinalPromptBoundary(promptText);
return (
promptBoundary >= 0 &&
promptText.slice(promptBoundary).trim().length === 0 &&
isSpecificShellPromptCandidate(promptText) &&
!isEmbeddedPromptMarker(promptText, promptBoundary)
);
}
function looksLikeCommandAfterCompletePrompt(promptText: string, extra: string): boolean {
return isCompleteSpecificPrompt(promptText) && extra.trim().length > 0;
}
function hasShellCommandAfterOptionalDecoration(text: string): boolean {
const trimmedStart = text.trimStart();
if (startsWithCommonShellCommand(trimmedStart)) return true;
const [, afterDecoration = ""] = trimmedStart.match(/^\S+\s+(.+)$/) ?? [];
return startsWithCommonShellCommand(afterDecoration);
}
function isSingleBareDirectoryExtra(extra: string): boolean {
const trimmed = extra.trim();
return /^\s*\S+\s+$/.test(extra) && isPromptBareDirectoryText(trimmed);
}
function hasExplicitThemedDirectorySpacing(extra: string): boolean {
return /^\s+\S+\s+$/.test(extra);
}
type PromptDecorationReconcileOptions = {
allowSingleWordCommandDirectory?: boolean;
};
function canTreatCommonCommandNameAsThemedDirectory(
extra: string,
typedInput: string,
options: PromptDecorationReconcileOptions = {},
): boolean {
const trimmedInput = typedInput.trim();
return (
isSingleBareDirectoryExtra(extra) &&
(
/\s/.test(trimmedInput) ||
/^(?:ls|cd|pwd)$/.test(trimmedInput) ||
(
options.allowSingleWordCommandDirectory === true &&
hasExplicitThemedDirectorySpacing(extra)
)
)
);
}
function canReconcilePromptDecoration(
prompt: PromptDetectionResult,
typedInput: string,
options: PromptDecorationReconcileOptions = {},
): boolean {
if (
!prompt.isAtPrompt ||
!typedInput ||
prompt.userInput.length <= typedInput.length ||
!prompt.userInput.endsWith(typedInput)
) {
return false;
}
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
if (looksLikeCommandAfterCompletePrompt(prompt.promptText, extra)) return false;
if (
isThemedPromptText(prompt.promptText) &&
canTreatCommonCommandNameAsThemedDirectory(extra, typedInput, options)
) {
return true;
}
if (isThemedPromptText(prompt.promptText) && hasShellCommandAfterOptionalDecoration(extra)) {
return false;
}
const candidatePromptText = prompt.promptText + extra;
const promptEndsAtBoundary =
endsAtFinalPromptBoundary(candidatePromptText) &&
isSpecificShellPromptCandidate(candidatePromptText);
return promptEndsAtBoundary || isPromptDecorationExtra(extra, prompt.promptText);
}
function alignTypedInputFromCursorPrefix(
raw: PromptDetectionResult,
cursorPrefix: string | null,
typedBuffer: string,
): AlignedPromptResult | null {
if (!cursorPrefix) return null;
if (!raw.isAtPrompt && isNonPromptLine(cursorPrefix)) return null;
const promptText = inferPromptTextBeforeTypedInput(cursorPrefix, typedBuffer, !raw.isAtPrompt);
if (!promptText || !canUseInferredPromptText(promptText, raw.isAtPrompt)) {
return null;
}
return {
prompt: {
isAtPrompt: true,
promptText,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
function canUseReliablePromptPrefix(
raw: PromptDetectionResult,
typedBuffer: string,
): boolean {
// Empty echo alone is not validation: echo-disabled prompts can look like
// a normal shell PS1 (e.g. `read -s -p '$ '`), and treating the keystroke
// buffer as alignedTyped would authorize history recording. Pre-echo
// autocomplete uses a separate path that keeps alignedTyped null.
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
return false;
}
if (typedBuffer.length <= raw.userInput.length) return false;
return isReliableTypedPrefix(raw.userInput, typedBuffer, {
allowShortEcho: allowsShortPromptEcho(raw.promptText),
});
}
function isLikelyBareMongoPromptName(promptName: string): boolean {
return /^(?:test|admin|local|config)$/i.test(promptName);
}
function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
const trimmed = promptText.trimEnd();
if (!trimmed.endsWith(">")) return false;
const promptName = trimmed.slice(0, -1).trim();
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
}
function endsWithWindowsPathGreaterThanPrompt(promptText: string): boolean {
const trimmed = promptText.trimEnd();
if (!trimmed.endsWith(">")) return false;
const before = trimmed.slice(0, -1).trimEnd();
// cmd.exe: `C:\path>` / `C:\>`; PowerShell: `PS C:\path>`
if (/^[A-Za-z]:[\\/]/.test(before)) return true;
if (/^PS\s+[A-Za-z]:[\\/]/i.test(before)) return true;
return false;
}
function endsWithStandardShellPrompt(promptText: string): boolean {
const finalChar = promptText.trimEnd().at(-1);
return finalChar === "$" || finalChar === "#" || finalChar === "%";
}
function allowsShortPromptEcho(promptText: string): boolean {
return (
endsWithStandardShellPrompt(promptText) ||
endsWithHostStyleGreaterThanPrompt(promptText) ||
endsWithWindowsPathGreaterThanPrompt(promptText)
);
}
function isReliableTypedPrefix(
echoedInput: string,
typedBuffer: string,
options: { allowShortEcho?: boolean } = {},
): boolean {
if (!typedBuffer.startsWith(echoedInput)) return false;
if (
options.allowShortEcho &&
typedBuffer.trim().length <= 2 &&
echoedInput.trim().length >= 1
) {
return true;
}
return (
echoedInput.length >= Math.max(4, typedBuffer.length - 2) ||
(echoedInput.endsWith(" ") && echoedInput.trim().length >= 2) ||
(echoedInput.includes(" ") && echoedInput.length >= 4)
);
}
function withTypedUserInput(
prompt: PromptDetectionResult,
typedBuffer: string,
): PromptDetectionResult {
return {
...prompt,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
};
}
function alignThemedDecorationWithPartialEcho(
raw: PromptDetectionResult,
typedBuffer: string,
): AlignedPromptResult | null {
if (!raw.isAtPrompt || !isThemedPromptText(raw.promptText)) return null;
const maxEchoLength = Math.min(raw.userInput.length, typedBuffer.length);
for (let echoLength = maxEchoLength; echoLength > 0; echoLength--) {
const echoedInput = typedBuffer.slice(0, echoLength);
if (!raw.userInput.endsWith(echoedInput)) continue;
const extra = raw.userInput.slice(0, raw.userInput.length - echoLength);
if (extra.length === 0) continue;
const hasReliableThemedDirectoryPrefix =
isSingleBareDirectoryExtra(extra) &&
hasExplicitThemedDirectorySpacing(extra) &&
typedBuffer.trim().length <= 3 &&
echoedInput.trim().length >= 1;
const syntheticPrompt = {
...raw,
userInput: extra + typedBuffer,
cursorOffset: extra.length + typedBuffer.length,
};
if (
!hasReliableThemedDirectoryPrefix &&
!isReliableTypedPrefix(echoedInput, typedBuffer)
) {
continue;
}
if (!canReconcilePromptDecoration(syntheticPrompt, typedBuffer, {
allowSingleWordCommandDirectory: true,
})) continue;
return {
prompt: {
isAtPrompt: true,
promptText: raw.promptText + extra,
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
};
}
return null;
}
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
export function detectPrompt(term: XTerm): PromptDetectionResult {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
const line = buffer.getLine(cursorY);
if (!line) return NO_PROMPT;
// translateToString(false) preserves trailing spaces — important for cursor-based
// input extraction (trailing space triggers empty token for option suggestions)
const lineText = line.translateToString(false);
// Check for non-prompt patterns (pagers, editors, etc.)
if (isSensitiveTerminalChallenge(lineText) || isNonPromptLine(lineText)) return NO_PROMPT;
if (line.isWrapped) {
const wrappedPrefix = getWrappedCursorPrefix(term);
if (wrappedPrefix && (isSensitiveTerminalChallenge(wrappedPrefix) || isNonPromptLine(wrappedPrefix))) {
return NO_PROMPT;
}
}
// Empty line
if (lineText.trim().length === 0) return NO_PROMPT;
// cursorX is a cell column; lineText is characters. Wide glyphs (CJK in a
// Windows `C:\Users\用户>` prompt) make substring(cursorX) overshoot into
// xterm's empty-cell padding and poison userInput with spaces (#2813 CMD).
const cursorLinePrefix = sliceStringByCellColumns(lineText, 0, Math.max(0, cursorX), term);
const afterCursor = sliceStringByCellColumns(lineText, Math.max(0, cursorX), undefined, term);
// Try to find the prompt boundary on the current line. xterm buffer rows are
// padded with blank cells; when the cursor is at the visible row end, scan
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
// trailing space. If there is command text to the right of the cursor, keep
// the full line so "$" / ">" inside mid-line edits are validated against
// their real following character.
const promptScanText = afterCursor.trim().length > 0
? lineText
: cursorLinePrefix;
const promptEnd = findPromptBoundary(promptScanText);
if (promptEnd >= 0) {
const promptText = lineText.substring(0, promptEnd);
// Input is whatever sits between the prompt and the cursor on the cell-
// accurate prefix — don't use cursorX as a character index.
const userInput = cursorLinePrefix.length >= promptEnd
? cursorLinePrefix.substring(promptEnd)
: "";
const cursorOffset = userInput.length;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
// Handle wrapped lines: if the prompt is on a previous row (e.g., long path or
// long command wrapped onto multiple rows), look upward for the prompt line.
// The current row's content is continuation of the command.
if (line.isWrapped) {
// Walk up to find the first non-wrapped line (the prompt line)
let promptRow = cursorY - 1;
while (promptRow >= 0) {
const prevLine = buffer.getLine(promptRow);
if (!prevLine) break;
if (!prevLine.isWrapped) break;
promptRow--;
}
const promptLine = buffer.getLine(promptRow);
if (promptLine) {
const promptLineText = promptLine.translateToString(false);
if (isSensitiveTerminalChallenge(promptLineText) || isNonPromptLine(promptLineText)) return NO_PROMPT;
const pEnd = findPromptBoundary(promptLineText);
if (pEnd >= 0) {
const promptText = promptLineText.substring(0, pEnd);
// Concatenate all rows from promptRow to cursorY to get full input
let fullInput = promptLineText.substring(pEnd);
for (let row = promptRow + 1; row <= cursorY; row++) {
const rowLine = buffer.getLine(row);
if (rowLine) fullInput += rowLine.translateToString(false);
}
// Trim to cursor position on the last row
const totalCols = term.cols;
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
const cursorOffset = userInput.length;
if (isSensitiveTerminalChallenge(promptText + userInput)
|| isNonPromptLine(promptText + userInput)) return NO_PROMPT;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
}
}
return NO_PROMPT;
}
/**
* Whether a character lives in the Unicode Private Use Area (U+E000U+F8FF).
* Powerline separators (U+E0B0..) and Nerd Font icons (U+E200.., U+F000..) all
* fall here. A PUA char followed by a space is common in themed prompt
* terminators (oh-my-posh, starship, p10k, etc.), but commands can still echo
* those glyphs, so PUA boundaries are kept lower priority than standard prompt
* characters and reconciled with the typed buffer when available.
*/
function isPuaChar(ch: string): boolean {
if (!ch) return false;
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
}
function getBoundaryMarkerIndex(lineText: string, boundary: number): number {
if (boundary <= 0) return -1;
return lineText[boundary - 1] === " " ? boundary - 2 : boundary - 1;
}
function isEmbeddedPromptMarkerAt(lineText: string, markerIndex: number): boolean {
if (markerIndex <= 0) return false;
const marker = lineText[markerIndex];
if (marker !== "#" && marker !== "%" && marker !== ">" && marker !== "$") return false;
const prev = lineText[markerIndex - 1];
return !/[\s~:\])}]/.test(prev);
}
function isEmbeddedPromptMarker(lineText: string, boundary: number): boolean {
return isEmbeddedPromptMarkerAt(lineText, getBoundaryMarkerIndex(lineText, boundary));
}
function canSupersedeThemedPromptBoundary(
lineText: string,
previousBoundary: number,
markerIndex: number,
): boolean {
if (!isThemedPromptText(lineText.slice(0, previousBoundary))) return false;
const rawBetween = lineText.slice(previousBoundary, markerIndex);
const between = rawBetween.trim();
return (
between.length === 0 ||
isPromptPathDecoration(between) ||
isPromptStatusText(between) ||
(
/^\s/.test(rawBetween) &&
isPromptBareDirectoryText(between)
)
);
}
function canPromptMarkerSupersedePreviousBoundary(ch: string): boolean {
return ch === "$" || ch === "#" || ch === "%" || ch === ">" || ch === "";
}
function isSpacedPromptSegment(lineText: string, boundary: number): boolean {
const markerIndex = getBoundaryMarkerIndex(lineText, boundary);
if (markerIndex <= 0) return false;
if (lineText[markerIndex - 1] !== " ") return false;
return lineText[markerIndex + 1] === " ";
}
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
* Returns the character index where user input begins, or -1 if no prompt detected.
*/
function findPromptBoundary(lineText: string): number {
// Scan for prompt boundary. Take the LAST candidate.
// For ambiguous chars like >, limit scan to first 60% to avoid matching redirections.
// For unambiguous prompt chars ($, #), scan the full line since they're rarely
// confused with shell syntax in a prompt position.
const lineLen = lineText.trimEnd().length;
const scanLimit = Math.min(lineLen, 200);
let lastStandardBoundary = -1;
let lastPuaBoundary = -1;
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
const isStandard = PROMPT_CHARS.has(ch);
const isPua = !isStandard && isPuaChar(ch);
if (!isStandard && !isPua) continue;
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
if (
(ch === ">" || ch === "") &&
lastStandardBoundary >= 0 &&
/\s/.test(lineText.slice(0, i).trim()) &&
!isEmbeddedPromptMarker(lineText, lastStandardBoundary) &&
!canSupersedeThemedPromptBoundary(lineText, lastStandardBoundary, i)
) {
continue;
}
// Must be followed by a space or end-of-line.
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
if (nextChar !== null && nextChar !== " ") {
// Special case: cmd.exe prompt `C:\path>command` — allow > without space
// only if preceded by a path-like pattern (drive letter or backslash)
if (ch === ">" && i > 1 && (lineText[i - 1] === "\\" || lineText[i - 1] === "/" || /^[A-Za-z]:/.test(lineText))) {
// Looks like a path ending — accept as prompt
} else {
continue;
}
}
// For '$': exclude shell variable references ($HOME, $PATH, ${...}, $(...))
if (ch === "$") {
// Check what comes AFTER the space — but more importantly check what
// comes BEFORE to see if this looks like a prompt ending vs mid-command $.
// A prompt $ is typically preceded by: space, ), ], digit, username chars, or is at position 0.
// A variable $ is typically inside a command: echo $HOME, export PATH=$PATH:...
//
// Heuristic: if the $ is preceded by a letter/digit/underscore without a space before it
// (i.e., it's part of a token like "echo" or "=$PATH"), it's likely a variable.
if (i > 0) {
const prev = lineText[i - 1];
// If preceded by = or / or another non-separator, it's a variable reference
if (prev === "=" || prev === "/" || prev === ":") continue;
// If preceded by a letter and there's no space between, it could be $HOME-style
// But actually: "user@host:~$ " has letter before $. So check if there's
// a valid prompt pattern before the $.
}
// Check what follows: if after "$ " there's more content with $ in variable positions
// Actually the simplest reliable check: if the character after the space is alphanumeric
// or $ or (, this is likely the START of a command (i.e., this $ IS the prompt ending).
// That's always true for a prompt. So the $ check is really about false positives mid-line.
//
// Better heuristic: if we haven't seen a space before this $ (meaning the $ is inside
// the first token), it's likely a prompt. If we've already passed spaces (meaning
// we're past the first "word"), a $ is more likely a variable.
let seenSpaceBeforeDollar = false;
for (let j = 0; j < i; j++) {
if (lineText[j] === " ") { seenSpaceBeforeDollar = true; break; }
}
// If there was a space before this $, it might be mid-command (like "echo $HOME")
// Only accept if the $ is reasonably close to common prompt patterns
if (seenSpaceBeforeDollar) {
// Check if this looks like a bracketed prompt ending: "]$ " or ")$ "
if (i > 0 && (lineText[i - 1] === "]" || lineText[i - 1] === ")" ||
lineText[i - 1] === " " || lineText[i - 1] === "~")) {
// Likely a prompt ending like [user@host ~]$
} else {
continue; // Skip — likely a variable reference mid-command
}
}
}
// Record this as a candidate boundary. A standard shell prompt terminator
// is more reliable than a later Powerline/Nerd Font glyph in command text.
const boundary = nextChar === " " ? i + 2 : i + 1;
const candidatePromptText = lineText.slice(0, boundary);
if (isStandard && hasSwallowedCommandAfterPrompt(candidatePromptText, boundary)) {
continue;
}
if (isStandard && lastStandardBoundary >= 0) {
const themedPromptCanSupersede = canSupersedeThemedPromptBoundary(
lineText,
lastStandardBoundary,
getBoundaryMarkerIndex(lineText, boundary),
);
const canSupersedePreviousBoundary =
canPromptMarkerSupersedePreviousBoundary(ch) &&
(
isEmbeddedPromptMarker(lineText, lastStandardBoundary) ||
isSpacedPromptSegment(lineText, lastStandardBoundary) ||
themedPromptCanSupersede
) &&
(
themedPromptCanSupersede ||
isSpecificShellPromptCandidate(candidatePromptText, {
allowGreaterThanTerminator: ch === ">" || ch === "",
})
);
if (!canSupersedePreviousBoundary) continue;
}
if (isStandard) {
lastStandardBoundary = boundary;
} else {
lastPuaBoundary = boundary;
}
}
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
}
/**
* Reconcile a buffer-parsed prompt with the user's own keystroke history.
*
* findPromptBoundary stops at the first `PROMPT_CHAR + space` it sees, so
* themes that render additional content after the prompt char — e.g.
* oh-my-zsh's robbyrussell prints "➜ ~ " where `~` is the cwd — get
* parsed as prompt="➜ " + userInput="~ lo". Every consumer downstream
* (history recording, suggestion matching, insertion) then treats the
* theme's cwd marker as part of the user's command, which pollutes
* history with entries like "~ sudo id" and makes Tab insertions prepend
* a phantom "~ " to the typed command (issue #806).
*
* Whenever we have an independent record of what the user actually typed
* since the last Enter (keystroke buffer), we can detect this case: the
* real input is always a suffix of the over-captured userInput. When it
* is, reattribute the leading garbage back to promptText so the rest of
* the pipeline sees the clean split.
*/
export function reconcilePromptWithTypedInput(
prompt: PromptDetectionResult,
typedInput: string,
): PromptDetectionResult {
if (!prompt.isAtPrompt) return prompt;
if (!typedInput) return prompt;
if (prompt.userInput === typedInput) return prompt;
if (
prompt.userInput.length > typedInput.length &&
prompt.userInput.endsWith(typedInput)
) {
if (!canReconcilePromptDecoration(prompt, typedInput, {
allowSingleWordCommandDirectory: true,
})) {
return prompt;
}
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
return {
isAtPrompt: true,
promptText: prompt.promptText + extra,
userInput: typedInput,
cursorOffset: typedInput.length,
};
}
return prompt;
}
export function reconcilePromptWithExternalCommand(
prompt: PromptDetectionResult,
command: string,
): PromptDetectionResult | null {
const typedInput = command.trim();
if (!prompt.isAtPrompt || typedInput.length === 0) return null;
const syntheticPrompt = {
...prompt,
userInput: `${prompt.userInput}${typedInput}`,
cursorOffset: prompt.userInput.length + typedInput.length,
};
if (!canReconcilePromptDecoration(syntheticPrompt, typedInput, {
allowSingleWordCommandDirectory: true,
})) {
return null;
}
const extra = syntheticPrompt.userInput.slice(
0,
syntheticPrompt.userInput.length - typedInput.length,
);
return {
isAtPrompt: true,
promptText: prompt.promptText + extra,
userInput: typedInput,
cursorOffset: typedInput.length,
};
}
/**
* Unified entry point for any autocomplete code path that needs a prompt
* view. Every consumer (fetchSuggestions, insertSuggestion,
* handleSubDirSelect, Enter-record) goes through this one helper so the
* alignment policy lives in exactly one place — if another out-of-band
* line-rewrite path gets added later and forgets to notify the keystroke
* buffer, the worst that happens is reconcile no-ops and we degrade to
* pre-#806 behavior, not a worse pollution.
*
* Alignment rule: the keystroke buffer is usable only when it's marked
* reliable and it can be reconciled with the live line. Exact raw
* matches are safe, over-captured prompt chrome can be moved back into
* promptText, and no-space prompts can be inferred from the cursor line
* when the inferred prompt still looks like a shell prompt. Otherwise
* the buffer is ignored and the raw detector result passes through.
*/
export function getAlignedPrompt(
term: XTerm | null,
typedBuffer: string,
typedReliable: boolean,
): AlignedPromptResult {
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
const raw = detectPrompt(term);
if (!typedReliable || typedBuffer.length === 0) {
return { prompt: raw, alignedTyped: null };
}
if (raw.isAtPrompt) {
if (raw.userInput === typedBuffer) {
return { prompt: raw, alignedTyped: typedBuffer };
}
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
const prompt = reconcilePromptWithTypedInput(raw, typedBuffer);
if (prompt === raw) return { prompt: raw, alignedTyped: null };
return {
prompt,
alignedTyped: typedBuffer,
};
}
const themedDecorationAlignment = alignThemedDecorationWithPartialEcho(raw, typedBuffer);
if (themedDecorationAlignment) return themedDecorationAlignment;
if (canUseReliablePromptPrefix(raw, typedBuffer)) {
return {
prompt: withTypedUserInput(raw, typedBuffer),
alignedTyped: typedBuffer,
};
}
// No echo yet (CJK IME / high-latency SSH): surface the keystroke buffer
// on prompts detectPrompt already recognizes (#2813), but do not set
// alignedTyped. Empty / whitespace-only echo is also what echo-disabled
// password prompts and padded themed PS1s look like, so this path must
// not authorize history recording, built-in suggestion acceptance, or
// third-party completion providers until echo validates the line.
if (
raw.userInput.trim().length === 0 &&
(allowsShortPromptEcho(raw.promptText) || isThemedPromptText(raw.promptText))
) {
return {
prompt: withTypedUserInput(raw, typedBuffer),
alignedTyped: null,
allowExternalProviders: false,
};
}
}
const cursorPrefixCandidates = [
getWrappedCursorPrefix(term),
getCursorLinePrefix(term),
];
for (const cursorPrefix of cursorPrefixCandidates) {
const aligned = alignTypedInputFromCursorPrefix(raw, cursorPrefix, typedBuffer);
if (aligned) return aligned;
}
return { prompt: raw, alignedTyped: null };
}

View File

@@ -0,0 +1,109 @@
/**
* Patterns that indicate the user is NOT at a prompt
* (e.g., inside vim, less, man, top, etc.)
*/
export const NON_PROMPT_PATTERNS = [
/^~$/, // vim empty line marker
/^\s*--\s*More\s*--/, // less/more pager
/^\s*\(END\)/, // less end marker
/^:\s*$/, // vim command mode
/^\s*~\s*$/, // vim tilde lines
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
/^\s{4}(?:->|['"`]>)\s/, // mysql / mariadb continuation prompts
/^(?:mysql|sqlite(?:3)?|redis(?:-cli)?|psql|mariadb)>\s/i, // mysql> / sqlite> / redis-cli> prompts
/^SQL>\s/i, // sqlplus SQL> prompts
/^(?:sftp|ftp|lftp|ghci|node|mongo|mongosh|deno|irb|pry|julia|scala|gdb|lldb|cqlsh|hive|spark-sql|jshell|ksql|trino|presto|duckdb)>\s/i,
/^irb\([^)]*\):\d+[:*]?\d*>\s/i,
/^pry\([^)]*\)>\s/i,
/^\[\d+\]\s+pry\([^)]*\)>\s/i,
/^lftp\s+\S+>\s/i,
/^\s{3}\.{3}>\s/,
/^cqlsh(?::[\w.-]+)?>\s/i,
/^(?:hive|spark-sql)\s+\([^)]+\)>\s/i,
/^(?:\d+:\s*)?jdbc:hive2?:\/\/\S+>\s/i,
/^(?:test|admin|local|config)>\s+(?:db(?:\.|\s*$)|rs\.|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*)/i,
/^[\w.-]+:[A-Z]+>\s+(?:db\.|rs\.|exit\b|(?:const|let|var|await)\b|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:[\w.-]+\s+){0,5}\[[^\]]+\]\s+[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:[\w.-]+\s+){1,5}[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
/^(?:trino|presto)(?::[\w.-]+){1,2}>\s/i,
/^[\w.-]+@(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3}):\d+>\s/i,
/^(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)(?:\[\d+\])?>\s/, // redis host:port> prompts
/^MariaDB\s+\[[^\]]+\]>\s/i, // MariaDB [(none)]> prompts
/^[\w.-]+=[#>]\s/, // postgres=# / postgres=> REPL prompts
/^[\w.-]+[-'"][#>]\s/, // postgres-# / postgres'# continuation prompts
/^[\w.-]+(?:\([^)]*|\*|!|\^|\$[^$]*\$)[#>]\s/, // postgres multiline prompt states
];
export const COMMON_SHELL_COMMANDS = new Set([
"alias",
"awk",
"az",
"brew",
"bun",
"bundle",
"cargo",
"cat",
"cd",
"chmod",
"chown",
"code",
"composer",
"cp",
"curl",
"docker",
"echo",
"emacs",
"env",
"export",
"find",
"gcloud",
"gh",
"git",
"go",
"gradle",
"grep",
"helm",
"java",
"javac",
"kubectl",
"less",
"ls",
"make",
"mkdir",
"mvn",
"mv",
"nano",
"node",
"npm",
"npx",
"nvim",
"php",
"pip",
"pip3",
"pnpm",
"printf",
"python",
"python3",
"rails",
"rm",
"rsync",
"ruby",
"rustc",
"scp",
"screen",
"sed",
"ssh",
"sudo",
"tail",
"tar",
"terraform",
"tmux",
"touch",
"uv",
"vi",
"vim",
"yarn",
]);
/** Characters that commonly end a shell prompt */
export const PROMPT_CHARS = new Set(["$", "#", "%", ">", "", "", "→", "➜", "➤", "⟩", "»", ""]);

View File

@@ -0,0 +1,506 @@
/**
* Remote path completion for terminal autocomplete.
* Lists files/directories on the remote (or local) machine
* when the user types commands that expect path arguments.
*/
import type { CompletionContext } from "./completionEngine";
import type { FigArg } from "./figSpecLoader";
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
/** Directory entry returned from IPC */
export interface DirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
interface ResolvePathOptions {
preferRelativeCwd?: boolean;
}
/** Bridge interface for directory listing */
interface PathBridge {
listAutocompleteRemoteDir?: (
sessionId: string,
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
listAutocompleteLocalDir?: (
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
}
function getBridge(): PathBridge | undefined {
return (window as Window & { netcatty?: PathBridge }).netcatty;
}
// Cache directory listings for 5 seconds. Full-directory cache is shared between
// popup suggestions and cascading sub-directory panels; filtered cache avoids
// repeated round-trips while the user keeps typing within the same directory.
const fullDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const filteredDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const inFlightRequests = new Map<string, Promise<DirEntry[]>>();
const CACHE_TTL_MS = 5000;
const MAX_CACHE_SIZE = 30;
const MAX_FILTERED_CACHE_SIZE = 60;
/** Commands that commonly accept file/directory path arguments.
* Subcommand-first tools (docker, kubectl, go, cargo, make) are excluded —
* their path arguments are better handled via Fig specs. */
const PATH_COMMANDS = new Set([
// Navigation & listing
"cd", "pushd", "ls", "ll", "la", "dir", "tree", "exa", "eza", "lsd",
// Viewing & editing
"cat", "less", "more", "head", "tail", "bat", "tac", "nl", "tee",
"vim", "vi", "nvim", "nano", "emacs", "code", "subl", "micro", "helix", "hx", "joe", "mcedit",
// File operations
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "ln", "install", "shred",
// Permissions & metadata
"chmod", "chown", "chgrp", "stat", "file", "lsattr", "chattr",
// Search & filter
"find", "rg", "grep", "egrep", "fgrep", "ag", "fd", "locate",
"wc", "sort", "uniq", "cut", "awk", "sed",
// Archive & compression
"tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd",
"7z", "rar", "unrar",
// Transfer & sync
"scp", "rsync", "diff", "cmp", "patch",
// Scripting & execution
"source", ".", "bash", "sh", "zsh", "fish",
"python", "python3", "node", "ruby", "perl", "php", "rustc", "gcc", "g++",
"deno", "bun", "tsx", "ts-node",
// Disk & filesystem
"du", "df", "chroot",
// Misc
"realpath", "readlink", "basename", "dirname", "md5sum", "sha256sum", "xxd", "hexdump",
"xdg-open", "open", "start",
]);
/** Commands that only accept directories (not files) */
const FOLDER_ONLY_COMMANDS = new Set(["cd", "mkdir", "rmdir", "pushd"]);
/**
* Check if the current command context expects a path argument.
*/
export function shouldDoPathCompletion(
ctx: CompletionContext,
resolvedArgs?: FigArg | FigArg[],
): { shouldComplete: boolean; foldersOnly: boolean } {
const currentWord = stripWrappingQuotes(ctx.currentWord);
// 1. Typed path trigger: if current word starts with path-like prefix, always complete
if (currentWord.startsWith("/") || currentWord.startsWith("./") ||
currentWord.startsWith("../") || currentWord.startsWith("~/") ||
currentWord === "." || currentWord === ".." || currentWord === "~") {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
// 2. Fig spec template check
if (resolvedArgs) {
const args = Array.isArray(resolvedArgs) ? resolvedArgs : [resolvedArgs];
for (const arg of args) {
const templates = Array.isArray(arg.template) ? arg.template : arg.template ? [arg.template] : [];
if (templates.includes("filepaths") || templates.includes("folders")) {
return {
shouldComplete: true,
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
};
}
}
}
// 3. Hardcoded command list (for commands without fig specs)
if (ctx.wordIndex >= 1 && PATH_COMMANDS.has(ctx.commandName)) {
// Only if we're past the command name and not typing an option
if (!currentWord.startsWith("-")) {
return {
shouldComplete: true,
foldersOnly: FOLDER_ONLY_COMMANDS.has(ctx.commandName),
};
}
}
return { shouldComplete: false, foldersOnly: false };
}
/**
* Parse the current word into directory-to-list and filter prefix.
*/
export function resolvePathComponents(
currentWord: string,
cwd: string | undefined,
options: ResolvePathOptions = {},
): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } {
const quotePrefix = getLeadingQuote(currentWord);
const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix);
const unquotedWord = stripWrappingQuotes(currentWord);
const preferRelativeCwd = options.preferRelativeCwd === true;
// Handle empty input — list CWD
if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") {
const dir = unquotedWord === "~"
? "~"
: unquotedWord === ".."
? resolveDirLookup("../", cwd, preferRelativeCwd)
: resolveDirLookup("", cwd, preferRelativeCwd);
const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix;
return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix };
}
// Find the last path separator
const lastSlash = unquotedWord.lastIndexOf("/");
if (lastSlash >= 0) {
const dirPart = unquotedWord.substring(0, lastSlash + 1); // includes trailing /
const filterPart = unquotedWord.substring(lastSlash + 1);
const decodedDirPart = decodeShellPathFragment(dirPart);
const decodedFilterPart = decodeShellPathFragment(filterPart);
const dirToList = resolveDirLookup(decodedDirPart, cwd, preferRelativeCwd);
return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix };
}
// No slash — filter CWD entries by the typed prefix
return {
dirToList: resolveDirLookup("", cwd, preferRelativeCwd),
filterPrefix: decodeShellPathFragment(unquotedWord),
pathPrefix: quotePrefix,
quoteSuffix,
};
}
export function normalizePathTokenForLookup(
token: string,
cwd?: string,
options: ResolvePathOptions = {},
): string {
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd, options);
if (!filterPrefix) return dirToList;
if (!dirToList || dirToList === ".") {
return filterPrefix;
}
const needsSeparator = !dirToList.endsWith("/");
return `${dirToList}${needsSeparator ? "/" : ""}${filterPrefix}`;
}
/**
* Get path completion suggestions.
*/
export async function getPathSuggestions(
ctx: CompletionContext,
options: {
sessionId?: string;
protocol?: string;
os?: "linux" | "windows" | "macos";
cwd?: string;
cwdSource?: AutocompleteCwdSource;
foldersOnly: boolean;
},
): Promise<{ name: string; type: DirEntry["type"] }[]> {
const { sessionId, protocol, os, cwd, cwdSource, foldersOnly } = options;
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd, {
preferRelativeCwd: shouldPreferRemoteShellCwd(protocol, sessionId, os, cwd, cwdSource),
});
const entries = await listDirectoryEntries(dirToList, {
sessionId,
protocol,
os,
foldersOnly,
filterPrefix,
limit: 100,
});
return sortPathEntries(entries);
}
/**
* List directory contents via IPC, with shared caching and in-flight dedup.
*/
export async function listDirectoryEntries(
dirPath: string,
options: {
sessionId?: string;
protocol?: string;
os?: "linux" | "windows" | "macos";
foldersOnly: boolean;
filterPrefix?: string;
limit?: number;
},
): Promise<DirEntry[]> {
const {
sessionId,
protocol,
os,
foldersOnly,
filterPrefix = "",
limit = 100,
} = options;
const normalizedPrefix = filterPrefix.toLowerCase();
const maxEntries = clampLimit(limit);
const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`;
const fullCacheKey = `${baseKey}:all`;
const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`;
const bypassCache = shouldBypassCache(protocol, sessionId, os, dirPath);
const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey;
// Full directory cache can satisfy both full and filtered lookups.
// Relative SSH cwd paths bypass durable cache and in-flight reuse: the shell
// cwd can move, so a listing started for "." in directory A must not satisfy
// a later lookup after cd into B. Soft-budget timeout + late refresh already
// share one promise at the getCompletions call site.
if (!bypassCache) {
const fullCached = fullDirCache.get(fullCacheKey);
if (isFresh(fullCached)) {
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
}
if (normalizedPrefix) {
const filteredCached = filteredDirCache.get(filteredCacheKey);
if (isFresh(filteredCached)) {
return filteredCached.entries;
}
}
const inFlightFull = inFlightRequests.get(fullCacheKey);
if (inFlightFull) {
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
}
const inFlight = inFlightRequests.get(requestKey);
if (inFlight) return inFlight;
}
// Make IPC call
const promise = (async (): Promise<DirEntry[]> => {
try {
const bridge = getBridge();
if (!bridge) return [];
let result: { success: boolean; entries: DirEntry[] };
if (protocol === "local" || !sessionId) {
if (!bridge.listAutocompleteLocalDir) return [];
result = await bridge.listAutocompleteLocalDir(
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
} else {
if (!bridge.listAutocompleteRemoteDir) return [];
result = await bridge.listAutocompleteRemoteDir(
sessionId,
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
}
if (result.success) {
const timestamp = Date.now();
if (bypassCache) {
return result.entries;
}
if (normalizedPrefix) {
filteredDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE);
return result.entries;
}
fullDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(fullDirCache, MAX_CACHE_SIZE);
return result.entries;
}
return [];
} catch {
return [];
} finally {
if (!bypassCache) {
inFlightRequests.delete(requestKey);
}
}
})();
if (!bypassCache) {
inFlightRequests.set(requestKey, promise);
}
return promise;
}
function clampLimit(limit: number): number {
if (!Number.isFinite(limit)) return 100;
return Math.max(1, Math.min(200, Math.floor(limit)));
}
function resolveDirLookup(pathToken: string, cwd: string | undefined, preferRelativeCwd = false): string {
if (!pathToken) return preferRelativeCwd ? "." : (cwd || ".");
if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken);
if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken);
if (preferRelativeCwd) return normalizePosixLikePath(pathToken);
if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`);
return normalizePosixLikePath(pathToken);
}
export function shouldPreferRemoteShellCwd(
protocol: string | undefined,
sessionId: string | undefined,
os?: "linux" | "windows" | "macos",
cwd?: string,
cwdSource?: AutocompleteCwdSource,
): boolean {
if (cwdSource === "prompt" && cwd?.startsWith("/")) return false;
return Boolean(sessionId && protocol !== "local" && os === "linux");
}
function shouldBypassCache(
protocol: string | undefined,
sessionId: string | undefined,
os: "linux" | "windows" | "macos" | undefined,
dirPath: string,
): boolean {
if (!shouldPreferRemoteShellCwd(protocol, sessionId, os)) return false;
return !dirPath.startsWith("/") && dirPath !== "~" && !dirPath.startsWith("~/");
}
function normalizePosixLikePath(input: string): string {
if (!input) return ".";
const hasLeadingSlash = input.startsWith("/");
const hasTildeRoot = input === "~" || input.startsWith("~/");
const hasTrailingSlash = input.length > 1 && input.endsWith("/");
const fixedRootSegments = hasTildeRoot ? 1 : 0;
const raw = hasLeadingSlash
? input.slice(1)
: hasTildeRoot
? input.slice(2)
: input;
const segments = hasTildeRoot ? ["~"] : [];
for (const segment of raw.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (
segments.length > fixedRootSegments &&
segments[segments.length - 1] !== ".."
) {
segments.pop();
} else if (!hasLeadingSlash || hasTildeRoot) {
segments.push(segment);
}
continue;
}
segments.push(segment);
}
let result: string;
if (hasLeadingSlash) {
result = "/" + segments.join("/");
if (result === "/") return result;
} else if (segments.length > 0) {
result = segments.join("/");
} else if (hasTildeRoot) {
result = "~";
} else {
result = ".";
}
if (hasTrailingSlash && result !== "/" && result !== "." && result !== "~") {
result += "/";
} else if (hasTrailingSlash && result === "~") {
result = "~/";
}
return result;
}
function isFresh(
cached: { entries: DirEntry[]; timestamp: number } | undefined,
): cached is { entries: DirEntry[]; timestamp: number } {
return Boolean(cached && Date.now() - cached.timestamp < CACHE_TTL_MS);
}
function filterEntries(entries: DirEntry[], filterPrefix: string, limit: number): DirEntry[] {
if (!filterPrefix) return entries.slice(0, limit);
const filtered: DirEntry[] = [];
for (const entry of entries) {
if (entry.name.toLowerCase().startsWith(filterPrefix)) {
filtered.push(entry);
if (filtered.length >= limit) break;
}
}
return filtered;
}
function evictOldest(
cache: Map<string, { entries: DirEntry[]; timestamp: number }>,
maxSize: number,
): void {
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value;
if (!oldestKey) break;
cache.delete(oldestKey);
}
}
function decodeShellPathFragment(value: string): string {
let result = "";
let escaped = false;
for (const ch of value) {
if (escaped) {
result += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
result += ch;
}
if (escaped) result += "\\";
return result;
}
function getLeadingQuote(value: string): string {
return value.startsWith('"') || value.startsWith("'") ? value[0] : "";
}
function getTrailingMatchingQuote(value: string, quotePrefix: string): string {
return quotePrefix && value.endsWith(quotePrefix) ? quotePrefix : "";
}
function stripWrappingQuotes(value: string): string {
if (!value) return value;
let result = value;
if (result.startsWith('"') || result.startsWith("'")) {
result = result.slice(1);
}
if (result.endsWith('"') || result.endsWith("'")) {
result = result.slice(0, -1);
}
return result;
}
function sortPathEntries(entries: DirEntry[]): DirEntry[] {
return [...entries].sort((left, right) => {
const leftRank = left.type === "directory" ? 0 : left.type === "symlink" ? 1 : 2;
const rightRank = right.type === "directory" ? 0 : right.type === "symlink" ? 1 : 2;
if (leftRank !== rightRank) return leftRank - rightRank;
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
}

View File

@@ -0,0 +1,65 @@
/**
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
* when the user is typing the command name. Matches against the snippet label
* and the first line of its command (case-insensitive; prefix matches rank
* above substring matches). Chinese labels also match via pinyin / initials
* through the shared search matcher (#2813). Each suggestion carries the full
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
const SNIPPET_PREFIX_BONUS = 100;
function snippetAvailableForAutocomplete(
snippet: Snippet,
host: { hostId?: string; hostGroup?: string },
): boolean {
if (snippet.targetsAllHosts) return true;
const hasScopedTargets = Boolean(
snippet.targets?.length || snippet.targetGroups !== undefined,
);
if (!hasScopedTargets) return true;
if (!host.hostId) return false;
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
}
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
options: { hostId?: string; hostGroup?: string } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
const out: CompletionSuggestion[] = [];
for (const snippet of snippets) {
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
const label = (snippet.label || "").toLowerCase();
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
const labelPrefix = label.startsWith(needle);
// Literal prefix/substring first (cheap); fall back to shared smart matcher
// so Chinese titles surface for pinyin / initials the same way host search does.
const matches = labelPrefix
|| label.includes(needle)
|| firstLine.startsWith(needle)
|| matchesSearchQuery(needle, snippet.label, firstLine);
if (!matches) continue;
out.push({
text: snippet.label,
displayText: snippet.label,
description: snippet.command,
source: "snippet",
score: SNIPPET_BASE_SCORE + (labelPrefix ? SNIPPET_PREFIX_BONUS : 0),
snippet,
});
}
out.sort((a, b) => b.score - a.score);
return out;
}

View File

@@ -0,0 +1,278 @@
import type { MutableRefObject, RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { GhostTextAddon } from "./GhostTextAddon";
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
import { getAlignedPrompt } from "./promptDetector";
import { recordCommand } from "./commandHistoryStore";
import { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
interface TerminalAutocompleteInputContext {
settingsRef: MutableRefObject<AutocompleteSettings>;
lastKeystrokeRef: MutableRefObject<number>;
suppressNextEnterRecordRef: MutableRefObject<boolean>;
lastAcceptedCommandRef: MutableRefObject<string | null>;
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewBaselineRef: MutableRefObject<string>;
previewActiveRef: MutableRefObject<boolean>;
termRef: RefObject<XTerm | null>;
hostIdRef: MutableRefObject<string>;
hostOsRef: MutableRefObject<"linux" | "windows" | "macos">;
ghostAddonRef: MutableRefObject<GhostTextAddon | null>;
debounceTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>;
clearState: () => void;
syncPopupToInput: (input: string | null) => void;
fetchSuggestions: () => void | Promise<void>;
}
export function handleTerminalAutocompleteInput(
data: string,
context: TerminalAutocompleteInputContext,
): void {
const {
settingsRef,
lastKeystrokeRef,
suppressNextEnterRecordRef,
lastAcceptedCommandRef,
typedInputBufferRef,
typedBufferReliableRef,
previewBaselineRef,
previewActiveRef,
termRef,
hostIdRef,
hostOsRef,
ghostAddonRef,
debounceTimerRef,
clearState,
syncPopupToInput,
fetchSuggestions,
} = context;
if (!settingsRef.current.enabled) {
return;
}
const now = Date.now();
const timeSinceLastKeystroke = now - lastKeystrokeRef.current;
lastKeystrokeRef.current = now;
// Command recording: Enter key
if (data === "\r" || data === "\n") {
// Skip recording if selectAndExecute already recorded this command
if (suppressNextEnterRecordRef.current) {
suppressNextEnterRecordRef.current = false;
} else {
// If user accepted a completion (Tab/→) and immediately pressed Enter,
// the buffer may not reflect the accepted text yet. Use the tracked value.
if (lastAcceptedCommandRef.current) {
recordCommand(lastAcceptedCommandRef.current, hostIdRef.current, hostOsRef.current);
} else {
// Require a live prompt before trusting either keystroke buffer
// or buffer-based detection — otherwise sudo password Enter
// would record the typed password as a command.
const typedBuffer = typedInputBufferRef.current;
const typedBufferReliable = typedBufferReliableRef.current;
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
termRef.current,
typedBuffer,
typedBufferReliable,
);
const commandToRecord = getCommandToRecordOnEnter(
livePrompt,
alignedTyped,
typedBuffer,
typedBufferReliable,
);
if (commandToRecord) {
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
}
}
lastAcceptedCommandRef.current = null;
}
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
clearState();
return;
}
// Ctrl+C, Ctrl+U — clear. These kill the zle line entirely, so the
// buffer is once again a true reflection of the (empty) line.
if (data === "\x03" || data === "\x15") {
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
// Same rationale as the ctrl/escape early returns below: any
// previously-accepted suggestion is gone from the line too, so
// accept → Ctrl-C → type "foo" → Enter must not log the stale
// accepted command via the Enter fast path.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// Backspace / DEL: drop the last typed char so the buffer stays aligned
// with what the shell actually holds.
if (data === "\x7f" || data === "\b") {
typedInputBufferRef.current = typedInputBufferRef.current.slice(0, -1);
} else if (data === "\x17") {
// Ctrl+W: word-erase — kill the trailing whitespace + word.
typedInputBufferRef.current = typedInputBufferRef.current.replace(/\s*\S+\s*$/, "");
} else if (data.startsWith("\x1b[200~")) {
// Bracketed paste: "\x1b[200~...\x1b[201~". The inner bytes are
// literal input, so newlines stay on the zle line instead of
// executing each segment — meaning we must preserve the whole
// content in the buffer, not just the post-final-newline tail
// (Codex #814 P2).
//
// Reliability is *inherited*, not reset: if the buffer was
// already aligned with the line (reliable=true), appending this
// paste keeps it aligned; if the buffer was unreliable (e.g.
// after ↑ recalled a history command so line ≠ buffer), the
// paste only extends the tail but the head is still whatever
// the shell had, so the buffer stays unreliable. Without this,
// a paste-after-recall flow would flip reliability back on and
// Enter would record just the pasted suffix as the command
// (Codex #814 P1 follow-up).
const endIdx = data.indexOf("\x1b[201~");
const content = endIdx >= 0
? data.slice("\x1b[200~".length, endIdx)
: data.slice("\x1b[200~".length);
typedInputBufferRef.current += content;
// Paste extends the line past whatever was accepted, so the
// Enter fast-path must not record the pre-paste accepted
// command — mirrors the non-bracketed paste branch below.
lastAcceptedCommandRef.current = null;
clearState();
return;
} else if (data.startsWith("\x1b") && data !== "\x1b") {
// Cursor-movement / function keys — we lose track of where the
// cursor sits relative to our append-only buffer. Mark the
// buffer unreliable and drop it; detectPrompt takes over until
// the next Enter / Ctrl-C / Ctrl-U.
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
typedInputBufferRef.current += data;
} else if (data.length > 1 && !data.startsWith("\x1b")) {
// Paste chunk. Any \r / \n inside executes the preceding text as
// a command in the shell, so keeping the pre-newline portion in
// our buffer would leave stale content that a later Enter could
// record (Codex #814 P2). Drop everything up to and including
// the last terminator and keep only the tail as new content.
// Intermediate executed lines aren't synthesized back into
// recordCommand here — the onCommandExecuted path in
// createXTermRuntime still captures them independently.
const lastCR = data.lastIndexOf("\r");
const lastLF = data.lastIndexOf("\n");
const nlIdx = Math.max(lastCR, lastLF);
if (nlIdx >= 0) {
typedInputBufferRef.current = data.slice(nlIdx + 1);
typedBufferReliableRef.current = true;
// The embedded newline flushed any previously-accepted
// suggestion too — clearing the cache here prevents the next
// Enter from falling into the lastAcceptedCommandRef fast path
// and recording that stale command.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
typedInputBufferRef.current += data;
} else if (data.length === 1 && data.charCodeAt(0) < 32) {
// Any other single control char (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F,
// Ctrl-R, Ctrl-P, Ctrl-N, ...) moves the cursor or swaps the
// line in ways this append-only buffer can't follow. Same story
// as escape sequences above — and hide the ghost too, so the
// unreliable-accept fallback doesn't pull a stale tail onto a
// recalled line (Codex #815 follow-up).
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
// Null the fast-path accepted-command cache: accept-then-Ctrl-R
// should not let an old accepted command sneak back in via the
// Enter fast path after reverse-search picks a different one.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// Escape sequences (arrow keys, Home, End, etc.): clear stale suggestions
// since cursor position may have changed, making current suggestions invalid.
// Up/Down/Right/Tab are handled by handleKeyEvent; other sequences land here.
if (data.startsWith("\x1b") && data !== "\x1b") {
// Same fast-path reset as the single-byte ctrl-char branch above —
// accept-then-↑/↓ must not record the stale accepted command if
// the user then presses Enter on a different recalled line.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// User is typing more — invalidate accepted command fallback since the
// command is being edited further (e.g., accepted "git status" then added " --short")
lastAcceptedCommandRef.current = null;
// The previewed candidate is now edited, so the line is the user's own
// text. Drop preview-active so Escape dismisses the popup without
// reverting these edits back to the stale baseline (#1005).
previewActiveRef.current = false;
if (typedBufferReliableRef.current) {
previewBaselineRef.current = typedInputBufferRef.current;
}
// The popup must follow the edited line immediately, before the debounced
// provider refresh runs. Reconcile stale history rows against the current
// input; an unreliable append-only buffer cannot validate any old row.
if (settingsRef.current.showPopupMenu) {
const currentInput = typedBufferReliableRef.current
? typedInputBufferRef.current
: null;
syncPopupToInput(
currentInput !== null && currentInput.length >= settingsRef.current.minChars
? currentInput
: null,
);
}
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
// show() time; a fast "type + press →" sequence then pastes the
// pre-update tail on top of the new input ("doc" + "cker ls" →
// "doccker ls"). Skip when the user has turned showGhostText off
// mid-session: otherwise a ghost that was active before the toggle
// would keep moving around under a setting the user just said to
// disable (Codex #815 P2).
//
// Reliable buffer: feed adjustToInput the full post-mutation buffer
// so multi-char pastes refresh the ghost as one batch. Unreliable
// buffer (post Tab / cursor-move / history recall): the buffer
// is just the suffix typed since unreliability began, so feeding
// it to adjustToInput would fail the prefix invariant and hide
// the ghost. Instead let the addon evolve its own currentInput
// off the keystroke directly (issue #906) — that input was seeded
// by the last show() with the live xterm reading, which is the
// only post-Tab source-of-truth we have.
if (settingsRef.current.showGhostText) {
if (typedBufferReliableRef.current) {
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
} else {
ghostAddonRef.current?.applyKeystroke(data);
}
}
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle
const isFastTyping = timeSinceLastKeystroke < settingsRef.current.fastTypingThresholdMs;
// Debounced suggestion fetch
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (isFastTyping) {
// Still debounce, but with a longer delay to wait for typing to pause
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void fetchSuggestions();
}, settingsRef.current.debounceMs * 3);
} else {
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void fetchSuggestions();
}, settingsRef.current.debounceMs);
}
}

View File

@@ -0,0 +1,387 @@
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type { GhostTextAddon } from "./GhostTextAddon";
import type { AutocompleteSettings, AutocompleteState, SubDirEntry } from "./useTerminalAutocomplete";
import type { Snippet } from "../../../domain/models";
interface TerminalAutocompleteKeyEventContext {
settingsRef: MutableRefObject<AutocompleteSettings>;
stateRef: MutableRefObject<AutocompleteState>;
ghostAddonRef: MutableRefObject<GhostTextAddon | null>;
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewActiveRef: MutableRefObject<boolean>;
lastAcceptedCommandRef: MutableRefObject<string | null>;
setState: Dispatch<SetStateAction<AutocompleteState>>;
expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void;
writeToTerminal: (text: string) => void;
clearState: () => void;
renderSubDirPath: (level: number, entry: SubDirEntry) => void;
handleSubDirSelect: (level: number, entry: SubDirEntry) => void;
fetchSubDirForIndex: (index: number) => void;
renderPreviewSelection: (index: number) => void;
acceptPreviewlessSelection: (index: number) => boolean;
acceptSnippet: (snippet: Snippet) => boolean;
/** Deadline (ms) until which `.` / `_` are treated as readline Meta follow-ups. */
escMetaPrefixUntilRef: MutableRefObject<number>;
now?: () => number;
}
/** Readline keyseq-timeout default; Esc then . within this window is M-. */
export const AUTOCOMPLETE_ESC_META_TIMEOUT_MS = 500;
export function autocompleteEscMetaFollowUpSequence(e: {
key: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}): string | null {
if (e.altKey || e.ctrlKey || e.metaKey) return null;
// `_` is Shift+Minus on a standard keyboard; Shift+. is `>` and must not yank.
if (e.key === "." && !e.shiftKey) return "\x1b.";
if (e.key === "_") return "\x1b_";
return null;
}
const isAutocompleteConfirmEnter = (
e: KeyboardEvent,
settings: AutocompleteSettings,
): boolean => (
e.key === "Enter" &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
(!e.shiftKey || settings.shiftEnterNewlineEnabled === false)
);
export function handleTerminalAutocompleteKeyEvent(
e: KeyboardEvent,
context: TerminalAutocompleteKeyEventContext,
): boolean {
const {
settingsRef,
stateRef,
ghostAddonRef,
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
lastAcceptedCommandRef,
setState,
expandSubDir,
writeToTerminal,
clearState,
renderSubDirPath,
handleSubDirSelect,
fetchSubDirForIndex,
renderPreviewSelection,
acceptPreviewlessSelection,
acceptSnippet,
escMetaPrefixUntilRef,
now = Date.now,
} = context;
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
const metaFollowUp = autocompleteEscMetaFollowUpSequence(e);
if (metaFollowUp && now() < escMetaPrefixUntilRef.current) {
escMetaPrefixUntilRef.current = 0;
e.preventDefault();
writeToTerminal(metaFollowUp);
// Match handleTerminalAutocompleteInput's ESC-sequence path: yank-last-arg
// rewrites the shell line, so the append-only typed buffer is stale.
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
lastAcceptedCommandRef.current = null;
return false;
}
if (e.key !== "Escape" && e.key !== "Shift" && e.key !== "Control" && e.key !== "Alt" && e.key !== "Meta") {
escMetaPrefixUntilRef.current = 0;
}
const s = stateRef.current;
const ghost = ghostAddonRef.current;
// Right arrow: if popup has selected directory with sub-dir panel, enter it
// Skip this handler entirely when sub-dir panels are focused — let the
// sub-panel navigation block handle → for deeper expansion.
if (e.key === "ArrowRight" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.selectedIndex >= 0 && s.subDirPanels.length > 0) {
const selected = s.suggestions[s.selectedIndex];
if (selected?.fileType === "directory") {
e.preventDefault();
const firstEntry = s.subDirPanels[0]?.entries[0];
setState((prev) => {
const panels = [...prev.subDirPanels];
if (panels[0]) panels[0] = { ...panels[0], selectedIndex: 0 };
return { ...prev, subDirPanels: panels, subDirFocusLevel: 0 };
});
if (firstEntry?.type === "directory") {
expandSubDir(0, firstEntry, false);
}
return false;
}
}
// Otherwise: accept ghost text. Use isActive(), not isVisible(),
// so a fast "type + →" that lands in the hide-until-render gap
// still hits this branch and accepts the pending ghost.
if (ghost?.isActive()) {
e.preventDefault();
const fullSuggestion = ghost.getSuggestion();
// When the keystroke buffer is reliable, recompute the tail
// against the *live* buffer so a fast "type + →" in the
// hide-until-render gap still writes the correct tail. When
// it's not reliable (post history-recall / Ctrl-R), we can't
// treat empty buffer as "nothing typed" — the line actually
// has content we're not tracking — so fall back to the
// ghost's own cached tail instead of writing the entire
// suggestion onto an already-populated line.
let ghostText: string;
let newBuffer: string | null;
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion && fullSuggestion.startsWith(live)) {
ghostText = fullSuggestion.substring(live.length);
newBuffer = fullSuggestion;
} else {
ghostText = "";
newBuffer = null;
}
} else {
ghostText = ghost.getGhostText();
newBuffer = null; // buffer is unreliable; don't flip it back on
}
if (ghostText) {
writeToTerminal(ghostText);
lastAcceptedCommandRef.current = fullSuggestion;
if (newBuffer !== null) {
typedInputBufferRef.current = newBuffer;
typedBufferReliableRef.current = true;
}
ghost.hide();
clearState();
} else {
ghost.hide();
}
return false;
}
}
// Ctrl+Right / Alt+Right (Mac): accept next word
if (e.key === "ArrowRight" && (e.ctrlKey || e.altKey) && !e.metaKey && !e.shiftKey) {
if (ghost?.isActive()) {
e.preventDefault();
const fullSuggestion = ghost.getSuggestion();
if (!fullSuggestion) {
ghost.hide();
return false;
}
// Determine the baseline the next word should extend. Reliable
// buffer: resync the ghost to the live buffer so getNextWord
// operates on the up-to-date tail. Unreliable buffer (post
// history-recall / Ctrl-R): don't reanchor to "" — that would
// make getNextWord hand back the very first word and the shell
// would duplicate leading tokens on top of the recalled line.
// Fall back to the ghost's existing cached input instead.
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion.startsWith(live)) {
ghost.show(fullSuggestion, live);
} else {
ghost.hide();
return false;
}
}
const base = ghost.getGhostText().length > 0
? fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length)
: fullSuggestion;
const nextWord = ghost.getNextWord();
if (nextWord) {
writeToTerminal(nextWord);
// Only extend the buffer if it was already aligned with the
// line — otherwise we'd end up with just the appended word,
// which the next Enter would then record as the command.
if (typedBufferReliableRef.current) {
typedInputBufferRef.current += nextWord;
}
// Shrink the ghost to reflect what's left after the accept.
const newInput = base + nextWord;
if (fullSuggestion.startsWith(newInput) && fullSuggestion.length > newInput.length) {
ghost.show(fullSuggestion, newInput);
} else {
ghost.hide();
}
}
return false;
}
}
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
// #1005: don't intercept Tab. Keep whatever is currently rendered on
// the line and let Tab reach the shell for native completion.
clearState();
previewActiveRef.current = false;
return true;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
if (ghost?.isActive()) {
ghost.hide();
}
}
// Up/Down/Left/Right: navigate popup + sub-dir panel
if (s.popupVisible && s.suggestions.length > 0) {
const focusLevel = s.subDirFocusLevel;
const focusedPanel = focusLevel >= 0 ? s.subDirPanels[focusLevel] : null;
// Sub-dir panel focused: ↑↓ navigate, ← go back, → go deeper
if (focusLevel >= 0 && focusedPanel) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const newIdx = e.key === "ArrowUp"
? (focusedPanel.selectedIndex <= 0 ? focusedPanel.entries.length - 1 : focusedPanel.selectedIndex - 1)
: (focusedPanel.selectedIndex >= focusedPanel.entries.length - 1 ? 0 : focusedPanel.selectedIndex + 1);
setState((prev) => {
const panels = [...prev.subDirPanels];
const p = panels[focusLevel];
if (!p) return prev;
panels[focusLevel] = { ...p, selectedIndex: newIdx };
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
});
// Live-render the highlighted entry's full path into the line (#1005).
const newEntry = focusedPanel.entries[newIdx];
if (newEntry && settingsRef.current.livePreview) renderSubDirPath(focusLevel, newEntry);
// Auto-expand next level if the newly selected item is a directory
if (newEntry?.type === "directory") {
expandSubDir(focusLevel, newEntry);
}
return false;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel + 1),
subDirFocusLevel: focusLevel - 1,
}));
return false;
}
if (e.key === "ArrowRight") {
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
if (entry?.type === "directory") {
e.preventDefault();
expandSubDir(focusLevel, entry, true); // moveFocus = true
return false;
}
}
if (isAutocompleteConfirmEnter(e, settingsRef.current) || e.key === "Tab") {
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
if (entry && focusedPanel.selectedIndex >= 0) {
e.preventDefault();
handleSubDirSelect(focusLevel, entry);
return false;
}
}
if (e.key === "Escape") {
e.preventDefault();
if (focusLevel > 0) {
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
subDirFocusLevel: focusLevel - 1,
}));
} else {
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
}
return false;
}
if (
e.key.length === 1 ||
e.key === "Backspace" ||
e.key === "Delete" ||
e.key === "Home" ||
e.key === "End"
) {
clearState();
}
return true;
}
// Main panel navigation. The cycle includes a -1 "no selection" slot so
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
// the selection live-renders the candidate into the command line (#1005).
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const n = s.suggestions.length;
const cur = s.selectedIndex;
const next =
e.key === "ArrowDown"
? (cur >= n - 1 ? -1 : cur + 1)
: (cur <= -1 ? n - 1 : cur - 1);
setState((prev) => ({
...prev,
selectedIndex: next,
subDirPanels: [], subDirFocusLevel: -1,
}));
if (settingsRef.current.livePreview) renderPreviewSelection(next);
if (next >= 0) fetchSubDirForIndex(next);
return false;
}
// Enter on popup. The selected candidate is already rendered into the
// line by live-preview, so let Enter reach the shell. Don't record here:
// handleInput's Enter path records the *actual* line — it uses
// lastAcceptedCommandRef (set on select) but falls back to the live
// buffer when the user edited the previewed command (typing nulls that
// ref), so recording stays accurate in both cases.
if (isAutocompleteConfirmEnter(e, settingsRef.current)) {
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
if (selected?.source === "snippet" && selected.snippet) {
if (!acceptSnippet(selected.snippet)) {
clearState();
previewActiveRef.current = false;
return true;
}
e.preventDefault();
previewActiveRef.current = false;
return false; // consume — run the snippet, not the typed text
}
if (!settingsRef.current.livePreview && selected) {
if (acceptPreviewlessSelection(s.selectedIndex)) {
e.preventDefault();
previewActiveRef.current = false;
return false;
}
clearState();
previewActiveRef.current = false;
return true;
}
clearState();
previewActiveRef.current = false;
return true;
}
}
// Escape: close popup and hide ghost text.
// Only consume Escape if popup is visible; don't block Escape for vi-mode shells
// when only ghost text is showing (ghost text is passive/non-intrusive).
// After dismissing the popup, arm a short Meta prefix so Esc+. / Esc+_ still
// reach readline yank-last-arg (issue #2364) without entering vi-cmd mode.
if (e.key === "Escape" && s.popupVisible) {
e.preventDefault();
if (previewActiveRef.current) {
renderPreviewSelection(-1); // restore the typed baseline
}
ghost?.hide();
clearState();
previewActiveRef.current = false;
escMetaPrefixUntilRef.current = now() + AUTOCOMPLETE_ESC_META_TIMEOUT_MS;
return false;
}
return true;
}

View File

@@ -0,0 +1,610 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type { CompletionSuggestion } from "./completionEngine";
import type { PromptDetectionResult } from "./promptDetector";
import type { SubDirPanel } from "./useTerminalAutocomplete";
import { stringCellWidth } from "./terminalStringCellWidth";
import { getXTermCellDimensions } from "./xtermUtils";
export function resolveAutocompleteCwd(
promptText: string,
currentWord: string,
fallbackCwd: string | undefined,
os: "linux" | "windows" | "macos",
): string | undefined {
return resolveAutocompleteCwdWithSource(promptText, currentWord, fallbackCwd, os).cwd;
}
export type AutocompleteCwdSource = "prompt" | "fallback" | "none";
export function resolveAutocompleteCwdWithSource(
promptText: string,
currentWord: string,
fallbackCwd: string | undefined,
os: "linux" | "windows" | "macos",
): { cwd: string | undefined; source: AutocompleteCwdSource } {
if (os === "windows") return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
const normalizedWord = currentWord.trim().replace(/^['"]/, "");
// Absolute or home-relative paths don't depend on cwd
if (normalizedWord.startsWith("/") || normalizedWord.startsWith("~/")) {
return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
}
// For empty word (e.g. "cd ") and relative paths, try prompt-based cwd
// extraction which reflects the current visible prompt — more up-to-date
// than fallbackCwd when OSC 7 is not supported.
const promptCwd = extractPosixCwdFromPrompt(promptText);
return chooseAutocompleteCwdWithSource(promptCwd, fallbackCwd);
}
function chooseAutocompleteCwdWithSource(
promptCwd: string | undefined,
fallbackCwd: string | undefined,
): { cwd: string | undefined; source: AutocompleteCwdSource } {
if (!promptCwd) return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
if (!fallbackCwd) return { cwd: promptCwd, source: "prompt" };
// Prompt cwd is extracted from the currently visible prompt, so it tracks
// directory changes even when OSC 7 is not supported. Prefer it over
// fallbackCwd (which may be stale from initial connection) whenever it
// looks like a usable path.
if (promptCwd.startsWith("/") || promptCwd === "~" || promptCwd.startsWith("~/")) {
return { cwd: promptCwd, source: "prompt" };
}
// Bare directory name (e.g. "xunlong") can't be used as a path — fallback
return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
}
function extractPosixCwdFromPrompt(promptText: string): string | undefined {
const trimmed = promptText.trimEnd().replace(/[#$%>]\s*$/, "");
if (!trimmed) return undefined;
const patterns = [
/:(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/,
/\s(\/[^\s\]]*|~(?:\/[^\s\]]*)?)\]$/,
/(^|[\s:])(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/,
];
for (const pattern of patterns) {
const match = trimmed.match(pattern);
if (!match) continue;
const candidate = match[match.length - 1];
if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) {
return candidate;
}
}
const fallbackTokens = trimmed
.split(/\s+/)
.map((token) => token.replace(/^[([{:]+/, "").replace(/[\])}:]+$/, ""));
for (let index = fallbackTokens.length - 1; index >= 0; index--) {
const candidate = fallbackTokens[index];
if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) {
return candidate;
}
}
return undefined;
}
export function areSuggestionsEqual(
left: CompletionSuggestion[],
right: CompletionSuggestion[],
): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
const a = left[i];
const b = right[i];
if (
a.text !== b.text ||
a.displayText !== b.displayText ||
a.description !== b.description ||
a.source !== b.source ||
a.score !== b.score ||
a.frequency !== b.frequency ||
a.fileType !== b.fileType
) {
return false;
}
}
return true;
}
/**
* Keep a popup highlight across a same-query list refresh (e.g. late path
* suggestions). Match the previously selected row by stable identity; if a
* late path replaces a same-text history/plugin entry, fall back to text.
*/
export function resolvePreservedSuggestionIndex(
previousSuggestions: CompletionSuggestion[],
previousSelectedIndex: number,
nextSuggestions: CompletionSuggestion[],
): number {
if (previousSelectedIndex < 0 || previousSelectedIndex >= previousSuggestions.length) {
return -1;
}
const selected = previousSuggestions[previousSelectedIndex];
if (!selected) return -1;
const exactIndex = nextSuggestions.findIndex(
(candidate) =>
candidate.text === selected.text &&
candidate.source === selected.source &&
candidate.displayText === selected.displayText &&
candidate.fileType === selected.fileType,
);
if (exactIndex >= 0) return exactIndex;
return nextSuggestions.findIndex((candidate) => candidate.text === selected.text);
}
export function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
const a = left[i];
const b = right[i];
if (a.dirPath !== b.dirPath || a.selectedIndex !== b.selectedIndex) return false;
if (a.entries.length !== b.entries.length) return false;
for (let j = 0; j < a.entries.length; j++) {
if (a.entries[j].name !== b.entries[j].name || a.entries[j].type !== b.entries[j].type) {
return false;
}
}
}
return true;
}
export interface PopupClampViewport {
left: number;
top: number;
width: number;
height: number;
}
export interface PopupPlacementInput {
/** Anchor (current input line) top edge, in viewport coordinates. */
anchorTop: number;
/** Anchor (current input line) bottom edge, in viewport coordinates. */
anchorBottom: number;
/** Desired left edge (cursor column), in viewport coordinates. */
anchorLeft: number;
viewportWidth: number;
viewportHeight: number;
/**
* Optional clamp region in viewport coordinates. Defaults to the rectangle
* `(0, 0, viewportWidth, viewportHeight)`.
*/
clampViewport?: PopupClampViewport;
/** Natural height the popup wants if unconstrained (main list or detail). */
desiredHeight: number;
/**
* Total horizontal extent of the popup including any cascading sub-directory
* panels and the detail tooltip — used so the whole assembly is clamped
* inside the viewport, not just the main list.
*/
totalWidth: number;
/**
* Width budget for horizontal clamping. Defaults to `totalWidth`. The detail
* tooltip is rendered beside the list and can extend left on its own, so
* callers may pass a smaller width to keep the primary list near the cursor.
*/
clampWidth?: number;
/** Hard cap on rendered height (matches the list's maxHeight prop). */
maxHeight: number;
/** Gap between the anchor line and the popup. */
anchorGap: number;
/** Minimum distance to keep from the viewport edges. */
viewportPadding: number;
/**
* Direction hint from the cursor-cell based calculation. Only used to break
* ties when neither side can fully fit the desired height.
*/
expandUpwardHint: boolean;
/**
* When true, keep rendering above the supplied anchor even if there is a
* full fit below. Used after a wrap pins the anchor to the command start
* so placement cannot flip down over the continuation rows (#3061).
*/
forceExpandUpward?: boolean;
}
export interface PopupPlacement {
/** Whether the popup renders above the anchor line (flipped up). */
renderUpward: boolean;
/** Final top edge, in viewport coordinates (already clamped). */
top: number;
/** Final left edge, in viewport coordinates (already clamped). */
left: number;
/** Height budget for the rendered content (drives scrolling). */
maxHeight: number;
}
export interface PopupGeometryClampInput {
left: number;
top: number;
width: number;
height: number;
clampViewport: PopupClampViewport;
viewportPadding: number;
}
export interface PopupGeometry {
top: number;
left: number;
}
function clampCoordinate(value: number, min: number, max: number): number {
if (max <= min) return min;
return Math.max(min, Math.min(value, max));
}
/**
* Final guardrail using the rendered popup's actual DOM size. The placement
* pass uses estimated list/detail/panel sizes so it can decide before render;
* this pass prevents any estimate mismatch or delayed xterm cursor refresh
* from letting the fixed-position portal escape the terminal/app bounds.
*/
export function clampAutocompletePopupGeometry(
input: PopupGeometryClampInput,
): PopupGeometry {
const { left, top, width, height, clampViewport, viewportPadding } = input;
const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0;
const safeHeight = Number.isFinite(height) ? Math.max(0, height) : 0;
const minLeft = clampViewport.left + viewportPadding;
const minTop = clampViewport.top + viewportPadding;
const maxLeft = clampViewport.left + clampViewport.width - viewportPadding - safeWidth;
const maxTop = clampViewport.top + clampViewport.height - viewportPadding - safeHeight;
return {
left: clampCoordinate(left, minLeft, Math.max(minLeft, maxLeft)),
top: clampCoordinate(top, minTop, Math.max(minTop, maxTop)),
};
}
/**
* Decide where to place the autocomplete popup so it never spills past the
* viewport edges. Pure and deterministic so the boundary math is unit-tested
* independently of React/DOM.
*
* Vertical: prefer downward, but flip upward when the space below the input
* line can't fit the desired height and the space above is a better fit. The
* height is then clamped to whatever the chosen side actually offers so the
* list scrolls instead of overflowing.
*
* Horizontal: clamp the left edge using the popup's *total* width (main list +
* cascading sub-dir panels + detail tooltip), not just the main list, so wide
* assemblies near the right edge slide left instead of overflowing. When the
* assembly is wider than the viewport it pins to the left padding so the
* primary list stays visible.
*/
export function computeAutocompletePopupPlacement(
input: PopupPlacementInput,
): PopupPlacement {
const {
anchorTop,
anchorBottom,
anchorLeft,
viewportWidth,
viewportHeight,
desiredHeight,
totalWidth,
maxHeight,
anchorGap,
viewportPadding,
expandUpwardHint,
forceExpandUpward = false,
clampViewport,
clampWidth,
} = input;
const bounds: PopupClampViewport = clampViewport ?? {
left: 0,
top: 0,
width: viewportWidth,
height: viewportHeight,
};
const boundsRight = bounds.left + bounds.width;
const boundsBottom = bounds.top + bounds.height;
const horizontalClampWidth = clampWidth ?? totalWidth;
const cappedDesiredHeight = Math.min(maxHeight, Math.max(0, desiredHeight));
const spaceAbove = Math.max(0, anchorTop - bounds.top - viewportPadding - anchorGap);
const spaceBelow = Math.max(0, boundsBottom - anchorBottom - viewportPadding - anchorGap);
const canFullyRenderAbove = spaceAbove >= cappedDesiredHeight;
const canFullyRenderBelow = spaceBelow >= cappedDesiredHeight;
const renderUpward = forceExpandUpward && spaceAbove > 0
? true
: canFullyRenderBelow
? false
: canFullyRenderAbove
? true
: expandUpwardHint
? spaceAbove >= Math.min(spaceBelow, 80)
: spaceAbove > spaceBelow;
const availableVerticalSpace = renderUpward ? spaceAbove : spaceBelow;
const availableViewportHeight = Math.max(0, bounds.height - viewportPadding * 2);
const effectiveMaxHeight = Math.max(
0,
Math.min(maxHeight, availableVerticalSpace, availableViewportHeight),
);
const contentHeightForPlacement = Math.min(effectiveMaxHeight, cappedDesiredHeight);
const unclampedTop = renderUpward
? Math.max(bounds.top + viewportPadding, anchorTop - anchorGap - contentHeightForPlacement)
: Math.min(
anchorBottom + anchorGap,
boundsBottom - viewportPadding - contentHeightForPlacement,
);
const minTop = bounds.top + viewportPadding;
const maxTop = Math.max(minTop, boundsBottom - viewportPadding - contentHeightForPlacement);
const top = Math.max(minTop, Math.min(unclampedTop, maxTop));
// Right edge that keeps the clamped assembly inside the bounds. When the
// assembly is wider than the available room this goes below the left padding,
// so the final clamp pins the popup to the left padding (primary list wins).
const maxLeft = boundsRight - viewportPadding - Math.max(0, horizontalClampWidth);
const left = Math.max(bounds.left + viewportPadding, Math.min(anchorLeft, maxLeft));
return { renderUpward, top, left, maxHeight: effectiveMaxHeight };
}
export interface AutocompleteViewportAnchor {
anchorLeft: number;
anchorTop: number;
anchorBottom: number;
expandUpward: boolean;
}
const ESTIMATED_ROW_HEIGHT_PX = 28;
const POPUP_CHROME_PADDING_PX = 8;
function estimatePopupHeight(itemCount: number): number {
return itemCount * ESTIMATED_ROW_HEIGHT_PX + POPUP_CHROME_PADDING_PX;
}
function shouldExpandAutocompleteUpward(
cursorY: number,
spaceBelowPx: number,
spaceAbovePx: number,
estimatedPopupHeight: number,
): boolean {
if (spaceBelowPx >= estimatedPopupHeight) return false;
if (spaceAbovePx >= estimatedPopupHeight) return true;
return cursorY > 2 && spaceAbovePx >= spaceBelowPx;
}
/** Predicted cursor cell for popup anchoring (column within the row + row). */
export type AutocompleteCursorCell = {
column: number;
row: number;
};
function clampAutocompleteViewportRow(row: number, termRows: number): number {
if (Number.isFinite(termRows) && termRows > 0) {
return Math.max(0, Math.min(row, termRows - 1));
}
return Math.max(0, row);
}
/** Absolute buffer index of the first physical row of the current wrapped line. */
function resolveWrappedCommandStartAbsY(term: XTerm): number {
const buffer = term.buffer.active;
let startAbsY = buffer.cursorY + buffer.baseY;
let startLine = buffer.getLine(startAbsY);
while (startLine?.isWrapped && startAbsY > 0) {
startAbsY -= 1;
startLine = buffer.getLine(startAbsY);
}
return startAbsY;
}
/**
* Viewport row of the command start (prompt / first physical line). After a
* wrap at the bottom, xterm keeps the cursor on `term.rows - 1` and scrolls;
* this row moves up with the wrapped command so the popup cannot cover it.
*/
export function resolveAutocompleteCommandStartRow(term: XTerm): number {
const buffer = term.buffer.active;
const startAbsY = resolveWrappedCommandStartAbsY(term);
const viewportOrigin = Number.isFinite(buffer.viewportY) ? buffer.viewportY : buffer.baseY;
return clampAutocompleteViewportRow(startAbsY - viewportOrigin, Number(term.rows));
}
/**
* Best-effort cursor cell for popup anchoring. xterm's helper textarea and
* buffer.cursorX can lag behind the keystroke that triggered completion, so
* derive the column from the aligned prompt and wrap onto following rows when
* unechoed wide input crosses `term.cols`.
*
* When the live cursor already sits on a soft-wrapped continuation row,
* measure from the logical line start so a still-unechoed `userInput` suffix
* advances past the partial wrap instead of anchoring at the lagged cell.
*
* A wrap past the last visible row scrolls the buffer; xterm keeps the cursor
* on `term.rows - 1`. Clamp the predicted row so a completion that resolves
* before that scroll does not place the popup one cell below the grid.
*/
export function resolveAutocompleteCursorCell(
term: XTerm,
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
): AutocompleteCursorCell {
const buffer = term.buffer.active;
const cols = Math.max(1, Number(term.cols) || 80);
const termRows = Number(term.rows);
const absY = buffer.cursorY + buffer.baseY;
const startAbsY = resolveWrappedCommandStartAbsY(term);
const startRowY = startAbsY - buffer.baseY;
let fromLine = (buffer.cursorY - startRowY) * cols + buffer.cursorX;
const cursorLine = buffer.getLine(absY);
if (cursorLine) {
const lineText = cursorLine.translateToString(false);
const tail = lineText.substring(buffer.cursorX).trimEnd();
if (tail.length === 0) {
const endCol = Math.max(buffer.cursorX, lineText.trimEnd().length);
fromLine = (buffer.cursorY - startRowY) * cols + endCol;
}
}
// Use xterm's active Unicode width so CJK / emoji / fullwidth glyphs in
// the synthetic pre-echo userInput advance the popup with the same cell
// count as the real cursor (#2813).
const fromPrompt =
stringCellWidth(prompt.promptText, term) + stringCellWidth(prompt.userInput, term);
const rawColumn = Math.max(fromLine, fromPrompt);
const predictedRow = Math.max(0, startRowY + Math.floor(rawColumn / cols));
// Only clamp when the terminal reports a real viewport height; missing
// `rows` (tests/mocks) must not collapse every wrap onto row 0.
return {
column: rawColumn % cols,
row: clampAutocompleteViewportRow(predictedRow, termRows),
};
}
/** Column-only helper for callers that do not need the predicted wrap row. */
export function resolveAutocompleteCursorColumn(
term: XTerm,
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
): number {
return resolveAutocompleteCursorCell(term, prompt).column;
}
/** Clamp autocomplete popups to the active terminal screen in split workspaces.
*
* Uses the visible `.xterm-screen` rect as the clamp boundary so the popup
* never overflows the *actual* rendered terminal grid. The `.xterm-container`
* can be a few pixels taller than the screen (rounding/padding), so falling
* back to its rect produced a false positive `spaceBelow` at the bottom row
* and caused short suggestion lists to flip downward below the visible area
* (see issue #1710).
*/
export function resolveAutocompleteClampViewport(container: HTMLElement | null): PopupClampViewport {
const pane = container?.closest<HTMLElement>('[data-section="terminal-split-pane"]');
const screen = container?.querySelector<HTMLElement>(".xterm-screen")
?? null;
// Clamp to the rendered screen so the popup cannot spill past the visible
// terminal rows. If the screen is not mounted yet, fall back to the split
// pane/container rect or the full viewport.
const rect = screen?.getBoundingClientRect()
?? pane?.getBoundingClientRect()
?? container?.getBoundingClientRect();
if (rect && rect.width > 0 && rect.height > 0) {
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
return {
left: 0,
top: 0,
width: typeof window !== "undefined" ? window.innerWidth : 1200,
height: typeof window !== "undefined" ? window.innerHeight : 800,
};
}
/**
* Resolve the autocomplete anchor in viewport coordinates so split panes and
* padded xterm screens stay aligned with the real cursor.
*
* When `commandStartRow` is above `cursorRow` (a wrapped command), an
* upward popup pins to the start row so it cannot cover the first physical
* line after a wrap-induced scroll (#3061). Downward popups still pin to
* the cursor row so they sit below the whole command.
*/
export function resolveAutocompleteAnchorInViewport(
term: XTerm,
container: HTMLElement | null,
itemCount: number,
cursorColumn = term.buffer.active.cursorX,
cursorRow = term.buffer.active.cursorY,
commandStartRow = cursorRow,
): AutocompleteViewportAnchor {
const empty: AutocompleteViewportAnchor = {
anchorLeft: 0,
anchorTop: 0,
anchorBottom: 0,
expandUpward: false,
};
if (!container || !term.element) return empty;
const rows = Math.max(1, term.rows);
const estimatedPopupHeight = estimatePopupHeight(itemCount);
const dims = getXTermCellDimensions(term);
const screen =
container.querySelector<HTMLElement>(".xterm-screen")
?? term.element.querySelector<HTMLElement>(".xterm-screen")
?? container;
const screenRect = screen.getBoundingClientRect();
const upwardRow = Math.min(commandStartRow, cursorRow);
const downwardRow = Math.max(commandStartRow, cursorRow);
const spaceBelow = Math.max(0, (rows - downwardRow - 1) * dims.height);
const spaceAbove = Math.max(0, upwardRow * dims.height);
const expandUpward = shouldExpandAutocompleteUpward(
downwardRow,
spaceBelow,
spaceAbove,
estimatedPopupHeight,
);
const anchorRow = expandUpward ? upwardRow : downwardRow;
const anchorLeft = screenRect.left + cursorColumn * dims.width;
const anchorTop = screenRect.top + anchorRow * dims.height;
const anchorBottom = screenRect.top + (anchorRow + 1) * dims.height;
return {
anchorLeft,
anchorTop,
anchorBottom,
expandUpward,
};
}
/** Popup viewport anchor using the live wrapped command-start row (#3061). */
export function resolveAutocompletePopupAnchorInViewport(
term: XTerm,
container: HTMLElement | null,
itemCount: number,
cursorColumn: number,
cursorRow: number,
): AutocompleteViewportAnchor {
return resolveAutocompleteAnchorInViewport(
term,
container,
itemCount,
cursorColumn,
cursorRow,
resolveAutocompleteCommandStartRow(term),
);
}
/**
* Next stored popup viewport when the command-start / cursor anchor moves.
* Returns `prev` when nothing changed so callers can skip a React update.
*/
export function nextAutocompletePopupAnchorViewport(
prev: { left: number; top: number; bottom: number },
expandUpward: boolean,
anchor: AutocompleteViewportAnchor,
): { viewport: { left: number; top: number; bottom: number }; expandUpward: boolean } | null {
const viewport = {
left: anchor.anchorLeft,
top: anchor.anchorTop,
bottom: anchor.anchorBottom,
};
if (
prev.left === viewport.left
&& prev.top === viewport.top
&& prev.bottom === viewport.bottom
&& expandUpward === anchor.expandUpward
) {
return null;
}
return { viewport, expandUpward: anchor.expandUpward };
}

View File

@@ -0,0 +1,211 @@
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import {
isNonPromptLine,
reconcilePromptWithExternalCommand,
type PromptDetectionResult,
} from "./promptDetector";
import { computeLivePreviewWrite } from "./livePreviewSequence";
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
function hasStandardShellPromptTerminator(promptText: string): boolean {
return /[$#%>]$/.test(promptText.trimEnd());
}
function isSingleThemedPromptTerminator(promptText: string): boolean {
const trimmed = promptText.trim();
if (trimmed.length !== 1) return false;
const code = trimmed.charCodeAt(0);
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
}
function isThemedPromptPathToken(token: string): boolean {
return (
token === "~" ||
token.startsWith("~/") ||
token.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(token) ||
token.includes("\\")
);
}
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
const hasThemedPromptMarker =
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
Array.from(prompt.promptText).some((ch) => {
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
});
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
return false;
}
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
return (
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
/\S+\s+\S/.test(prompt.userInput)
);
}
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
}
/**
* Command-line text used for autocomplete matching (popup / ghost).
*
* Enter recording keeps a stricter echo-alignment policy so short lagging
* prefixes are not committed as history. Autocomplete can safely prefer the
* reliable keystroke buffer when it is ahead of the remote shell echo —
* otherwise high-latency SSH drops local history/fig matches until the user
* pauses and the echo catches up (#2830).
*/
export function resolveAutocompleteQueryInput(
prompt: PromptDetectionResult,
typedBuffer: string,
typedBufferReliable: boolean,
): string | null {
if (!prompt.isAtPrompt) return null;
// Prefer the keystroke buffer when it is reliably aligned with the remote
// echo as a shared prefix in either direction:
// - buffer ahead of echo (typing faster than SSH echo)
// - echo ahead of buffer (partial/full backspace while echo still lags)
// Without the second case, a lagging echo of deleted characters would keep
// driving completions/accept (e.g. typed `gi` + echo `git` → accept
// ` status` → remote `gi status`). An unreliable empty buffer is different:
// history recall / cursor moves clear the buffer without meaning the line
// is empty, so fall through to prompt.userInput there.
if (
typedBufferReliable &&
(typedBuffer.startsWith(prompt.userInput) ||
prompt.userInput.startsWith(typedBuffer))
) {
return typedBuffer;
}
return prompt.userInput;
}
/**
* Whether an in-flight completion result still belongs to the active query.
*
* Live preview rewrites the typed buffer to the highlighted candidate, so a
* naive `currentInput === queryInput` check would drop late path listings
* while a preview row remains selected.
*/
export function isSameAutocompleteQuery(options: {
queryInput: string;
currentInput: string | null;
previewActive: boolean;
previewBaseline: string;
}): boolean {
if (options.currentInput === null) return false;
if (options.currentInput === options.queryInput) return true;
return options.previewActive && options.previewBaseline === options.queryInput;
}
/**
* Whether fetchSuggestions must refuse to query/render for an already-known
* sensitive line (host latch or auth-challenge prompt text).
*
* This is *not* a substitute for the empty-echo / `allowExternalProviders:
* false` wait in useTerminalAutocomplete: `read -s -p '$ '` still looks like
* a normal shell PS1 until echo validates, so that path stays fail-closed
* separately (#2814).
*/
export function shouldBlockAutocompleteForSensitivePrompt(options: {
sensitiveInputActive: boolean;
promptText: string;
}): boolean {
if (options.sensitiveInputActive) return true;
return isSensitiveTerminalChallenge(options.promptText);
}
/**
* Keystrokes that rewrite the remote command line to `candidate`.
*
* Must use the same echo-lag-aware baseline as suggestion matching: the remote
* shell already has the typed buffer, even when local echo still shows a short
* prefix. Using lagging `prompt.userInput` here would append a duplicate tail
* (e.g. typed `systemctl` + echo `s` + accept → send `ystemctl …`).
*/
export function computeAutocompleteAcceptWrite(options: {
prompt: PromptDetectionResult;
typedBuffer: string;
typedBufferReliable: boolean;
candidate: string;
os: string;
execute?: boolean;
allowLineReplacement?: boolean;
}): string | null {
const currentLine = resolveAutocompleteQueryInput(
options.prompt,
options.typedBuffer,
options.typedBufferReliable,
);
if (currentLine === null) return null;
const allowLineReplacement = options.allowLineReplacement !== false;
if (
!options.candidate.startsWith(currentLine) &&
!allowLineReplacement
) {
return null;
}
const body = computeLivePreviewWrite({
currentLine,
candidate: options.candidate,
os: options.os,
promptText: options.prompt.promptText,
});
if (!options.execute) return body;
return body ? `${body}\r` : "\r";
}
export function getCommandToRecordOnEnter(
livePrompt: PromptDetectionResult,
alignedTyped: string | null,
typedBuffer: string,
typedBufferReliable: boolean,
): string | null {
if (!livePrompt.isAtPrompt) return null;
const alignedCommand = alignedTyped?.trim();
if (alignedCommand) return alignedCommand;
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
if (reliableTypedCommand) {
const reconciledPrompt = reconcilePromptWithExternalCommand(
livePrompt,
reliableTypedCommand,
);
if (reconciledPrompt) return reliableTypedCommand;
}
const liveCommand = livePrompt.userInput.trim();
if (!liveCommand && reliableTypedCommand) {
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
? null
: reliableTypedCommand;
}
if (!liveCommand) return null;
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
const liveInputMayIncludePromptDecoration =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
liveCommand !== typedBuffer.trim() &&
liveCommand.endsWith(typedBuffer.trim());
if (liveInputMayIncludePromptDecoration) return null;
const liveInputMayBeLagging =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
typedBuffer.length > livePrompt.userInput.length &&
typedBuffer.startsWith(livePrompt.userInput);
if (liveInputMayBeLagging) return null;
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
return liveCommand;
}

View File

@@ -0,0 +1,56 @@
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
import type { AutocompleteHistoryScope } from "../../../domain/models";
import { shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
type TerminalAutocompleteSettingFields = {
autocompleteEnabled?: boolean;
autocompleteGhostText?: boolean;
autocompletePopupMenu?: boolean;
autocompleteDebounceMs?: number;
autocompleteMinChars?: number;
autocompleteMaxSuggestions?: number;
autocompleteHistoryScope?: AutocompleteHistoryScope;
shiftEnterNewlineEnabled?: boolean;
};
export function resolveTerminalAutocompleteSettings(input: {
protocol?: string;
terminalSettings?: TerminalAutocompleteSettingFields;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean;
systemUnknown?: boolean;
}): Partial<AutocompleteSettings> | undefined {
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
if (protocol === "serial" || systemUnknown) {
return {
enabled: terminalSettings?.autocompleteEnabled ?? true,
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings?.autocompletePopupMenu ?? true,
livePreview: false,
allowLineReplacement: false,
debounceMs: terminalSettings?.autocompleteDebounceMs ?? 100,
minChars: terminalSettings?.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings?.autocompleteMaxSuggestions ?? 50,
historyScope: terminalSettings?.autocompleteHistoryScope ?? "host",
shiftEnterNewlineEnabled: terminalSettings?.shiftEnterNewlineEnabled ?? true,
};
}
if (!terminalSettings) {
return isNetworkDevice ? { livePreview: false } : undefined;
}
return {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
livePreview: shouldWriteAutocompleteLivePreview(true, isNetworkDevice),
allowLineReplacement: true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 50,
historyScope: terminalSettings.autocompleteHistoryScope ?? "host",
shiftEnterNewlineEnabled: terminalSettings.shiftEnterNewlineEnabled ?? true,
};
}

View File

@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { PluginTerminalProviderRegistry } from '../../../application/state/pluginTerminalProviderRegistry.ts';
import { provideTerminalCompletions } from './terminalCompletionProviders.ts';
test('terminal completion adapter merges validated plugin results through the host Provider path', async () => {
const calls: unknown[] = [];
const registry = {
async request(request: unknown) {
calls.push(request);
return {
requestId: 'request-1',
stale: false,
results: [{
pluginId: 'com.example',
pluginVersion: '1.0.0',
providerId: 'com.example.completion',
kind: 'terminal.completion',
requestId: 'provider-1',
status: 'ok',
result: {
items: [
{ text: 'zzzzunlikely-command', displayText: 'Plugin command', score: 50_000 },
{ text: '', score: 100_000 },
],
},
}],
} as const;
},
} as unknown as PluginTerminalProviderRegistry;
const results = await provideTerminalCompletions(registry, {
input: 'zzzzunlikely',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
});
assert.equal(calls.length, 1);
assert.equal(results[0].text, 'zzzzunlikely-command');
assert.equal(results[0].source, 'plugin');
assert.equal(results[0].providerId, 'com.example.completion');
assert.equal(results.some((item) => item.text === ''), false);
});
test('terminal completion adapter ignores stale plugin responses', async () => {
const registry = {
async request() { return { requestId: 'request-1', stale: true, results: [] }; },
} as unknown as PluginTerminalProviderRegistry;
const results = await provideTerminalCompletions(registry, {
input: 'zzzzunlikely',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
});
assert.equal(results.some((item) => item.source === 'plugin'), false);
});
test('terminal completion adapter preserves built-in results when the plugin bridge fails', async () => {
const registry = {
async request() { throw new Error('bridge unavailable'); },
} as unknown as PluginTerminalProviderRegistry;
const results = await provideTerminalCompletions(registry, {
input: 'zzzzunlikely',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
});
assert.ok(Array.isArray(results));
});
test('terminal completion adapter bounds plugin activation and authorization before returning built-ins', async () => {
let signal: AbortSignal | undefined;
const registry = {
async request(_request: unknown, options?: { signal?: AbortSignal }) {
signal = options?.signal;
return new Promise(() => {});
},
} as unknown as PluginTerminalProviderRegistry;
const result = await Promise.race([
provideTerminalCompletions(registry, {
input: 'git ',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
pluginResponseTimeoutMs: 10,
}),
new Promise<'timed-out'>((resolve) => setTimeout(() => resolve('timed-out'), 250)),
]);
assert.notEqual(result, 'timed-out');
assert.ok(Array.isArray(result));
assert.equal(signal?.aborted, true);
});
test('terminal completion adapter aborts and discards plugin results when the host security gate closes', async () => {
let providerSignal: AbortSignal | undefined;
let resolveProvider: ((value: {
requestId: string;
stale: false;
results: readonly unknown[];
}) => void) | undefined;
const registry = {
request(_request: unknown, options?: { signal?: AbortSignal }) {
providerSignal = options?.signal;
return new Promise((resolve) => { resolveProvider = resolve as typeof resolveProvider; });
},
} as unknown as PluginTerminalProviderRegistry;
const securityController = new AbortController();
const pending = provideTerminalCompletions(registry, {
input: 'safe-command',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
maximum: 8,
signal: securityController.signal,
});
await new Promise((resolve) => setImmediate(resolve));
securityController.abort();
resolveProvider?.({
requestId: 'request-1',
stale: false,
results: [{
providerId: 'com.example.completion',
status: 'ok',
result: { items: [{ text: 'plugin-result', score: 50_000 }] },
}],
});
const results = await pending;
assert.equal(providerSignal?.aborted, true);
assert.equal(results.some((item) => item.source === 'plugin'), false);
});
test('terminal completion adapter preserves built-in snippet metadata on duplicate plugin text', async () => {
const registry = {
async request() {
return {
requestId: 'request-1',
stale: false,
results: [{
pluginId: 'com.example',
pluginVersion: '1.0.0',
providerId: 'com.example.completion',
kind: 'terminal.completion',
requestId: 'provider-1',
status: 'ok',
result: { items: [{ text: 'deploy', score: 50_000 }] },
}],
} as const;
},
} as unknown as PluginTerminalProviderRegistry;
const snippet = { id: 'deploy', label: 'deploy', command: 'kubectl apply -f .' };
const results = await provideTerminalCompletions(registry, {
input: 'dep',
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
hostOs: 'linux',
snippets: [snippet],
maximum: 8,
});
const duplicate = results.find((item) => item.text === 'deploy');
assert.equal(duplicate?.source, 'snippet');
assert.equal(duplicate?.snippet, snippet);
assert.equal(results.filter((item) => item.text === 'deploy').length, 1);
});

View File

@@ -0,0 +1,143 @@
import {
mergePluginCompletionItems,
normalizePluginCompletionResult,
} from '../../../domain/pluginTerminalProviders';
import type { PluginTerminalProviderRegistry } from '../../../application/state/pluginTerminalProviderRegistry';
import {
getCompletions,
type CompletionSuggestion,
} from './completionEngine';
import type { AutocompleteCwdSource } from './terminalAutocompleteLayout';
import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
export interface TerminalCompletionProviderRequest {
input: string;
session: NetcattyTerminalSessionSnapshot;
hostGroup?: string;
hostOs: 'linux' | 'windows' | 'macos';
cwdSource?: AutocompleteCwdSource;
snippets?: Snippet[];
maximum: number;
/** Which history pool built-in suggestions draw from. */
historyScope?: AutocompleteHistoryScope;
/** Internal end-to-end wait bound; tests may lower it deterministically. */
pluginResponseTimeoutMs?: number;
/** Host security/session cancellation propagated to the plugin bridge. */
signal?: AbortSignal;
/**
* Forwarded to built-in getCompletions when a path listing finishes after the
* soft budget (cache-bypassed relative SSH cwd).
*/
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
}
const DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS = 800;
type PluginCompletionResponse = Awaited<ReturnType<PluginTerminalProviderRegistry['request']>>;
function emptyPluginCompletionResponse(): PluginCompletionResponse {
return { requestId: '', stale: false, results: Object.freeze([]) };
}
async function waitForPluginCompletionResponse(
response: Promise<PluginCompletionResponse>,
timeoutMs: number,
onTimeout?: () => void,
): Promise<PluginCompletionResponse> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<PluginCompletionResponse>((resolve) => {
timer = setTimeout(() => {
onTimeout?.();
resolve(emptyPluginCompletionResponse());
}, timeoutMs);
});
try {
return await Promise.race([response, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function provideTerminalCompletions(
registry: PluginTerminalProviderRegistry | null,
request: TerminalCompletionProviderRequest,
): Promise<CompletionSuggestion[]> {
const builtInPromise = getCompletions(request.input, {
hostId: request.session.hostId,
hostGroup: request.hostGroup,
os: request.hostOs,
maxResults: request.maximum,
sessionId: request.session.sessionId,
protocol: request.session.protocol,
cwd: request.session.cwd,
cwdSource: request.cwdSource,
snippets: request.snippets,
historyScope: request.historyScope,
onLatePathSuggestions: request.onLatePathSuggestions,
});
const pluginRequestController = new AbortController();
const abortPluginRequest = () => pluginRequestController.abort();
request.signal?.addEventListener('abort', abortPluginRequest, { once: true });
if (request.signal?.aborted) pluginRequestController.abort();
const pluginPromise = registry?.request({
kind: 'terminal.completion',
operation: 'provideCompletions',
session: request.session,
payload: {
input: request.input,
cursor: request.input.length,
hostOs: request.hostOs,
cwdSource: request.cwdSource ?? null,
maximum: request.maximum,
},
deadlineMs: 750,
}, { signal: pluginRequestController.signal }).catch(() => emptyPluginCompletionResponse())
?? Promise.resolve(emptyPluginCompletionResponse());
const pluginResponseTimeoutMs = Number.isFinite(request.pluginResponseTimeoutMs)
? Math.max(1, Math.min(5_000, Math.trunc(request.pluginResponseTimeoutMs ?? 0)))
: DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS;
let builtIn: CompletionSuggestion[];
let pluginResponse: PluginCompletionResponse;
try {
[builtIn, pluginResponse] = await Promise.all([
builtInPromise,
waitForPluginCompletionResponse(
pluginPromise,
pluginResponseTimeoutMs,
() => pluginRequestController.abort(),
),
]);
} finally {
request.signal?.removeEventListener('abort', abortPluginRequest);
}
if (request.signal?.aborted || pluginRequestController.signal.aborted || pluginResponse.stale) {
return builtIn;
}
const pluginGroups = pluginResponse.results.map((result) => result.status === 'ok'
? normalizePluginCompletionResult(result.providerId, result.result)
: Object.freeze([]));
const pluginItems = mergePluginCompletionItems(pluginGroups, request.maximum);
const combined: CompletionSuggestion[] = [
...builtIn,
...pluginItems.map((item) => ({
text: item.text,
displayText: item.displayText,
...(item.description === undefined ? {} : { description: item.description }),
source: 'plugin' as const,
score: item.score,
providerId: item.providerId,
})),
];
const deduplicated = new Map<string, CompletionSuggestion>();
for (const item of combined) {
const existing = deduplicated.get(item.text);
if (!existing
|| (existing.source === 'plugin' && item.source !== 'plugin')
|| (existing.source === item.source && item.score > existing.score)) {
deduplicated.set(item.text, item);
}
}
return [...deduplicated.values()]
.sort((left, right) => right.score - left.score || left.text.localeCompare(right.text))
.slice(0, request.maximum);
}

View File

@@ -0,0 +1,163 @@
/**
* Terminal cell-column width for autocomplete / ghost positioning.
*
* When an xterm instance is available, prefer its active Unicode provider
* (`15-graphemes` via UnicodeGraphemesAddon) so emoji / VS-16 clusters match
* the cursor advance. Fall back to a small East-Asian-Width-style classifier
* for unit fakes that lack `_core.unicodeService`.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
type UnicodeServiceLike = {
getStringCellWidth?: (s: string) => number;
};
type TermWithUnicodeService = {
_core?: {
unicodeService?: UnicodeServiceLike;
};
};
const unicodeMarkPattern = /\p{Mark}/u;
function codePointCellWidth(cp: number): number {
// Zero-width joiners / format / variation selectors / marks — xterm
// folds these into the surrounding grapheme (wcwidth 0 or shouldJoin).
if (
cp === 0x00ad ||
cp === 0x200d || // ZWJ
(cp >= 0x200b && cp <= 0x200f) || // ZWSP..RLM
(cp >= 0x202a && cp <= 0x202e) || // bidi overrides
(cp >= 0x2060 && cp <= 0x206f) || // word joiner, invisible ops
(cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
cp === 0xfeff ||
(cp >= 0x1f3fb && cp <= 0x1f3ff) || // Emoji skin-tone modifiers
(cp >= 0xe0100 && cp <= 0xe01ef) || // Variation Selectors Supplement
unicodeMarkPattern.test(String.fromCodePoint(cp))
) {
return 0;
}
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
) {
return 2;
}
return 1;
}
function graphemeCellWidth(grapheme: string): number {
let max = 0;
for (const ch of grapheme) {
const w = codePointCellWidth(ch.codePointAt(0) ?? 0);
if (w > max) max = w;
}
return max;
}
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function fallbackStringCellWidth(s: string): number {
if (graphemeSegmenter) {
let w = 0;
for (const { segment } of graphemeSegmenter.segment(s)) {
w += graphemeCellWidth(segment);
}
return w;
}
// Fallback without Segmenter: sum code-point widths (ZWJ/marks already 0).
let w = 0;
for (const ch of s) {
w += codePointCellWidth(ch.codePointAt(0) ?? 0);
}
return w;
}
/** Terminal cell columns occupied by `s` (wide glyphs / grapheme clusters). */
export function stringCellWidth(
s: string,
term?: XTerm | TermWithUnicodeService | null,
): number {
if (!s) return 0;
const unicodeService = (term as TermWithUnicodeService | null | undefined)
?._core?.unicodeService;
const getWidth = unicodeService?.getStringCellWidth;
if (typeof getWidth === "function") {
return getWidth.call(unicodeService, s);
}
return fallbackStringCellWidth(s);
}
/**
* Slice a terminal line string by cell columns (xterm `cursorX` units).
*
* `translateToString()` returns characters, but `buffer.cursorX` is a cell
* column. Mixing them with `String#substring(cursorX)` pulls padding spaces
* into user input whenever the prompt contains wide glyphs (CJK paths in
* Windows CMD / PowerShell), which breaks autocomplete matching (#2813).
*/
export function sliceStringByCellColumns(
text: string,
startCell: number,
endCell?: number,
term?: XTerm | TermWithUnicodeService | null,
): string {
if (!text) return "";
const start = Math.max(0, startCell);
const end = endCell === undefined ? Number.POSITIVE_INFINITY : Math.max(start, endCell);
if (end === 0) return "";
let cell = 0;
let startIndex = 0;
let endIndex = text.length;
let sawStart = false;
const advance = (segment: string, index: number, segmentLength: number): boolean => {
const width = stringCellWidth(segment, term);
const nextCell = cell + width;
if (!sawStart && nextCell > start) {
startIndex = index;
sawStart = true;
}
if (nextCell >= end) {
endIndex = nextCell === end ? index + segmentLength : index;
if (!sawStart) {
startIndex = index;
sawStart = true;
}
return true;
}
cell = nextCell;
return false;
};
if (graphemeSegmenter) {
for (const { segment, index } of graphemeSegmenter.segment(text)) {
if (advance(segment, index, segment.length)) break;
}
} else {
let index = 0;
for (const ch of text) {
if (advance(ch, index, ch.length)) break;
index += ch.length;
}
}
if (!sawStart) return "";
return text.slice(startIndex, endIndex);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
/**
* Utility functions for xterm.js cell dimension access.
* Centralizes access to xterm's internal renderer API to reduce upgrade risk.
* Falls back to DOM measurement if the internal API is unavailable.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
export interface CellDimensions {
width: number;
height: number;
}
// Cache to avoid repeated DOM measurements (invalidated on resize)
let cachedDims: CellDimensions | null = null;
let cachedTermId: number = 0;
let termIdCounter = 0;
const termIdMap = new WeakMap<XTerm, number>();
function getTermId(term: XTerm): number {
let id = termIdMap.get(term);
if (id === undefined) {
id = ++termIdCounter;
termIdMap.set(term, id);
}
return id;
}
/**
* Get cell dimensions (width/height in CSS pixels) from an xterm instance.
* Tries the internal renderer API first (fast path), falls back to DOM measurement.
*/
export function getXTermCellDimensions(term: XTerm): CellDimensions {
// Try xterm core renderer API (fast path)
const coreAccess = term as XTerm & {
_core?: { _renderService?: { dimensions?: { css?: { cell?: CellDimensions } } } };
};
const coreDims = coreAccess._core?._renderService?.dimensions?.css?.cell;
if (coreDims && coreDims.width > 0 && coreDims.height > 0) {
// Update cache while we have a good value
const id = getTermId(term);
cachedDims = { width: coreDims.width, height: coreDims.height };
cachedTermId = id;
return cachedDims;
}
// Check cache (same terminal instance)
const id = getTermId(term);
if (cachedDims && cachedTermId === id) {
return cachedDims;
}
// Fallback: measure from DOM (triggers single reflow)
const dims = measureCellFromDOM(term);
cachedDims = dims;
cachedTermId = id;
return dims;
}
/**
* Measure cell dimensions by inserting a temporary span into the terminal element.
* Triggers a single reflow (reading offsetWidth + offsetHeight).
*/
function measureCellFromDOM(term: XTerm): CellDimensions {
const element = term.element;
if (!element) return { width: 8, height: 16 };
const span = document.createElement("span");
span.textContent = "W";
Object.assign(span.style, {
position: "absolute",
visibility: "hidden",
fontFamily: term.options.fontFamily || "monospace",
fontSize: `${term.options.fontSize}px`,
lineHeight: "normal",
});
element.appendChild(span);
const width = span.offsetWidth || 8;
const height = span.offsetHeight || 16;
span.remove();
return { width, height };
}
/**
* Invalidate the cached cell dimensions (call on terminal resize).
*/
export function invalidateCellDimensionCache(): void {
cachedDims = null;
}

View File

@@ -0,0 +1,526 @@
import assert from "node:assert/strict";
import test from "node:test";
import xterm from "@xterm/xterm";
import {
appendEraseScrollbackAfterFullErases,
clearTerminalViewport,
clearTerminalViewportAndSyncPty,
installEraseInDisplayHandlers,
isEraseBelowSequence,
preserveTerminalViewportInScrollback,
shouldPreserveViewportBeforeEraseBelow,
shouldPreserveViewportBeforeFullErase,
shouldScrollOnEraseInDisplay,
shouldWipeScrollbackAfterFullErase,
} from "./clearTerminalViewport.ts";
const { Terminal } = xterm;
const createMockTerm = (
bufferType: "normal" | "alternate",
cursor: { cursorX?: number; cursorY?: number } = {},
): { buffer: { active: { type: "normal" | "alternate"; cursorX: number; cursorY: number } } } => ({
buffer: {
active: {
type: bufferType,
cursorX: cursor.cursorX ?? 0,
cursorY: cursor.cursorY ?? 0,
},
},
});
type RegisteredCsiHandler = {
id: { prefix?: string; final: string };
callback: (params: Array<number | number[]>) => boolean | Promise<boolean>;
disposed: boolean;
};
const createEraseHandlerHarness = (
options: {
bufferType?: "normal" | "alternate";
clearWipesScrollback?: boolean;
cursorX?: number;
cursorY?: number;
inDec2026SyncBlock?: boolean;
scrollTop?: number;
scrollBottom?: number;
} = {},
) => {
const handlers: RegisteredCsiHandler[] = [];
const microtasks: Array<() => void> = [];
const trimStartCalls: number[] = [];
const onScrollPositions: number[] = [];
const scrollRegion = {
lines: {
length: options.clearWipesScrollback ? 9 : 5,
trimStart: (count: number) => {
trimStartCalls.push(count);
scrollRegion.lines.length -= count;
},
},
scrollTop: options.scrollTop ?? 0,
scrollBottom: options.scrollBottom ?? 4,
ybase: options.clearWipesScrollback ? 4 : 0,
ydisp: options.clearWipesScrollback ? 4 : 0,
};
const observedScrollRegions: Array<[number, number]> = [];
const term = {
rows: 5,
options: {
scrollOnEraseInDisplay: false,
},
parser: {
registerCsiHandler: (id: { prefix?: string; final: string }, callback: RegisteredCsiHandler["callback"]) => {
const handler = {
id,
callback,
disposed: false,
};
handlers.push(handler);
return {
dispose: () => {
handler.disposed = true;
},
};
},
},
buffer: {
active: {
type: options.bufferType ?? "normal",
baseY: 0,
cursorX: options.cursorX ?? 0,
cursorY: options.cursorY ?? 0,
getLine: (line: number) => {
if (line < 0 || line >= 5) {
return undefined;
}
return {
translateToString: () => `row-${line}`,
};
},
},
},
_core: {
buffer: scrollRegion,
scroll: () => {
observedScrollRegions.push([scrollRegion.scrollTop, scrollRegion.scrollBottom]);
},
_inputHandler: {
_onScroll: {
fire: (position: number) => {
onScrollPositions.push(position);
},
},
_eraseAttrData: () => ({}),
},
},
};
const disposable = installEraseInDisplayHandlers(term as never, {
getClearWipesScrollback: () => options.clearWipesScrollback ?? false,
isInDec2026SyncBlock: () => options.inDec2026SyncBlock ?? false,
scheduleMicrotask: (callback) => {
microtasks.push(callback);
},
});
const erase = handlers.find((handler) => handler.id.final === "J" && handler.id.prefix === undefined);
const selectiveErase = handlers.find((handler) => handler.id.final === "J" && handler.id.prefix === "?");
if (!erase || !selectiveErase) {
throw new Error("erase handlers were not registered");
}
return {
disposable,
erase: erase.callback,
flushMicrotasks: () => {
while (microtasks.length > 0) {
microtasks.shift()?.();
}
},
handlers,
observedScrollRegions,
onScrollPositions,
scrollRegion,
selectiveErase: selectiveErase.callback,
term,
trimStartCalls,
};
};
const writeTerminal = (term: InstanceType<typeof Terminal>, data: string): Promise<void> =>
new Promise((resolve) => term.write(data, resolve));
test("preserves viewport before full erase on the normal screen outside sync blocks", () => {
const term = createMockTerm("normal");
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false), true);
});
test("skips viewport preservation inside DEC 2026 sync blocks", () => {
const term = createMockTerm("normal");
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, true), false);
});
test("skips viewport preservation on the alternate screen", () => {
const term = createMockTerm("alternate");
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false), false);
});
test("skips viewport preservation when full erase should wipe scrollback", () => {
const term = createMockTerm("normal");
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false, true), false);
});
test("wipes scrollback after full erase only on the normal screen outside sync blocks", () => {
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("normal") as never, false, true), true);
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("normal") as never, true, true), false);
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("alternate") as never, false, true), false);
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("normal") as never, false, false), false);
});
test("native erase-in-display scrollback preservation follows the clear history setting", () => {
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, false, false), true);
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, false, true), false);
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, true, false), false);
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("alternate") as never, false, false), false);
});
test("native erase-in-display scrollback preservation is skipped with active scroll margins", () => {
const term = {
rows: 5,
buffer: {
active: {
type: "normal",
cursorX: 0,
cursorY: 0,
},
},
_core: {
buffer: {
scrollTop: 1,
scrollBottom: 3,
},
},
};
assert.equal(shouldScrollOnEraseInDisplay(term as never, false, false), false);
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false, false), true);
assert.equal(shouldPreserveViewportBeforeEraseBelow(term as never, false, false), true);
});
test("erase-below is treated as viewport clear only from the home position", () => {
assert.equal(isEraseBelowSequence([]), true);
assert.equal(isEraseBelowSequence([0]), true);
assert.equal(isEraseBelowSequence([2]), false);
assert.equal(shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal") as never, false, false), true);
assert.equal(
shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal", { cursorX: 1 }) as never, false, false),
false,
);
assert.equal(
shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal", { cursorY: 1 }) as never, false, false),
false,
);
assert.equal(shouldPreserveViewportBeforeEraseBelow(createMockTerm("alternate") as never, false, false), false);
});
test("viewport preservation temporarily uses the full scroll region", () => {
const scrollRegion = {
scrollTop: 1,
scrollBottom: 3,
};
const observedScrollRegions: Array<[number, number]> = [];
const term = {
rows: 5,
buffer: {
active: {
type: "normal",
baseY: 0,
getLine: (line: number) => {
if (line < 0 || line >= 5) {
return undefined;
}
return {
translateToString: () => `row-${line}`,
};
},
},
},
_core: {
buffer: scrollRegion,
scroll: () => {
observedScrollRegions.push([scrollRegion.scrollTop, scrollRegion.scrollBottom]);
},
_inputHandler: {
_eraseAttrData: () => ({}),
},
},
};
preserveTerminalViewportInScrollback(term as never);
assert.equal(observedScrollRegions.length, 5);
assert.deepEqual(observedScrollRegions, [
[0, 4],
[0, 4],
[0, 4],
[0, 4],
[0, 4],
]);
assert.deepEqual(scrollRegion, {
scrollTop: 1,
scrollBottom: 3,
});
});
test("installed erase handlers preserve scrollback by behavior", () => {
const fullClear = createEraseHandlerHarness();
assert.equal(fullClear.erase([2]), false);
assert.equal(fullClear.term.options.scrollOnEraseInDisplay, true);
assert.deepEqual(fullClear.observedScrollRegions, []);
fullClear.flushMicrotasks();
assert.equal(fullClear.term.options.scrollOnEraseInDisplay, false);
const marginFullClear = createEraseHandlerHarness({ scrollTop: 1, scrollBottom: 3 });
assert.equal(marginFullClear.erase([2]), false);
assert.equal(marginFullClear.term.options.scrollOnEraseInDisplay, false);
assert.deepEqual(marginFullClear.observedScrollRegions, [
[0, 4],
[0, 4],
[0, 4],
[0, 4],
[0, 4],
]);
assert.equal(marginFullClear.scrollRegion.scrollTop, 1);
assert.equal(marginFullClear.scrollRegion.scrollBottom, 3);
const eraseBelow = createEraseHandlerHarness();
assert.equal(eraseBelow.erase([]), false);
assert.equal(eraseBelow.observedScrollRegions.length, 5);
const eraseBelowAwayFromHome = createEraseHandlerHarness({ cursorY: 1 });
assert.equal(eraseBelowAwayFromHome.erase([]), false);
assert.equal(eraseBelowAwayFromHome.observedScrollRegions.length, 0);
const wipeEraseBelow = createEraseHandlerHarness({ clearWipesScrollback: true });
assert.equal(wipeEraseBelow.erase([]), false);
assert.deepEqual(wipeEraseBelow.trimStartCalls, [4]);
assert.equal(wipeEraseBelow.scrollRegion.ybase, 0);
assert.equal(wipeEraseBelow.scrollRegion.ydisp, 0);
assert.deepEqual(wipeEraseBelow.onScrollPositions, [0]);
const wipeEraseBelowAwayFromHome = createEraseHandlerHarness({
clearWipesScrollback: true,
cursorY: 1,
});
assert.equal(wipeEraseBelowAwayFromHome.erase([]), false);
assert.deepEqual(wipeEraseBelowAwayFromHome.trimStartCalls, []);
});
test("erase-below wipe preserves later output from the same write batch", async () => {
const term = new Terminal({ cols: 20, rows: 5, scrollback: 100 });
const disposable = installEraseInDisplayHandlers(term as never, {
getClearWipesScrollback: () => true,
isInDec2026SyncBlock: () => false,
});
await writeTerminal(term, "old1\r\nold2\r\nold3\r\nold4\r\nold5\r\nold6\r\nold7\r\nold8");
await writeTerminal(term, "\x1b[H\x1b[Jnew1\r\nnew2\r\nnew3\r\nnew4\r\nnew5\r\nnew6\r\nnew7\r\nnew8");
const scrollback = Array.from({ length: term.buffer.active.baseY }, (_, row) =>
term.buffer.active.getLine(row)?.translateToString(true) ?? ""
);
assert.equal(scrollback.some((line) => line.startsWith("old")), false);
assert.equal(scrollback.some((line) => line.startsWith("new")), true);
disposable.dispose();
term.dispose();
});
test("installed erase handlers honor wipe, sync, alternate, and selective clears", () => {
const preserveHistory = createEraseHandlerHarness({ clearWipesScrollback: false });
assert.equal(preserveHistory.erase([3]), true);
const wipeHistory = createEraseHandlerHarness({ clearWipesScrollback: true });
assert.equal(wipeHistory.erase([3]), false);
const syncBlock = createEraseHandlerHarness({ inDec2026SyncBlock: true });
assert.equal(syncBlock.erase([2]), false);
assert.equal(syncBlock.term.options.scrollOnEraseInDisplay, false);
assert.deepEqual(syncBlock.observedScrollRegions, []);
const alternateScreen = createEraseHandlerHarness({ bufferType: "alternate" });
assert.equal(alternateScreen.erase([2]), false);
assert.equal(alternateScreen.term.options.scrollOnEraseInDisplay, false);
assert.deepEqual(alternateScreen.observedScrollRegions, []);
const selectiveClear = createEraseHandlerHarness();
selectiveClear.term.options.scrollOnEraseInDisplay = true;
assert.equal(selectiveClear.selectiveErase([2]), false);
assert.equal(selectiveClear.term.options.scrollOnEraseInDisplay, false);
selectiveClear.disposable.dispose();
assert.equal(selectiveClear.handlers.every((handler) => handler.disposed), true);
});
test("local clear writes erase-scrollback when requested", () => {
const writes: string[] = [];
const term = {
rows: 5,
buffer: {
active: {
type: "normal",
baseY: 0,
cursorY: 2,
cursorX: 4,
},
},
_core: {
scroll: () => {},
_inputHandler: {
_eraseAttrData: () => ({}),
},
},
write: (payload: string, callback?: () => void) => {
writes.push(payload);
callback?.();
},
scrollToBottom: () => {},
};
const didClear = clearTerminalViewport(term as never, { wipeScrollback: true });
assert.equal(didClear, true);
assert.equal(writes.length, 1);
assert.equal(writes[0].includes("\x1b[3J"), true);
});
test("local clear preserves scrollback when erase-scrollback is not requested", () => {
const writes: string[] = [];
const term = {
rows: 5,
buffer: {
active: {
type: "normal",
baseY: 0,
cursorY: 2,
cursorX: 4,
},
},
_core: {
scroll: () => {},
_inputHandler: {
_eraseAttrData: () => ({}),
},
},
write: (payload: string, callback?: () => void) => {
writes.push(payload);
callback?.();
},
scrollToBottom: () => {},
};
const didClear = clearTerminalViewport(term as never, { wipeScrollback: false });
assert.equal(didClear, true);
assert.equal(writes.length, 1);
assert.equal(writes[0].includes("\x1b[3J"), false);
});
test("local clear reports that alternate-screen content was left unchanged", () => {
const writes: string[] = [];
const term = {
buffer: {
active: {
type: "alternate",
baseY: 0,
cursorY: 2,
cursorX: 4,
},
},
write: (payload: string) => writes.push(payload),
};
const didClear = clearTerminalViewport(term as never, { wipeScrollback: true });
assert.equal(didClear, false);
assert.deepEqual(writes, []);
});
test("PTY sync runs only when the local viewport was actually cleared", () => {
const syncCalls: string[] = [];
const makeTerm = (type: "normal" | "alternate", cursorY: number) => ({
rows: 5,
buffer: {
active: {
type,
baseY: 0,
cursorY,
cursorX: 4,
},
},
_core: {
scroll: () => {},
_inputHandler: {
_eraseAttrData: () => ({}),
},
},
write: (_payload: string, callback?: () => void) => callback?.(),
scrollToBottom: () => {},
});
assert.equal(clearTerminalViewportAndSyncPty(makeTerm("normal", 2) as never, {
syncPty: () => syncCalls.push("normal"),
}), true);
assert.equal(clearTerminalViewportAndSyncPty(makeTerm("alternate", 2) as never, {
syncPty: () => syncCalls.push("alternate"),
}), false);
assert.equal(clearTerminalViewportAndSyncPty(makeTerm("normal", 0) as never, {
syncPty: () => syncCalls.push("empty"),
}), false);
assert.deepEqual(syncCalls, ["normal"]);
});
test("appendEraseScrollback adds 3J after a normal full clear", () => {
assert.equal(
appendEraseScrollbackAfterFullErases("\x1b[H\x1b[2Jframe", {
wipeScrollback: true,
normalScreen: true,
}),
"\x1b[H\x1b[2J\x1b[3Jframe",
);
});
test("appendEraseScrollback skips 3J inside an in-chunk DEC 2026 block", () => {
assert.equal(
appendEraseScrollbackAfterFullErases("\x1b[?2026h\x1b[H\x1b[2Jframe\x1b[?2026l", {
wipeScrollback: true,
normalScreen: true,
}),
"\x1b[?2026h\x1b[H\x1b[2Jframe\x1b[?2026l",
);
});
test("appendEraseScrollback skips 3J for a delayed clear when sync was already open", () => {
// Prior chunk carried `?2026h`; this chunk is only home+clear+frame.
assert.equal(
appendEraseScrollbackAfterFullErases("\x1b[H\x1b[2Jframe\x1b[?2026l", {
wipeScrollback: true,
normalScreen: true,
startInDec2026SyncBlock: true,
}),
"\x1b[H\x1b[2Jframe\x1b[?2026l",
);
});
test("appendEraseScrollback still wipes when delayed clear is outside a sync block", () => {
assert.equal(
appendEraseScrollbackAfterFullErases("\x1b[H\x1b[2Jframe", {
wipeScrollback: true,
normalScreen: true,
startInDec2026SyncBlock: false,
}),
"\x1b[H\x1b[2J\x1b[3Jframe",
);
});

View File

@@ -0,0 +1,405 @@
import type { IDisposable, IParser, Terminal as XTerm } from "@xterm/xterm";
type CsiParam = number | number[];
type EraseInDisplayTerminal = XTerm & {
parser: Pick<IParser, "registerCsiHandler">;
};
type InternalTerminal = XTerm & {
_core?: {
buffer?: {
lines?: {
length: number;
trimStart?: (count: number) => void;
};
scrollTop: number;
scrollBottom: number;
ybase?: number;
ydisp?: number;
};
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
_inputHandler?: {
_onScroll?: {
fire?: (position: number) => void;
};
_eraseAttrData?: () => unknown;
};
};
};
type ClearTerminalViewportOptions = {
wipeScrollback?: boolean;
};
type ClearTerminalViewportAndSyncPtyOptions = ClearTerminalViewportOptions & {
syncPty: () => void;
};
type AppendEraseScrollbackOptions = {
wipeScrollback: boolean;
normalScreen: boolean;
/**
* Open DEC 2026 state carried from a prior PTY chunk (e.g. sync-block filter
* saw `\x1b[?2026h` earlier). Chunk-local tracking alone cannot see that, so a
* delayed full-redraw `\x1b[2J` would otherwise get a spurious `\x1b[3J`.
*/
startInDec2026SyncBlock?: boolean;
};
type EraseInDisplayHandlerOptions = {
getClearWipesScrollback: () => boolean;
isInDec2026SyncBlock: () => boolean;
scheduleMicrotask?: (callback: () => void) => void;
};
const getVisibleContentRowCount = (term: XTerm): number => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") {
return 0;
}
const baseY = buffer.baseY;
for (let row = term.rows - 1; row >= 0; row--) {
const line = buffer.getLine(baseY + row);
if (!line) {
continue;
}
if (line.translateToString(true).length > 0) {
return row + 1;
}
}
return 0;
};
const getInternalScrollRegion = (term: XTerm): { scrollTop: number; scrollBottom: number } | undefined => {
const internalBuffer = (term as InternalTerminal)._core?.buffer;
if (
typeof internalBuffer?.scrollTop !== "number"
|| typeof internalBuffer.scrollBottom !== "number"
) {
return undefined;
}
return internalBuffer;
};
const hasDefaultScrollRegion = (term: XTerm): boolean => {
const scrollRegion = getInternalScrollRegion(term);
if (!scrollRegion) {
return true;
}
return scrollRegion.scrollTop === 0 && scrollRegion.scrollBottom === term.rows - 1;
};
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
const rowsToPreserve = getVisibleContentRowCount(term);
if (rowsToPreserve <= 0) {
return;
}
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) {
return;
}
const scrollRegion = getInternalScrollRegion(term);
const previousScrollTop = scrollRegion?.scrollTop;
const previousScrollBottom = scrollRegion?.scrollBottom;
try {
// xterm scrolls inside active DECSTBM margins; widen them while preserving.
if (scrollRegion) {
scrollRegion.scrollTop = 0;
scrollRegion.scrollBottom = term.rows - 1;
}
for (let row = 0; row < rowsToPreserve; row++) {
scroll.call(internal._core, eraseAttr, false);
}
} finally {
if (
scrollRegion
&& previousScrollTop !== undefined
&& previousScrollBottom !== undefined
) {
scrollRegion.scrollTop = previousScrollTop;
scrollRegion.scrollBottom = previousScrollBottom;
}
}
};
export const clearTerminalViewport = (
term: XTerm,
options: ClearTerminalViewportOptions = {},
): boolean => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") return false;
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY === 0 && buffer.baseY === 0) return false;
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) return false;
// Push lines above cursor into scrollback so they are preserved.
// After cursorY scrolls the prompt line shifts to active-screen row 0.
for (let i = 0; i < cursorY; i++) {
scroll.call(internal._core, eraseAttr, false);
}
// Clear everything below the prompt and reposition the cursor on it.
// CSI coordinates are 1-indexed.
const col = cursorX + 1;
const eraseScrollback = options.wipeScrollback ? "\x1b[3J" : "";
term.write(`\x1b[2;1H\x1b[J${eraseScrollback}\x1b[1;${col}H`, () => {
term.scrollToBottom();
});
return true;
};
export const clearTerminalViewportAndSyncPty = (
term: XTerm,
{ syncPty, ...options }: ClearTerminalViewportAndSyncPtyOptions,
): boolean => {
const didClearViewport = clearTerminalViewport(term, options);
if (didClearViewport) {
syncPty();
}
return didClearViewport;
};
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 3;
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 2;
export const isEraseBelowSequence = (params: CsiParam[]): boolean =>
params.length === 0 || params[0] === 0;
export const shouldScrollOnEraseInDisplay = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback: boolean,
): boolean => {
if (clearWipesScrollback || inDec2026SyncBlock) {
return false;
}
return term.buffer.active.type === "normal" && hasDefaultScrollRegion(term);
};
/**
* Netcatty preserves visible rows in scrollback before CSI 2 J so shell `clear`
* does not discard history. TUIs inside DEC 2026 sync blocks or the alternate
* screen expect an in-place erase instead.
*/
export const shouldPreserveViewportBeforeFullErase = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback = false,
): boolean => {
if (inDec2026SyncBlock || clearWipesScrollback) {
return false;
}
return term.buffer.active.type === "normal";
};
export const shouldPreserveViewportBeforeEraseBelow = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback = false,
): boolean => {
if (!shouldPreserveViewportBeforeFullErase(term, inDec2026SyncBlock, clearWipesScrollback)) {
return false;
}
const buffer = term.buffer.active;
return buffer.cursorX === 0 && buffer.cursorY === 0;
};
export const shouldWipeScrollbackAfterEraseBelow = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback: boolean,
): boolean => {
if (!shouldWipeScrollbackAfterFullErase(term, inDec2026SyncBlock, clearWipesScrollback)) {
return false;
}
const buffer = term.buffer.active;
return buffer.cursorX === 0 && buffer.cursorY === 0;
};
const wipeTerminalScrollback = (term: XTerm): void => {
const internal = term as InternalTerminal;
const buffer = internal._core?.buffer;
const lines = buffer?.lines;
const scrollBackSize = (lines?.length ?? 0) - term.rows;
if (!buffer || !lines || scrollBackSize <= 0 || typeof lines.trimStart !== "function") {
return;
}
lines.trimStart(scrollBackSize);
buffer.ybase = Math.max((buffer.ybase ?? 0) - scrollBackSize, 0);
buffer.ydisp = Math.max((buffer.ydisp ?? 0) - scrollBackSize, 0);
internal._core?._inputHandler?._onScroll?.fire?.(0);
};
export const shouldWipeScrollbackAfterFullErase = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback: boolean,
): boolean => {
if (!clearWipesScrollback || inDec2026SyncBlock) {
return false;
}
return term.buffer.active.type === "normal";
};
export const installEraseInDisplayHandlers = (
term: EraseInDisplayTerminal,
{
getClearWipesScrollback,
isInDec2026SyncBlock,
scheduleMicrotask = queueMicrotask,
}: EraseInDisplayHandlerOptions,
): IDisposable => {
const setScrollOnEraseInDisplayOnce = (enabled: boolean): void => {
term.options.scrollOnEraseInDisplay = enabled;
if (enabled) {
scheduleMicrotask(() => {
term.options.scrollOnEraseInDisplay = false;
});
}
};
const eraseDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
const wipeAllowed = getClearWipesScrollback();
const inDec2026SyncBlock = isInDec2026SyncBlock();
// Scope xterm's native preservation to shell clears, not TUI redraws.
if (isEraseViewportSequence(params)) {
const useNativeScrollPreservation = shouldScrollOnEraseInDisplay(
term,
inDec2026SyncBlock,
wipeAllowed,
);
setScrollOnEraseInDisplayOnce(useNativeScrollPreservation);
if (
!useNativeScrollPreservation
&& shouldPreserveViewportBeforeFullErase(term, inDec2026SyncBlock, wipeAllowed)
) {
preserveTerminalViewportInScrollback(term);
}
return false;
}
setScrollOnEraseInDisplayOnce(false);
if (isEraseBelowSequence(params)) {
if (shouldPreserveViewportBeforeEraseBelow(term, inDec2026SyncBlock, wipeAllowed)) {
preserveTerminalViewportInScrollback(term);
} else if (shouldWipeScrollbackAfterEraseBelow(term, inDec2026SyncBlock, wipeAllowed)) {
wipeTerminalScrollback(term);
}
return false;
}
if (!isEraseScrollbackSequence(params)) {
return false;
}
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
// Honor it unless the user opts into the legacy "preserve history" behavior.
return !wipeAllowed;
});
const selectiveEraseDisposable = term.parser.registerCsiHandler({ prefix: "?", final: "J" }, () => {
setScrollOnEraseInDisplayOnce(false);
return false;
});
return {
dispose: () => {
eraseDisposable.dispose();
selectiveEraseDisposable.dispose();
},
};
};
export const appendEraseScrollbackAfterFullErases = (
data: string,
{
wipeScrollback,
normalScreen,
startInDec2026SyncBlock = false,
}: AppendEraseScrollbackOptions,
): string => {
if (!wipeScrollback || !normalScreen || data.length === 0) {
return data;
}
// Hot path: all rewrites below only ever trigger on a literal \x1b[2J.
// Sync open state may be carried from a prior chunk; without a 2J this chunk
// still needs no rewrite.
if (!data.includes("\x1b[2J")) {
return data;
}
let result = "";
let index = 0;
let inDec2026SyncBlock = startInDec2026SyncBlock;
let inAlternateScreen = false;
while (index < data.length) {
if (data.startsWith("\x1b[?2026h", index)) {
inDec2026SyncBlock = true;
result += "\x1b[?2026h";
index += "\x1b[?2026h".length;
continue;
}
if (data.startsWith("\x1b[?2026l", index)) {
inDec2026SyncBlock = false;
result += "\x1b[?2026l";
index += "\x1b[?2026l".length;
continue;
}
const altEnter = ["\x1b[?47h", "\x1b[?1047h", "\x1b[?1049h"].find((sequence) =>
data.startsWith(sequence, index)
);
if (altEnter) {
inAlternateScreen = true;
result += altEnter;
index += altEnter.length;
continue;
}
const altLeave = ["\x1b[?47l", "\x1b[?1047l", "\x1b[?1049l"].find((sequence) =>
data.startsWith(sequence, index)
);
if (altLeave) {
inAlternateScreen = false;
result += altLeave;
index += altLeave.length;
continue;
}
if (data.startsWith("\x1b[2J", index)) {
result += "\x1b[2J";
index += "\x1b[2J".length;
if (
!inDec2026SyncBlock
&& !inAlternateScreen
&& !data.startsWith("\x1b[3J", index)
) {
result += "\x1b[3J";
}
continue;
}
result += data[index];
index += 1;
}
return result;
};

View File

@@ -0,0 +1,286 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildRemoteClipboardImagePath,
getRemoteClipboardImageUploadErrorMessageKey,
handleRemoteClipboardImageUpload,
quoteRemotePathForShell,
} from "./clipboardImagePaste";
test("remote clipboard image path is placed under the current directory", () => {
assert.equal(
buildRemoteClipboardImagePath("/srv/app", "netcatty paste:1.png"),
"/srv/app/.netcatty-paste-images/netcatty_paste_1.png",
);
});
test("remote clipboard image path is empty when cwd is unavailable", () => {
assert.equal(
buildRemoteClipboardImagePath(undefined, "shot.png"),
"",
);
});
test("remote paths are quoted for shell-safe insertion", () => {
assert.equal(
quoteRemotePathForShell("/srv/app/.netcatty-paste-images/a b's.png"),
"'/srv/app/.netcatty-paste-images/a b'\\''s.png'",
);
});
test("remote clipboard image upload inserts the remote image path without broadcasting", async () => {
const writes: Array<{ sessionId: string; data: string; sensitive?: boolean }> = [];
const scrolled: string[] = [];
let focused = false;
let closedSftpId: string | undefined;
let deletedTempFile: string | undefined;
const transferPayloads: unknown[] = [];
const broadcastData: string[] = [];
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => ({
path: "/tmp/netcatty/shot.png",
name: "shot 1.png",
mediaType: "image/png",
size: 12,
}),
openSftpForSession: async (sessionId) => {
assert.equal(sessionId, "session-1");
return "sftp-1";
},
startStreamTransfer: async (options) => {
transferPayloads.push(options);
return { transferId: options.transferId, totalBytes: 12 };
},
closeSftp: async (sftpId) => {
closedSftpId = sftpId;
},
deleteTempFile: async (filePath) => {
deletedTempFile = filePath;
return { success: true };
},
},
createTransferId: () => "transfer-1",
getRemoteCwd: async () => "/home/alice/project",
isSensitiveInput: () => true,
sessionId: "session-1",
terminalBackend: {
writeToSession: (sessionId, data, options) => writes.push({
sessionId,
data,
sensitive: options?.sensitive,
}),
},
term: {
focus: () => {
focused = true;
},
},
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
});
assert.deepEqual(result, {
ok: true,
remotePath: "/home/alice/project/.netcatty-paste-images/shot_1.png",
pastedPath: "/home/alice/project/.netcatty-paste-images/shot_1.png",
});
assert.deepEqual(transferPayloads, [
{
transferId: "transfer-1",
sourcePath: "/tmp/netcatty/shot.png",
targetPath: "/home/alice/project/.netcatty-paste-images/shot_1.png",
sourceType: "local",
targetType: "sftp",
targetSftpId: "sftp-1",
totalBytes: 12,
},
]);
assert.deepEqual(writes, [
{
sessionId: "session-1",
data: "/home/alice/project/.netcatty-paste-images/shot_1.png",
sensitive: true,
},
]);
assert.deepEqual(scrolled, ["/home/alice/project/.netcatty-paste-images/shot_1.png"]);
assert.deepEqual(broadcastData, []);
assert.equal(focused, true);
assert.equal(closedSftpId, "sftp-1");
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
});
test("remote clipboard image upload reports no image when no image exists", async () => {
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => null,
openSftpForSession: async () => "sftp-1",
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("should not paste without an image"),
},
});
assert.deepEqual(result, { ok: false, reason: "no-image" });
});
test("remote clipboard image upload reports no image without inserting a path", async () => {
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => null,
openSftpForSession: async () => {
assert.fail("should not open SFTP without an image");
},
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("should not paste without an image"),
},
});
assert.deepEqual(result, { ok: false, reason: "no-image" });
});
test("remote clipboard image upload skips upload without a reliable cwd", async () => {
const transferPayloads: unknown[] = [];
let deletedTempFile: string | undefined;
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => ({
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
}),
openSftpForSession: async () => {
assert.fail("should not open SFTP without cwd");
},
startStreamTransfer: async (options) => {
transferPayloads.push(options);
return { transferId: options.transferId };
},
deleteTempFile: async (filePath) => {
deletedTempFile = filePath;
return { success: true };
},
},
getRemoteCwd: async () => undefined,
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("should not paste without upload"),
},
});
assert.deepEqual(result, { ok: false, reason: "no-cwd" });
assert.deepEqual(transferPayloads, []);
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
});
test("remote clipboard image upload does not insert a path when upload returns an error", async () => {
let closedSftpId: string | undefined;
let deletedTempFile: string | undefined;
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => ({
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
}),
openSftpForSession: async () => "sftp-1",
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
closeSftp: async (sftpId) => {
closedSftpId = sftpId;
},
deleteTempFile: async (filePath) => {
deletedTempFile = filePath;
return { success: true };
},
},
getRemoteCwd: async () => "/home/alice",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("should not paste failed upload path"),
},
});
assert.deepEqual(result, { ok: false, reason: "upload-failed" });
assert.equal(closedSftpId, "sftp-1");
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
});
test("remote clipboard image upload reports transfer failures without inserting a path", async () => {
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => ({
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
}),
openSftpForSession: async () => "sftp-1",
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
},
getRemoteCwd: async () => "/home/alice",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("should not paste failed upload path"),
},
});
assert.deepEqual(result, { ok: false, reason: "upload-failed" });
});
test("remote clipboard image upload treats clipboard read failures as no image", async () => {
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => {
throw new Error("clipboard unavailable");
},
openSftpForSession: async () => {
assert.fail("should not open SFTP without an image");
},
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("should not paste without an image"),
},
});
assert.deepEqual(result, { ok: false, reason: "no-image" });
});
test("remote clipboard image upload result maps to user-facing message keys", () => {
assert.equal(
getRemoteClipboardImageUploadErrorMessageKey({
ok: false,
reason: "no-image",
}),
"terminal.clipboardImageUpload.noImage",
);
assert.equal(
getRemoteClipboardImageUploadErrorMessageKey({
ok: false,
reason: "upload-failed",
}),
"terminal.clipboardImageUpload.failed",
);
assert.equal(
getRemoteClipboardImageUploadErrorMessageKey({
ok: true,
remotePath: "/tmp/image.png",
pastedPath: "/tmp/image.png",
}),
null,
);
});

View File

@@ -0,0 +1,141 @@
const REMOTE_CLIPBOARD_IMAGE_DIR = ".netcatty-paste-images";
type ClipboardImageFile = {
path: string;
name: string;
mediaType: string;
size?: number;
};
export type RemoteClipboardImageBridge = Pick<
NetcattyBridge,
"readClipboardImage" | "openSftpForSession" | "startStreamTransfer"
> & Pick<Partial<NetcattyBridge>, "closeSftp" | "deleteTempFile">;
type TerminalLike = {
focus?: () => void;
};
type HandleRemoteClipboardImagePasteOptions = {
bridge?: RemoteClipboardImageBridge;
createTransferId?: () => string;
getRemoteCwd: () => Promise<string | null | undefined>;
isSensitiveInput?: () => boolean;
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
sessionId: string | null | undefined;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
};
term?: TerminalLike | null;
};
export type RemoteClipboardImageUploadResult =
| { ok: true; remotePath: string; pastedPath: string }
| { ok: false; reason: "unsupported" | "no-session" | "no-image" | "no-cwd" | "upload-failed" };
export function getRemoteClipboardImageUploadErrorMessageKey(
result: RemoteClipboardImageUploadResult,
): "terminal.clipboardImageUpload.noImage" | "terminal.clipboardImageUpload.failed" | null {
if (result.ok === true) return null;
return result.reason === "no-image"
? "terminal.clipboardImageUpload.noImage"
: "terminal.clipboardImageUpload.failed";
}
const shellSafePathPattern = /^[A-Za-z0-9_./~:@%+=,-]+$/;
export function sanitizeRemoteClipboardImageName(name: string): string {
const fallback = "netcatty-paste.png";
const trimmed = name.trim() || fallback;
const sanitized = trimmed
.replace(/[\0/\\]/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
return sanitized || fallback;
}
export function buildRemoteClipboardImagePath(cwd: string | null | undefined, fileName: string): string {
const safeFileName = sanitizeRemoteClipboardImageName(fileName);
const normalizedCwd = typeof cwd === "string" ? cwd.trim() : "";
if (!normalizedCwd) return "";
const base = normalizedCwd.replace(/\/+$/g, "") || "/";
if (base === "/") {
return `/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
}
return `${base}/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
}
export function quoteRemotePathForShell(remotePath: string): string {
if (shellSafePathPattern.test(remotePath)) return remotePath;
return `'${remotePath.replace(/'/g, "'\\''")}'`;
}
function defaultTransferId(): string {
const uuid = globalThis.crypto?.randomUUID?.();
return uuid ? `clipboard-image-${uuid}` : `clipboard-image-${Date.now()}`;
}
export async function handleRemoteClipboardImageUpload({
bridge,
createTransferId = defaultTransferId,
getRemoteCwd,
isSensitiveInput,
scrollToBottomAfterProgrammaticInput,
sessionId,
terminalBackend,
term,
}: HandleRemoteClipboardImagePasteOptions): Promise<RemoteClipboardImageUploadResult> {
if (!sessionId) return { ok: false, reason: "no-session" };
if (!bridge?.readClipboardImage || !bridge.openSftpForSession || !bridge.startStreamTransfer) {
return { ok: false, reason: "unsupported" };
}
let image: ClipboardImageFile | null;
try {
image = await bridge.readClipboardImage();
} catch {
// A clipboard read failure is indistinguishable from an empty clipboard —
// treat it as "no image" so callers can fall back to a normal paste.
return { ok: false, reason: "no-image" };
}
if (!image?.path || !image.name) return { ok: false, reason: "no-image" };
let sftpId: string | undefined;
try {
const remoteCwd = await getRemoteCwd();
const targetPath = buildRemoteClipboardImagePath(remoteCwd, image.name);
if (!targetPath) return { ok: false, reason: "no-cwd" };
const transferId = createTransferId();
sftpId = await bridge.openSftpForSession(sessionId);
const transferResult = await bridge.startStreamTransfer({
transferId,
sourcePath: image.path,
targetPath,
sourceType: "local",
targetType: "sftp",
targetSftpId: sftpId,
totalBytes: image.size,
});
if (!transferResult || transferResult.error) return { ok: false, reason: "upload-failed" };
const pastedPath = quoteRemotePathForShell(targetPath);
terminalBackend.writeToSession(sessionId, pastedPath, {
sensitive: isSensitiveInput?.() === true,
});
scrollToBottomAfterProgrammaticInput?.(pastedPath);
term?.focus?.();
return { ok: true, remotePath: targetPath, pastedPath };
} finally {
if (sftpId && bridge.closeSftp) {
await bridge.closeSftp(sftpId).catch(() => undefined);
}
if (bridge.deleteTempFile) {
await bridge.deleteTempFile(image.path).catch(() => undefined);
}
}
}

View File

@@ -0,0 +1,506 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
type MockDirEntry = {
name: string;
type: "file" | "directory" | "symlink";
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() {
store.clear();
},
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const storySpec: FigSpec = {
name: "story",
subcommands: [
{
name: "open",
args: { template: "filepaths" },
},
{
name: "pick",
args: { name: "item", generators: {} },
},
],
};
const bridgeState: {
localEntries: MockDirEntry[];
remoteEntriesByPath: Map<string, MockDirEntry[]>;
remoteCalls: string[];
remoteDelayMs: number;
} = {
localEntries: [],
remoteEntriesByPath: new Map(),
remoteCalls: [],
remoteDelayMs: 0,
};
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => ["story"],
loadFigSpec: async (commandName: string) => commandName === "story" ? storySpec : null,
listAutocompleteLocalDir: async (
_path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => {
const prefix = (filterPrefix ?? "").toLowerCase();
const entries = bridgeState.localEntries
.filter((entry) => !foldersOnly || entry.type === "directory")
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
.slice(0, limit ?? bridgeState.localEntries.length);
return { success: true, entries };
},
listAutocompleteRemoteDir: async (
_sessionId: string,
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => {
bridgeState.remoteCalls.push(path);
if (bridgeState.remoteDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, bridgeState.remoteDelayMs));
}
const prefix = (filterPrefix ?? "").toLowerCase();
const remoteEntries = bridgeState.remoteEntriesByPath.get(path) ?? [];
const entries = remoteEntries
.filter((entry) => !foldersOnly || entry.type === "directory")
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
.slice(0, limit ?? remoteEntries.length);
return { success: true, entries };
},
},
},
configurable: true,
});
const {
getCompletions,
getPathSuggestionsWithinBudget,
} = await import("./autocomplete/completionEngine.ts");
const {
clearHistory,
recordCommand,
removeCommandHistoryEntry,
} = await import("./autocomplete/commandHistoryStore.ts");
const {
normalizePathTokenForLookup,
shouldPreferRemoteShellCwd,
} = await import("./autocomplete/remotePathCompleter.ts");
test.beforeEach(() => {
localStorage.clear();
clearHistory();
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
bridgeState.remoteEntriesByPath = new Map();
bridgeState.remoteCalls = [];
bridgeState.remoteDelayMs = 0;
});
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
recordCommand("story open package-lock.json", "host-1");
const completions = await getCompletions("story open pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "story open package.json");
const historyIndex = completions.findIndex((entry) =>
entry.source === "history" && entry.text === "story open package-lock.json"
);
assert.ok(historyIndex > 0);
assert.equal(completions[historyIndex]?.historyMatch, "path-argument");
});
test("path completion marks a matching history replacement even when its full line is shorter", async () => {
const historyCommand = "story package.json";
const input = "story open --number p";
recordCommand(historyCommand, "host-1");
const completions = await getCompletions(input, {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
const history = completions.find((entry) => entry.text === historyCommand);
assert.ok(history);
assert.ok(history.text.length < input.length);
assert.equal(history.historyMatch, "path-argument");
});
test("getCompletions does not treat generator-only spec args as path contexts", async () => {
recordCommand("story pick package-choice", "host-1");
const completions = await getCompletions("story pick pa", {
hostId: "host-1",
protocol: "local",
cwd: "/repo",
});
assert.ok(completions.length > 0);
assert.equal(completions[0]?.source, "history");
assert.equal(completions[0]?.text, "story pick package-choice");
assert.equal(completions.some((entry) => entry.source === "path"), false);
});
test("history suggestions stop when an edited argument no longer matches the command prefix", async () => {
const historyCommand = "python3.14 -m robot -d /home/wx0043/Desktop/suite9";
recordCommand(historyCommand, "host-1");
const matching = await getCompletions("python3.14 -m r", {
hostId: "host-1",
historyScope: "host",
protocol: "ssh",
sessionId: "session-1",
});
assert.equal(
matching.some((entry) => entry.source === "history" && entry.text === historyCommand),
true,
);
const changedArgument = await getCompletions("python3.14 -m p", {
hostId: "host-1",
historyScope: "host",
protocol: "ssh",
sessionId: "session-1",
});
assert.equal(
changedArgument.some((entry) => entry.source === "history" && entry.text === historyCommand),
false,
);
});
test("single-token history queries retain fuzzy command-name matching", async () => {
const historyCommand = "docker compose up";
recordCommand(historyCommand, "host-1");
const completions = await getCompletions("dcu", {
hostId: "host-1",
historyScope: "host",
protocol: "ssh",
sessionId: "session-1",
});
assert.equal(
completions.some((entry) => entry.source === "history" && entry.text === historyCommand),
true,
);
});
test("removeCommandHistoryEntry removes only the matching host's autocomplete record", async () => {
recordCommand("bad-command --flag", "host-1");
recordCommand("bad-command --flag", "host-2");
removeCommandHistoryEntry("bad-command --flag", "host-1");
const completions = await getCompletions("bad-command", {
hostId: "host-1",
historyScope: "global",
protocol: "local",
cwd: "/repo",
});
assert.equal(
completions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
true,
);
const hostOnlyCompletions = await getCompletions("bad-command", {
hostId: "host-1",
historyScope: "host",
protocol: "local",
cwd: "/repo",
});
assert.equal(
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
false,
);
});
test("removeCommandHistoryEntry trims command text like recordCommand", async () => {
recordCommand("padded-cmd", "host-trim");
assert.equal(removeCommandHistoryEntry(" padded-cmd ", "host-trim"), true);
const hostOnlyCompletions = await getCompletions("padded", {
hostId: "host-trim",
historyScope: "host",
protocol: "local",
cwd: "/repo",
});
assert.equal(
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "padded-cmd"),
false,
);
});
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
const completions = await getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
cwd: "~",
});
assert.deepEqual(bridgeState.remoteCalls, ["."]);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "cat worktree.txt");
assert.equal(completions.some((entry) => entry.text.includes("~")), false);
});
test("getCompletions uses absolute prompt cwd for remote relative path arguments", async () => {
bridgeState.remoteEntriesByPath.set(".", [{ name: "old-user-file.txt", type: "file" }]);
bridgeState.remoteEntriesByPath.set("/etc", [{ name: "passwd", type: "file" }]);
const completions = await getCompletions("cat pa", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
cwd: "/etc",
cwdSource: "prompt",
});
assert.deepEqual(bridgeState.remoteCalls, ["/etc"]);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "cat passwd");
assert.equal(completions.some((entry) => entry.text === "cat old-user-file.txt"), false);
});
test("remote subdirectory lookups keep absolute prompt cwd", () => {
const preferRelativeCwd = shouldPreferRemoteShellCwd("ssh", "session-1", "linux", "/etc", "prompt");
assert.equal(preferRelativeCwd, false);
assert.equal(
normalizePathTokenForLookup("pam.d/", "/etc", { preferRelativeCwd }),
"/etc/pam.d/",
);
});
test("getCompletions keeps remote shell cwd when absolute cwd is only a fallback", async () => {
bridgeState.remoteEntriesByPath.set("/old", [{ name: "old-user-file.txt", type: "file" }]);
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
const completions = await getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
cwd: "/old",
cwdSource: "fallback",
});
assert.deepEqual(bridgeState.remoteCalls, ["."]);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "cat worktree.txt");
assert.equal(completions.some((entry) => entry.text === "cat old-user-file.txt"), false);
});
test("getCompletions does not reuse cached remote relative listings after cwd changes", async () => {
bridgeState.remoteEntriesByPath.set(".", [{ name: "home-only.txt", type: "file" }]);
await getCompletions("cat ", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
});
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
const completions = await getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
});
assert.equal(bridgeState.remoteCalls.length, 2);
assert.equal(completions[0]?.text, "cat worktree.txt");
});
test("getCompletions does not reuse in-flight remote relative listings after cwd changes", async () => {
bridgeState.remoteDelayMs = 150;
bridgeState.remoteEntriesByPath.set(".", [{ name: "home-only.txt", type: "file" }]);
const first = getCompletions("cat ", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-inflight-cwd",
pathBudgetMs: Infinity,
});
// First listing must be in flight before the shell cwd listing changes.
await new Promise((resolve) => setTimeout(resolve, 20));
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
const second = await getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-inflight-cwd",
pathBudgetMs: Infinity,
});
await first;
assert.equal(bridgeState.remoteCalls.length, 2);
assert.equal(second[0]?.text, "cat worktree.txt");
assert.equal(second.some((entry) => entry.text === "cat home-only.txt"), false);
});
test("getCompletions returns local history before a slow remote path listing finishes", async () => {
recordCommand("cat worktree.txt", "host-1");
bridgeState.remoteDelayMs = 250;
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
const started = Date.now();
const completions = await getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-slow-path",
cwd: "~",
pathBudgetMs: 40,
});
const elapsed = Date.now() - started;
assert.ok(elapsed < 200, `expected local suggestions within budget, took ${elapsed}ms`);
assert.ok(
completions.some((entry) => entry.source === "history" && entry.text === "cat worktree.txt"),
);
assert.equal(completions.some((entry) => entry.source === "path"), false);
});
test("getCompletions surfaces late path suggestions for cache-bypassed relative SSH cwd", async () => {
recordCommand("cat worktree.txt", "host-1");
bridgeState.remoteDelayMs = 250;
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
let latePathSuggestions: Awaited<ReturnType<typeof getCompletions>> | null = null;
const latePathPromise = new Promise<void>((resolve) => {
void getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-late-path",
cwd: "/stale-fallback",
cwdSource: "fallback",
pathBudgetMs: 40,
onLatePathSuggestions: (suggestions) => {
latePathSuggestions = suggestions;
resolve();
},
}).then((completions) => {
assert.equal(completions.some((entry) => entry.source === "path"), false);
assert.ok(
completions.some((entry) => entry.source === "history" && entry.text === "cat worktree.txt"),
);
});
});
await latePathPromise;
assert.ok(latePathSuggestions);
assert.equal(latePathSuggestions![0]?.source, "path");
assert.equal(latePathSuggestions![0]?.text, "cat worktree.txt");
});
test("getPathSuggestionsWithinBudget ignores late rejections after the soft timeout", async () => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
const pathPromise = new Promise<{ name: string; type: "file" }[]>((_resolve, reject) => {
setTimeout(() => reject(new Error("late path failure")), 80);
});
const entries = await getPathSuggestionsWithinBudget(pathPromise, 20);
assert.deepEqual(entries, []);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.equal(unhandled.length, 0);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
test("getPathSuggestionsWithinBudget delivers late entries after the soft timeout", async () => {
const pathPromise = new Promise<{ name: string; type: "file" }[]>((resolve) => {
setTimeout(() => resolve([{ name: "late.txt", type: "file" }]), 80);
});
let lateEntries: { name: string; type: "file" }[] | null = null;
const entries = await getPathSuggestionsWithinBudget(pathPromise, 20, (late) => {
lateEntries = late as { name: string; type: "file" }[];
});
assert.deepEqual(entries, []);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.deepEqual(lateEntries, [{ name: "late.txt", type: "file" }]);
});
test("getCompletions includes other hosts' history when historyScope is global", async () => {
recordCommand("systemctl restart nginx", "host-a");
recordCommand("systemctl status nginx", "host-b");
const hostScoped = await getCompletions("systemctl", {
hostId: "host-a",
historyScope: "host",
});
assert.ok(
hostScoped.some((entry) => entry.source === "history" && entry.text === "systemctl restart nginx"),
);
assert.equal(
hostScoped.some((entry) => entry.source === "history" && entry.text === "systemctl status nginx"),
false,
);
const globalScoped = await getCompletions("systemctl", {
hostId: "host-a",
historyScope: "global",
});
assert.ok(
globalScoped.some((entry) => entry.source === "history" && entry.text === "systemctl restart nginx"),
);
assert.ok(
globalScoped.some((entry) => entry.source === "history" && entry.text === "systemctl status nginx"),
);
});

View File

@@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getCompletions } from "./autocomplete/completionEngine";
import { DEFAULT_AUTOCOMPLETE_SETTINGS } from "./autocomplete/useTerminalAutocomplete";
import type { Snippet } from "../../domain/models";
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
test("getCompletions includes snippet suggestions at the command position", async () => {
const out = await getCompletions("dep", { snippets: [deploySnippet] });
const snip = out.find((s) => s.source === "snippet");
assert.ok(snip, "expected a snippet suggestion");
assert.equal(snip?.displayText, "deploy");
});
test("getCompletions does not surface snippets past the command position", async () => {
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});
test("getCompletions applies dynamic group targets to the current host", async () => {
const groupedSnippet: Snippet = {
...deploySnippet,
targetGroups: ["Production"],
};
const matching = await getCompletions("dep", {
hostId: "host-prod",
hostGroup: "Production/Web",
snippets: [groupedSnippet],
});
const outside = await getCompletions("dep", {
hostId: "host-dev",
hostGroup: "Development",
snippets: [groupedSnippet],
});
assert.ok(matching.some((suggestion) => suggestion.source === "snippet"));
assert.equal(outside.some((suggestion) => suggestion.source === "snippet"), false);
});
test("getCompletions returns more than 8 snippet matches when default maxSuggestions allows it", async () => {
const snippets: Snippet[] = Array.from({ length: 20 }, (_, i) => ({
id: `s${i}`,
label: `deploy-${String(i).padStart(2, "0")}`,
command: `echo deploy-${i}`,
}));
assert.ok(
DEFAULT_AUTOCOMPLETE_SETTINGS.maxSuggestions > 8,
`expected raised default maxSuggestions (>8), got ${DEFAULT_AUTOCOMPLETE_SETTINGS.maxSuggestions}`,
);
const out = await getCompletions("dep", {
snippets,
maxResults: DEFAULT_AUTOCOMPLETE_SETTINGS.maxSuggestions,
});
const snippetMatches = out.filter((s) => s.source === "snippet");
assert.ok(
snippetMatches.length > 8,
`expected more than 8 snippet matches for scrolling popup, got ${snippetMatches.length}`,
);
assert.equal(snippetMatches.length, 20);
});

View File

@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
test("queries completions when the popup menu is enabled", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
true,
);
});
test("queries completions when ghost text is enabled", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
true,
);
});
test("skips completion work when both popup and ghost text are off", () => {
assert.equal(
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
false,
);
});

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Snippet } from '../../types';
import {
buildSnippetIdKey,
clampComposeBarHeight,
COMPOSE_BAR_BUILTIN_SNIPPET_IDS,
COMPOSE_BAR_MAX_HEIGHT,
COMPOSE_BAR_MIN_HEIGHT,
filterComposeBarSnippets,
resolveComposeBarDefaultSeedIds,
} from './composeBarHelpers';
const sampleSnippets: Snippet[] = [
{ id: 'a', label: 'List files', command: 'ls -la', package: 'utils' },
{ id: 'b', label: 'Disk usage', command: 'df -h', package: 'monitor' },
{ id: 'c', label: 'Restart nginx', command: 'systemctl restart nginx' },
];
test('clampComposeBarHeight clamps below minimum', () => {
assert.equal(clampComposeBarHeight(10), COMPOSE_BAR_MIN_HEIGHT);
});
test('clampComposeBarHeight clamps above maximum', () => {
assert.equal(clampComposeBarHeight(999), COMPOSE_BAR_MAX_HEIGHT);
});
test('clampComposeBarHeight passes through valid values', () => {
assert.equal(clampComposeBarHeight(150), 150);
});
test('filterComposeBarSnippets returns all snippets sorted when query is empty', () => {
assert.deepEqual(
filterComposeBarSnippets(sampleSnippets, '').map((s) => s.id),
['b', 'a', 'c'],
);
});
test('filterComposeBarSnippets filters by label, command, and package', () => {
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'nginx').map((s) => s.id), ['c']);
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'df').map((s) => s.id), ['b']);
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'utils').map((s) => s.id), ['a']);
});
test('filterComposeBarSnippets returns empty list when nothing matches', () => {
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'missing'), []);
});
test('buildSnippetIdKey joins ids with a null delimiter', () => {
assert.equal(buildSnippetIdKey(['a', 'b']), 'a\0b');
});
test('resolveComposeBarDefaultSeedIds uses vault snippets when available', () => {
assert.deepEqual(
resolveComposeBarDefaultSeedIds(sampleSnippets).map((id) => id),
['b', 'a', 'c'],
);
});
test('resolveComposeBarDefaultSeedIds falls back to built-ins when vault is empty', () => {
assert.deepEqual(
resolveComposeBarDefaultSeedIds([]),
COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, 4),
);
});

View File

@@ -0,0 +1,56 @@
import type { Snippet } from '../../types';
export const COMPOSE_BAR_MIN_HEIGHT = 72;
export const COMPOSE_BAR_MAX_HEIGHT = 360;
export function clampComposeBarHeight(height: number): number {
return Math.max(COMPOSE_BAR_MIN_HEIGHT, Math.min(COMPOSE_BAR_MAX_HEIGHT, height));
}
export function filterComposeBarSnippets(snippets: Snippet[], query: string): Snippet[] {
const normalized = query.trim().toLowerCase();
const filtered = normalized
? snippets.filter((snippet) => (
snippet.label.toLowerCase().includes(normalized)
|| snippet.command.toLowerCase().includes(normalized)
|| (snippet.package?.toLowerCase().includes(normalized) ?? false)
))
: snippets;
return filtered.slice().sort((a, b) => a.label.localeCompare(b.label));
}
export function buildSnippetIdKey(snippetIds: readonly string[]): string {
return snippetIds.join('\0');
}
/** Built-in quick commands shown until the user customizes the strip. */
export const COMPOSE_BAR_BUILTIN_SNIPPETS: Snippet[] = [
{ id: '__compose_builtin_ls', label: 'ls -la', command: 'ls -la' },
{ id: '__compose_builtin_df', label: 'df -h', command: 'df -h' },
{ id: '__compose_builtin_free', label: 'free -h', command: 'free -h' },
{ id: '__compose_builtin_pwd', label: 'pwd', command: 'pwd' },
];
export const COMPOSE_BAR_BUILTIN_SNIPPET_IDS = COMPOSE_BAR_BUILTIN_SNIPPETS.map((s) => s.id);
export const COMPOSE_BAR_DEFAULT_SEED_COUNT = 4;
export function resolveComposeBarDefaultSeedIds(snippets: Snippet[]): string[] {
if (snippets.length > 0) {
return filterComposeBarSnippets(snippets, '')
.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT)
.map((snippet) => snippet.id);
}
return COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT);
}
export function mergeComposeBarSnippetMap(snippets: Snippet[]): Map<string, Snippet> {
const map = new Map<string, Snippet>();
for (const builtin of COMPOSE_BAR_BUILTIN_SNIPPETS) {
map.set(builtin.id, builtin);
}
for (const snippet of snippets) {
map.set(snippet.id, snippet);
}
return map;
}

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
} from "./connectAutomationBatch.ts";
test("cancel waits for the stop operation registered by the abort listener", async () => {
const batch = createConnectAutomationBatch();
let releaseStop: (() => void) | undefined;
let stopFinished = false;
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
releaseStop = () => {
stopFinished = true;
resolve();
};
}));
}, { once: true });
const cancelling = cancelConnectAutomationBatch(batch);
await Promise.resolve();
assert.equal(stopFinished, false);
releaseStop?.();
await cancelling;
assert.equal(stopFinished, true);
});
test("cancel surfaces a backend stop failure", async () => {
const batch = createConnectAutomationBatch();
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, async () => {
throw new Error("stop failed");
});
}, { once: true });
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
});
test("cancel can retry a stop operation after a transient failure", async () => {
const batch = createConnectAutomationBatch();
let attempts = 0;
trackConnectAutomationStop(batch, async () => {
attempts += 1;
if (attempts === 1) throw new Error("temporary stop failure");
});
await assert.rejects(
() => cancelConnectAutomationBatch(batch),
/temporary stop failure/,
);
await cancelConnectAutomationBatch(batch);
assert.equal(attempts, 2);
});

View File

@@ -0,0 +1,25 @@
export interface ConnectAutomationBatch {
controller: AbortController;
stopCurrentRun: (() => Promise<void>) | null;
}
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
controller: new AbortController(),
stopCurrentRun: null,
});
export const trackConnectAutomationStop = (
batch: ConnectAutomationBatch,
stopCurrentRun: (() => Promise<void>) | null,
): void => {
batch.stopCurrentRun = stopCurrentRun;
};
export const cancelConnectAutomationBatch = async (
batch: ConnectAutomationBatch,
): Promise<void> => {
batch.controller.abort();
if (batch.stopCurrentRun) {
await batch.stopCurrentRun();
}
};

View File

@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
test("concatenates appended chunks while under the cap", () => {
const buf = createConnectionLogBuffer(100);
buf.append("foo");
buf.append("bar");
buf.append("baz");
assert.equal(buf.toString(), "foobarbaz");
});
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
const max = 10;
const buf = createConnectionLogBuffer(max);
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
let naive = "";
for (const c of chunks) {
buf.append(c);
naive += c;
}
assert.equal(buf.toString(), naive.slice(-max));
assert.equal(buf.toString().length, max);
});
test("trims a single chunk larger than the cap to its last maxChars", () => {
const buf = createConnectionLogBuffer(5);
buf.append("0123456789");
assert.equal(buf.toString(), "56789");
});
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
const buf = createConnectionLogBuffer(6);
buf.append("abcde"); // 5
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
assert.equal(buf.toString(), "efghij");
});
test("stays correct across many small appends (ring semantics)", () => {
const max = 50;
const buf = createConnectionLogBuffer(max);
let naive = "";
for (let i = 0; i < 500; i++) {
const chunk = `x${i}-`;
buf.append(chunk);
naive += chunk;
}
assert.equal(buf.toString(), naive.slice(-max));
});
test("reset clears the buffer", () => {
const buf = createConnectionLogBuffer(100);
buf.append("hello");
buf.reset();
assert.equal(buf.toString(), "");
buf.append("world");
assert.equal(buf.toString(), "world");
});
test("ignores empty appends", () => {
const buf = createConnectionLogBuffer(100);
buf.append("a");
buf.append("");
buf.append("b");
assert.equal(buf.toString(), "ab");
});
test("keeps the segment count bounded across many tiny appends", () => {
// The whole point of the rewrite: trimming must not walk one array entry
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
// single-char appends arrive once it's at capacity.
const maxChars = 100;
const blockSize = 10;
const buf = createConnectionLogBuffer(maxChars, blockSize);
let naive = "";
for (let i = 0; i < 10000; i++) {
buf.append("x");
naive += "x";
}
assert.ok(
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
`segmentCount ${buf.segmentCount()} exceeded the bound`,
);
assert.equal(buf.toString(), naive.slice(-maxChars));
});
test("seals and trims whole blocks with a small blockSize", () => {
const buf = createConnectionLogBuffer(10, 4);
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
let naive = "";
for (const c of chunks) {
buf.append(c);
naive += c;
}
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
});

View File

@@ -0,0 +1,94 @@
/**
* A bounded, append-only text buffer that retains only the last `maxChars`
* characters — the connection log used for diagnostics/replay.
*
* The naive implementation (`log += chunk; if (log.length > max) log =
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
* cap is reached — on the render thread, for every output chunk including each
* echoed keystroke.
*
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
* the oldest data therefore only ever drops/slices a handful of blocks — never
* one array element per append, which would make trim O(number of appends) and
* defeat the purpose. Append is amortized O(chunk); the full string is
* materialized only on `toString()` (called rarely, on finalize).
*/
export interface ConnectionLogBuffer {
append(chunk: string): void;
toString(): string;
reset(): void;
/**
* Number of internal string segments currently retained. Exposed for tests
* to assert the bounded-memory / bounded-trim property.
*/
segmentCount(): number;
}
const DEFAULT_BLOCK_SIZE = 64 * 1024;
export function createConnectionLogBuffer(
maxChars: number,
blockSize: number = DEFAULT_BLOCK_SIZE,
): ConnectionLogBuffer {
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
let tail = ""; // open block currently being filled (newest data)
let total = 0; // total retained length across blocks + tail
const trim = () => {
let overflow = total - maxChars;
if (overflow <= 0) return;
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
while (overflow > 0 && blocks.length > 0) {
const head = blocks[0];
if (head.length <= overflow) {
blocks.shift();
total -= head.length;
overflow -= head.length;
} else {
blocks[0] = head.slice(overflow);
total -= overflow;
overflow = 0;
}
}
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
// maxChars); keep its last `maxChars` characters.
if (overflow > 0) {
tail = tail.slice(overflow);
total -= overflow;
}
};
return {
append(chunk: string): void {
if (!chunk) return;
// A single chunk at/over the cap can only contribute its own tail.
if (chunk.length >= maxChars) {
blocks = [];
tail = chunk.slice(chunk.length - maxChars);
total = tail.length;
return;
}
tail += chunk;
total += chunk.length;
if (tail.length >= blockSize) {
blocks.push(tail);
tail = "";
}
if (total > maxChars) trim();
},
toString(): string {
return blocks.length > 0 ? blocks.join("") + tail : tail;
},
reset(): void {
blocks = [];
tail = "";
total = 0;
},
segmentCount(): number {
return blocks.length + (tail.length > 0 ? 1 : 0);
},
};
}

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CONNECTION_PROGRESS_CAP,
CONNECTION_PROGRESS_START,
advanceIndeterminateConnectionProgress,
advanceMonotonicConnectionProgress,
resolveHopConnectionProgress,
} from "./connectionProgress";
test("indeterminate connection progress only moves forward toward the cap", () => {
assert.equal(advanceIndeterminateConnectionProgress(CONNECTION_PROGRESS_CAP), CONNECTION_PROGRESS_CAP);
assert.equal(advanceIndeterminateConnectionProgress(94), CONNECTION_PROGRESS_CAP);
const first = advanceIndeterminateConnectionProgress(CONNECTION_PROGRESS_START);
assert.ok(first > CONNECTION_PROGRESS_START);
assert.ok(first < CONNECTION_PROGRESS_CAP);
const second = advanceIndeterminateConnectionProgress(first);
assert.ok(second > first);
});
test("hop connection progress stays within the visual cap", () => {
assert.equal(resolveHopConnectionProgress(1, 1), 90);
assert.equal(resolveHopConnectionProgress(1, 2), 50);
assert.equal(resolveHopConnectionProgress(2, 2), 90);
assert.equal(resolveHopConnectionProgress(0, 1), 10);
});
test("connection progress updates never rewind", () => {
assert.equal(advanceMonotonicConnectionProgress(40, 10), 40);
assert.equal(advanceMonotonicConnectionProgress(40, 90), 90);
assert.equal(advanceMonotonicConnectionProgress(5, 5), 5);
});
test("connecting seeds progress once and hop/phase changes do not snap it back", () => {
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
assert.match(
effectsSource,
/if \(status === "connecting"\) \{\s*setIsDisconnectedDialogDismissed\(false\);\s*setProgressValue\(CONNECTION_PROGRESS_START\);/,
);
assert.match(effectsSource, /setProgressValue\(advanceIndeterminateConnectionProgress\)/);
assert.doesNotMatch(effectsSource, /setProgressValue\(5\)/);
const starterSource = readFileSync(
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
"utf8",
);
assert.match(starterSource, /advanceMonotonicConnectionProgress/);
assert.match(starterSource, /resolveHopConnectionProgress/);
});

View File

@@ -0,0 +1,18 @@
export const CONNECTION_PROGRESS_START = 5;
export const CONNECTION_PROGRESS_CAP = 95;
export const advanceIndeterminateConnectionProgress = (prev: number): number => {
if (prev >= CONNECTION_PROGRESS_CAP) return prev;
const remaining = CONNECTION_PROGRESS_CAP - prev;
const increment = Math.max(1, remaining * 0.15);
return Math.min(CONNECTION_PROGRESS_CAP, prev + increment);
};
export const resolveHopConnectionProgress = (hop: number, total: number): number => {
const safeTotal = Math.max(1, total);
const safeHop = Math.max(0, hop);
return Math.min(CONNECTION_PROGRESS_CAP, (safeHop / safeTotal) * 80 + 10);
};
export const advanceMonotonicConnectionProgress = (prev: number, next: number): number =>
Math.max(prev, next);

View File

@@ -0,0 +1,99 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
SSH_AUTH_READY_TIMEOUT_MS,
SSH_TCP_CONNECT_TIMEOUT_MS,
getConnectionTimeoutMs,
hasConnectionPassedTcpDial,
resolveActiveConnectionTimeoutHost,
shouldRunConnectionTimeout,
} from "./connectionTimeouts";
test("SSH connection timeout constants separate TCP dial from auth wait", () => {
assert.equal(SSH_TCP_CONNECT_TIMEOUT_MS, 20_000);
assert.equal(SSH_AUTH_READY_TIMEOUT_MS, 120_000);
});
const baseTimeoutState = {
status: "connecting",
needsAuth: false,
isLocalConnection: false,
isSerialConnection: false,
hasSshTcpConnectProgress: true,
needsHostKeyVerification: false,
isConnectionAwaitingUserInput: false,
isConnectionPastTcpDial: false,
};
test("connection timeout runs for an ordinary remote connect attempt", () => {
assert.equal(shouldRunConnectionTimeout(baseTimeoutState), true);
});
test("connection timeout pauses while SSH waits for user confirmation", () => {
assert.equal(shouldRunConnectionTimeout({
...baseTimeoutState,
needsHostKeyVerification: true,
}), false);
assert.equal(shouldRunConnectionTimeout({
...baseTimeoutState,
isConnectionAwaitingUserInput: true,
}), false);
});
test("connection timeout switches from TCP dial to auth readiness after TCP connects", () => {
assert.equal(getConnectionTimeoutMs(baseTimeoutState), SSH_TCP_CONNECT_TIMEOUT_MS);
assert.equal(getConnectionTimeoutMs({
...baseTimeoutState,
isConnectionPastTcpDial: true,
}), SSH_AUTH_READY_TIMEOUT_MS);
});
test("connection timeout uses configured timeout values", () => {
assert.equal(getConnectionTimeoutMs(baseTimeoutState, {
tcpConnectTimeoutMs: 45_000,
authReadyTimeoutMs: 300_000,
}), 45_000);
assert.equal(getConnectionTimeoutMs({
...baseTimeoutState,
isConnectionPastTcpDial: true,
}, {
tcpConnectTimeoutMs: 45_000,
authReadyTimeoutMs: 300_000,
}), 300_000);
});
test("connection timeout follows the current jump host before the target", () => {
const target = { sshTcpConnectTimeoutSeconds: 20, sshAuthReadyTimeoutSeconds: 120 };
const jumps = [
{ sshTcpConnectTimeoutSeconds: 75, sshAuthReadyTimeoutSeconds: 360 },
{ sshTcpConnectTimeoutSeconds: 90, sshAuthReadyTimeoutSeconds: 420 },
];
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 1), jumps[0]);
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 2), jumps[1]);
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 3), target);
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps), target);
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 1, "forwarding"), jumps[1]);
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 2, "forwarding"), target);
});
test("connection timeout keeps the auth-ready window for protocols without SSH TCP progress", () => {
assert.equal(getConnectionTimeoutMs({
...baseTimeoutState,
hasSshTcpConnectProgress: false,
}, {
tcpConnectTimeoutMs: 5_000,
authReadyTimeoutMs: 5_000,
}), SSH_AUTH_READY_TIMEOUT_MS);
});
test("TCP dial is only considered passed after an actual transport connection", () => {
assert.equal(hasConnectionPassedTcpDial("connecting"), false);
assert.equal(hasConnectionPassedTcpDial("auth-attempt"), false);
assert.equal(hasConnectionPassedTcpDial("error"), false);
assert.equal(hasConnectionPassedTcpDial("forwarding"), false);
assert.equal(hasConnectionPassedTcpDial("tcp-connected"), true);
assert.equal(hasConnectionPassedTcpDial("authenticating"), true);
assert.equal(hasConnectionPassedTcpDial("connected"), true);
});

View File

@@ -0,0 +1,71 @@
import {
DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS,
DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS,
} from '../../domain/sshConnectionTimeouts';
import type { Host } from '../../domain/models';
export const SSH_TCP_CONNECT_TIMEOUT_MS = DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS * 1000;
export const SSH_AUTH_READY_TIMEOUT_MS = DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS * 1000;
type ConnectionTimeouts = {
tcpConnectTimeoutMs?: number;
authReadyTimeoutMs?: number;
};
type HostConnectionTimeouts = Pick<
Host,
'sshTcpConnectTimeoutSeconds' | 'sshAuthReadyTimeoutSeconds'
>;
export function resolveActiveConnectionTimeoutHost(
targetHost: HostConnectionTimeouts,
chainHosts: HostConnectionTimeouts[],
currentHop?: number,
connectionPhase?: string,
): HostConnectionTimeouts {
const activeHop = connectionPhase === 'forwarding' && currentHop
? currentHop + 1
: currentHop;
if (!activeHop || activeHop > chainHosts.length) return targetHost;
return chainHosts[activeHop - 1] ?? targetHost;
}
type ConnectionTimeoutState = {
status: string;
needsAuth: boolean;
isLocalConnection: boolean;
isSerialConnection: boolean;
hasSshTcpConnectProgress: boolean;
needsHostKeyVerification: boolean;
isConnectionAwaitingUserInput: boolean;
isConnectionPastTcpDial: boolean;
};
export function getConnectionTimeoutMs(
state: ConnectionTimeoutState,
timeouts: ConnectionTimeouts = {},
): number {
const tcpConnectTimeoutMs = timeouts.tcpConnectTimeoutMs ?? SSH_TCP_CONNECT_TIMEOUT_MS;
const authReadyTimeoutMs = timeouts.authReadyTimeoutMs ?? SSH_AUTH_READY_TIMEOUT_MS;
if (!state.hasSshTcpConnectProgress) return SSH_AUTH_READY_TIMEOUT_MS;
return state.isConnectionPastTcpDial
? authReadyTimeoutMs
: tcpConnectTimeoutMs;
}
export function hasConnectionPassedTcpDial(status: string): boolean {
return status === "tcp-connected"
|| status === "authenticating"
|| status === "authenticated"
|| status === "connected"
|| status === "shell";
}
export function shouldRunConnectionTimeout(state: ConnectionTimeoutState): boolean {
return state.status === "connecting"
&& !state.needsAuth
&& !state.isLocalConnection
&& !state.isSerialConnection
&& !state.needsHostKeyVerification
&& !state.isConnectionAwaitingUserInput;
}

View File

@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
COPY_ON_SELECT_USER_GESTURE_RELEASE_MS,
createCopyOnSelectUserGestureTracker,
pulseCopyOnSelectUserCommand,
shouldWriteCopyOnSelect,
subscribeCopyOnSelectUserCommand,
subscribeCopyOnSelectUserGesture,
} from "./copyOnSelect.ts";
test("copy-on-select writes only after a user selection gesture", () => {
assert.equal(shouldWriteCopyOnSelect({
hasText: true,
copyOnSelect: true,
isRestoringSelection: false,
isUserSelection: true,
}), true);
});
test("copy-on-select skips SearchAddon and other programmatic selections", () => {
assert.equal(shouldWriteCopyOnSelect({
hasText: true,
copyOnSelect: true,
isRestoringSelection: false,
isUserSelection: false,
}), false);
});
test("copy-on-select still skips restore and attach snapshots", () => {
assert.equal(shouldWriteCopyOnSelect({
allowCopy: false,
hasText: true,
copyOnSelect: true,
isRestoringSelection: false,
isUserSelection: true,
}), false);
assert.equal(shouldWriteCopyOnSelect({
hasText: true,
copyOnSelect: true,
isRestoringSelection: true,
isUserSelection: true,
}), false);
assert.equal(shouldWriteCopyOnSelect({
hasText: false,
copyOnSelect: true,
isRestoringSelection: false,
isUserSelection: true,
}), false);
assert.equal(shouldWriteCopyOnSelect({
hasText: true,
copyOnSelect: false,
isRestoringSelection: false,
isUserSelection: true,
}), false);
});
test("user gesture stays armed until shortly after pointer-up", () => {
assert.ok(COPY_ON_SELECT_USER_GESTURE_RELEASE_MS < 200);
const scheduled: Array<{ cb: () => void; ms: number }> = [];
const tracker = createCopyOnSelectUserGestureTracker({
setTimeoutFn: ((cb: () => void, ms?: number) => {
scheduled.push({ cb, ms: ms ?? 0 });
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimeoutFn: (() => {}) as typeof clearTimeout,
});
assert.equal(tracker.isActive(), false);
tracker.mark();
assert.equal(tracker.isActive(), true);
tracker.release();
assert.equal(tracker.isActive(), true);
assert.equal(scheduled.at(-1)?.ms, COPY_ON_SELECT_USER_GESTURE_RELEASE_MS);
// A later SearchAddon revival (200ms) must not still look like a drag.
scheduled.at(-1)?.cb();
assert.equal(tracker.isActive(), false);
tracker.dispose();
});
test("marking again cancels a pending release so a new drag can copy", () => {
const cleared: number[] = [];
let nextId = 1;
const tracker = createCopyOnSelectUserGestureTracker({
setTimeoutFn: ((cb: () => void) => {
const id = nextId;
nextId += 1;
void cb;
return id as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimeoutFn: ((id: ReturnType<typeof setTimeout>) => {
cleared.push(id as unknown as number);
}) as typeof clearTimeout,
});
tracker.mark();
tracker.release();
tracker.mark();
assert.deepEqual(cleared, [1]);
assert.equal(tracker.isActive(), true);
tracker.dispose();
});
const listenerKey = (
type: string,
options?: boolean | AddEventListenerOptions,
): string => {
const capture = options === true || (
typeof options === "object" && options?.capture === true
);
return `${type}:${capture ? "capture" : "bubble"}`;
};
const createEventTargetStub = () => {
const listeners = new Map<string, Set<EventListener>>();
return {
addEventListener(
type: string,
listener: EventListener,
options?: boolean | AddEventListenerOptions,
) {
const key = listenerKey(type, options);
const set = listeners.get(key) ?? new Set();
set.add(listener);
listeners.set(key, set);
},
removeEventListener(
type: string,
listener: EventListener,
options?: boolean | AddEventListenerOptions,
) {
listeners.get(listenerKey(type, options))?.delete(listener);
},
dispatch(type: string, event: Event, phase: "capture" | "bubble") {
for (const listener of listeners.get(`${type}:${phase}`) ?? []) {
listener(event);
}
},
};
};
const eventOn = (type: string, target: EventTarget): Event => {
const event = new Event(type);
Object.defineProperty(event, "target", { value: target });
return event;
};
test("pointer listeners mark on capture down and release on document up", () => {
const el = createEventTargetStub();
const root = createEventTargetStub();
const view = createEventTargetStub();
let marked = 0;
let pulsed = 0;
let released = 0;
const unsubscribe = subscribeCopyOnSelectUserGesture(
{ element: el },
{
mark: () => {
marked += 1;
},
release: () => {
released += 1;
},
pulse: () => {
pulsed += 1;
},
},
root,
view,
);
root.dispatch("mousedown", eventOn("mousedown", el), "capture");
root.dispatch("mouseup", eventOn("mouseup", el), "bubble");
root.dispatch("contextmenu", eventOn("contextmenu", el), "capture");
root.dispatch("touchcancel", eventOn("touchcancel", el), "bubble");
view.dispatch("blur", eventOn("blur", el), "bubble");
assert.equal(marked, 1);
assert.equal(released, 3);
assert.equal(pulsed, 1);
unsubscribe();
root.dispatch("mousedown", eventOn("mousedown", el), "capture");
root.dispatch("mouseup", eventOn("mouseup", el), "bubble");
root.dispatch("touchcancel", eventOn("touchcancel", el), "bubble");
view.dispatch("blur", eventOn("blur", el), "bubble");
assert.equal(marked, 1);
assert.equal(released, 3);
assert.equal(pulsed, 1);
});
test("late contextmenu after mouseup is a one-shot and then releases", () => {
const scheduled: Array<() => void> = [];
const tracker = createCopyOnSelectUserGestureTracker({
setTimeoutFn: ((cb: () => void) => {
scheduled.push(cb);
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimeoutFn: (() => {}) as typeof clearTimeout,
});
tracker.mark();
tracker.release();
// Windows: contextmenu after mouseup used to cancel this timer and stick.
tracker.pulse();
assert.equal(tracker.isActive(), true);
scheduled.at(-1)?.();
assert.equal(tracker.isActive(), false);
tracker.dispose();
});
test("document-capture contextmenu still counts when the terminal never sees the bubble", () => {
const el = createEventTargetStub();
const outside = createEventTargetStub();
const root = createEventTargetStub();
let pulsed = 0;
subscribeCopyOnSelectUserGesture(
{ element: el },
{
mark: () => {},
release: () => {},
pulse: () => {
pulsed += 1;
},
},
root,
);
// tmux/vim capture handler on the container stops the event before
// term.element bubble listeners would run.
root.dispatch("contextmenu", eventOn("contextmenu", el), "capture");
root.dispatch("contextmenu", eventOn("contextmenu", outside), "capture");
assert.equal(pulsed, 1);
});
test("user-invoked Select All pulses only the selected terminal", () => {
let pulsedA = 0;
let pulsedB = 0;
const terminalA = { id: "a" };
const terminalB = { id: "b" };
const unsubscribeA = subscribeCopyOnSelectUserCommand(terminalA, () => {
pulsedA += 1;
});
const unsubscribeB = subscribeCopyOnSelectUserCommand(terminalB, () => {
pulsedB += 1;
});
pulseCopyOnSelectUserCommand(terminalA);
assert.equal(pulsedA, 1);
assert.equal(pulsedB, 0);
unsubscribeA();
unsubscribeB();
pulseCopyOnSelectUserCommand(terminalA);
assert.equal(pulsedA, 1);
assert.equal(pulsedB, 0);
});
test("issue 3007: search match then later revival does not copy", () => {
const scheduled: Array<() => void> = [];
const tracker = createCopyOnSelectUserGestureTracker({
setTimeoutFn: ((cb: () => void) => {
scheduled.push(cb);
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout,
clearTimeoutFn: (() => {}) as typeof clearTimeout,
});
// Typing in the search bar selects the match with no terminal pointer.
assert.equal(shouldWriteCopyOnSelect({
hasText: true,
copyOnSelect: true,
isRestoringSelection: false,
isUserSelection: tracker.isActive(),
}), false);
// User drag-selects a docker image id in the buffer.
tracker.mark();
assert.equal(shouldWriteCopyOnSelect({
hasText: true,
copyOnSelect: true,
isRestoringSelection: false,
isUserSelection: tracker.isActive(),
}), true);
tracker.release();
scheduled.at(-1)?.();
// Opening the snippet dialog resizes the terminal; SearchAddon re-selects
// the search term. Clipboard must stay on the image id.
assert.equal(shouldWriteCopyOnSelect({
hasText: true,
copyOnSelect: true,
isRestoringSelection: false,
isUserSelection: tracker.isActive(),
}), false);
tracker.dispose();
});

View File

@@ -0,0 +1,168 @@
/**
* Copy-on-select policy for the xterm selection overlay.
*
* SearchAddon (and other programmatic paths) call terminal.select() to mark
* the active match. Those selection-change events must not write the
* clipboard — otherwise a later user selection is overwritten by the search
* term after a resize/write revival (issue #3007).
*/
export const COPY_ON_SELECT_USER_GESTURE_RELEASE_MS = 80;
export type CopyOnSelectUserGestureTracker = {
mark: () => void;
release: () => void;
/** Mark then release — one-shot gestures such as a late contextmenu. */
pulse: () => void;
isActive: () => boolean;
dispose: () => void;
};
export const createCopyOnSelectUserGestureTracker = ({
releaseDelayMs = COPY_ON_SELECT_USER_GESTURE_RELEASE_MS,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
}: {
releaseDelayMs?: number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
} = {}): CopyOnSelectUserGestureTracker => {
let active = false;
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
const clearReleaseTimer = () => {
if (releaseTimer === null) return;
clearTimeoutFn(releaseTimer);
releaseTimer = null;
};
const mark = () => {
clearReleaseTimer();
active = true;
};
const release = () => {
clearReleaseTimer();
releaseTimer = setTimeoutFn(() => {
releaseTimer = null;
active = false;
}, releaseDelayMs);
};
const pulse = () => {
mark();
release();
};
const dispose = () => {
clearReleaseTimer();
active = false;
};
return {
mark,
release,
pulse,
isActive: () => active,
dispose,
};
};
const CAPTURE = { capture: true } as const;
const eventIsInsideTerminal = (
event: Event,
el: EventTarget,
): boolean => {
const target = event.target;
if (!target) return false;
if (target === el) return true;
const host = el as { contains?: (node: EventTarget) => boolean };
return typeof host.contains === "function" && host.contains(target);
};
export const subscribeCopyOnSelectUserGesture = (
term: { element?: EventTarget | null } | null | undefined,
tracker: Pick<CopyOnSelectUserGestureTracker, "mark" | "release" | "pulse">,
root: Pick<EventTarget, "addEventListener" | "removeEventListener"> | null = (
typeof document === "undefined" ? null : document
),
view: Pick<EventTarget, "addEventListener" | "removeEventListener"> | null = (
typeof window === "undefined" ? null : window
),
): (() => void) => {
const el = term?.element;
if (!el || !root) return () => {};
const onPointerDown = (event: Event) => {
if (!eventIsInsideTerminal(event, el)) return;
tracker.mark();
};
const onContextMenu = (event: Event) => {
if (!eventIsInsideTerminal(event, el)) return;
// Capture on the document so we still see right-clicks that
// useTerminalEffects intercepts with stopImmediatePropagation
// (tmux/vim mouse tracking + select-word). Pulse so a late
// contextmenu after mouseup cannot leave the tracker armed.
tracker.pulse();
};
const onPointerUp = () => tracker.release();
root.addEventListener("mousedown", onPointerDown, CAPTURE);
root.addEventListener("touchstart", onPointerDown, CAPTURE);
root.addEventListener("contextmenu", onContextMenu, CAPTURE);
root.addEventListener("mouseup", onPointerUp);
root.addEventListener("touchend", onPointerUp);
root.addEventListener("touchcancel", onPointerUp);
// Alt-tab / window blur drops the matching mouseup in Electron.
view?.addEventListener("blur", onPointerUp);
return () => {
root.removeEventListener("mousedown", onPointerDown, CAPTURE);
root.removeEventListener("touchstart", onPointerDown, CAPTURE);
root.removeEventListener("contextmenu", onContextMenu, CAPTURE);
root.removeEventListener("mouseup", onPointerUp);
root.removeEventListener("touchend", onPointerUp);
root.removeEventListener("touchcancel", onPointerUp);
view?.removeEventListener("blur", onPointerUp);
};
};
const userCommandPulses = new Map<unknown, () => void>();
/** Select All / Select Word from a shortcut or menu — not SearchAddon. */
export const subscribeCopyOnSelectUserCommand = (
key: unknown,
pulse: () => void,
): (() => void) => {
userCommandPulses.set(key, pulse);
return () => {
if (userCommandPulses.get(key) === pulse) {
userCommandPulses.delete(key);
}
};
};
export const pulseCopyOnSelectUserCommand = (key: unknown): void => {
userCommandPulses.get(key)?.();
};
export const shouldWriteCopyOnSelect = ({
allowCopy = true,
hasText,
copyOnSelect,
isRestoringSelection,
isUserSelection,
}: {
allowCopy?: boolean;
hasText: boolean;
copyOnSelect: boolean;
isRestoringSelection: boolean;
isUserSelection: boolean;
}): boolean => (
allowCopy
&& hasText
&& copyOnSelect
&& !isRestoringSelection
&& isUserSelection
);

View File

@@ -0,0 +1,126 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { extractRootPathsFromDropEntries } from './terminalHelpers.ts';
// Inline copy of extractRootPathsFromClipboardFiles for standalone test
function extractRootPathsFromClipboardFiles(
files: Array<{ path: string; name: string; isDirectory: boolean; size?: number }>,
): string[] {
const paths: string[] = [];
const seenPaths = new Set<string>();
for (const file of files) {
const fullPath = file.path;
if (!fullPath || seenPaths.has(fullPath)) continue;
paths.push(fullPath.includes(' ') ? `"${fullPath}"` : fullPath);
seenPaths.add(fullPath);
}
return paths;
}
describe('extractRootPathsFromClipboardFiles', () => {
it('single file path', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([{
path: '/home/user/file.txt', name: 'file.txt', isDirectory: false, size: 100,
}]),
['/home/user/file.txt'],
);
});
it('multiple files', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([
{ path: '/home/a.txt', name: 'a.txt', isDirectory: false, size: 10 },
{ path: '/home/b.txt', name: 'b.txt', isDirectory: false, size: 20 },
]),
['/home/a.txt', '/home/b.txt'],
);
});
it('quotes paths with spaces', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([{
path: '/home/user/my file.txt', name: 'my file.txt', isDirectory: false, size: 100,
}]),
['"/home/user/my file.txt"'],
);
});
it('deduplicates', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([
{ path: '/home/file.txt', name: 'file.txt', isDirectory: false, size: 10 },
{ path: '/home/file.txt', name: 'file.txt', isDirectory: false, size: 10 },
]),
['/home/file.txt'],
);
});
it('handles directory entries', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([{
path: '/home/myfolder', name: 'myfolder', isDirectory: true, size: 0,
}]),
['/home/myfolder'],
);
});
it('filters out empty paths', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([{
path: '', name: 'empty', isDirectory: false,
}]),
[],
);
});
it('Windows-style paths', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([{
path: 'C:\\Users\\test\\file.txt', name: 'file.txt', isDirectory: false, size: 100,
}]),
['C:\\Users\\test\\file.txt'],
);
});
it('empty list', () => {
assert.deepEqual(extractRootPathsFromClipboardFiles([]), []);
});
it('multiple spaced paths', () => {
assert.deepEqual(
extractRootPathsFromClipboardFiles([
{ path: '/home/user/a b.txt', name: 'a b.txt', isDirectory: false, size: 10 },
{ path: '/home/user/c d.txt', name: 'c d.txt', isDirectory: false, size: 20 },
]),
['"/home/user/a b.txt"', '"/home/user/c d.txt"'],
);
});
});
describe('extractRootPathsFromDropEntries', () => {
it('uses a reconstructed DropEntry local path when File.path is unavailable', () => {
const fileWithoutPath = { name: 'child.txt' } as File;
assert.deepEqual(
extractRootPathsFromDropEntries([{
file: fileWithoutPath,
localPath: '/home/user/folder/child.txt',
relativePath: 'folder/child.txt',
isDirectory: false,
}]),
['/home/user/folder'],
);
});
it('uses native path-only entries from a folder scan', () => {
assert.deepEqual(
extractRootPathsFromDropEntries([{
file: null,
localPath: '/home/user/folder/src/main.ts',
relativePath: 'folder/src/main.ts',
isDirectory: false,
}]),
['/home/user/folder'],
);
});
});

View File

@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { COMMON_FIG_SPECS, normalizeCommandName } from "./autocomplete/figSpecLoader";
test("preload common specs includes dnf alongside yum and apt", () => {
assert.ok(COMMON_FIG_SPECS.includes("apt"));
assert.ok(COMMON_FIG_SPECS.includes("yum"));
assert.ok(COMMON_FIG_SPECS.includes("dnf"));
});
test("normalizeCommandName strips path and extension", () => {
assert.equal(normalizeCommandName("/usr/bin/dnf"), "dnf");
assert.equal(normalizeCommandName("DNF"), "dnf");
});

View File

@@ -0,0 +1,172 @@
import test from "node:test";
import assert from "node:assert/strict";
import { focusTerminalSessionInput, hasOpenAppDialog } from "./focusTerminalSession";
test("focusTerminalSessionInput focuses the xterm helper textarea immediately and after scheduled retries", () => {
const focusCalls: string[] = [];
const textarea = {
focus: () => focusCalls.push("focus"),
};
const pane = {
querySelector: (selector: string) => {
assert.equal(selector, "textarea.xterm-helper-textarea");
return textarea;
},
};
const queriedSelectors: string[] = [];
const doc = {
querySelector: (selector: string) => {
queriedSelectors.push(selector);
if (selector === '[role="dialog"][data-state="open"]') return null;
return pane;
},
};
const timeouts: number[] = [];
focusTerminalSessionInput("session-1", {
document: doc,
requestAnimationFrame: (callback) => {
callback();
return 1;
},
setTimeout: (callback, delay) => {
timeouts.push(delay);
callback();
return delay;
},
});
assert.deepEqual(queriedSelectors, [
'[role="dialog"][data-state="open"]',
'[data-session-id="session-1"]',
'[role="dialog"][data-state="open"]',
'[data-session-id="session-1"]',
]);
assert.deepEqual(timeouts, [50]);
assert.deepEqual(focusCalls, ["focus", "focus"]);
});
test("hasOpenAppDialog detects open Radix dialogs", () => {
assert.equal(hasOpenAppDialog(null), false);
assert.equal(hasOpenAppDialog({ querySelector: () => null }), false);
assert.equal(
hasOpenAppDialog({
querySelector: (selector) => (
selector === '[role="dialog"][data-state="open"]' ? {} : null
),
}),
true,
);
});
test("focusTerminalSessionInput skips textarea focus while an app dialog is open", () => {
const focusCalls: string[] = [];
const events: string[] = [];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
dispatchEvent: (event: Event) => {
events.push((event as CustomEvent<{ sessionId: string }>).detail.sessionId);
return true;
},
},
});
try {
focusTerminalSessionInput("session-1", {
document: {
querySelector: (selector) => {
if (selector === '[role="dialog"][data-state="open"]') return {};
if (selector === '[data-session-id="session-1"]') {
return {
querySelector: () => ({
focus: () => focusCalls.push("focus"),
}),
};
}
return null;
},
},
requestAnimationFrame: (callback) => {
callback();
return 1;
},
setTimeout: (callback) => {
callback();
return 0;
},
});
assert.deepEqual(focusCalls, []);
assert.deepEqual(events, ["session-1", "session-1"]);
} finally {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: undefined,
});
}
});
test("focusTerminalSessionInput dispatches a terminal restore focus event", () => {
const events: string[] = [];
const handler = (event: Event) => {
events.push((event as CustomEvent<{ sessionId: string }>).detail.sessionId);
};
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
dispatchEvent: (event: Event) => {
handler(event);
return true;
},
},
});
try {
focusTerminalSessionInput("session-1", {
document: {
querySelector: (selector) => {
if (selector === '[role="dialog"][data-state="open"]') return null;
return {
querySelector: () => ({ focus: () => undefined }),
};
},
},
requestAnimationFrame: (callback) => {
callback();
return 1;
},
setTimeout: (callback) => {
callback();
return 0;
},
});
assert.deepEqual(events, ["session-1", "session-1"]);
} finally {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
});
}
});
test("focusTerminalSessionInput ignores empty or unavailable targets", () => {
assert.doesNotThrow(() => {
focusTerminalSessionInput(null, {
document: undefined,
requestAnimationFrame: (callback) => {
callback();
return 1;
},
setTimeout: (callback, delay) => {
callback();
return delay;
},
});
});
});

View File

@@ -0,0 +1,89 @@
type QueryRoot = {
querySelector: (selector: string) => unknown | null;
};
type QueryTarget = QueryRoot & {
querySelector: (selector: string) => QueryTarget | FocusableTarget | null;
};
type FocusableTarget = {
focus?: () => void;
};
/** Skip terminal refocus while a Radix dialog is open so deferred tmux actions do not steal modal focus. */
export const hasOpenAppDialog = (
doc: QueryRoot | null = typeof document !== "undefined" ? document : null,
): boolean => {
if (!doc) return false;
return doc.querySelector('[role="dialog"][data-state="open"]') !== null;
};
interface FocusTerminalSessionInputOptions {
document?: QueryTarget | null;
requestAnimationFrame?: (callback: () => void) => unknown;
setTimeout?: (callback: () => void, delay: number) => unknown;
retryDelays?: readonly number[];
}
const escapeAttributeValue = (value: string): string =>
value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
export const TERMINAL_SESSION_RESTORE_FOCUS_EVENT = "netcatty:terminal-session-restore-focus";
export type TerminalSessionRestoreFocusDetail = {
sessionId: string;
};
const dispatchTerminalSessionRestoreFocus = (sessionId: string): void => {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<TerminalSessionRestoreFocusDetail>(
TERMINAL_SESSION_RESTORE_FOCUS_EVENT,
{ detail: { sessionId } },
));
};
export const focusTerminalSessionInput = (
sessionId: string | null | undefined,
options: FocusTerminalSessionInputOptions = {},
): void => {
if (!sessionId) return;
const doc = options.document ?? (typeof document !== "undefined" ? document : null);
if (!doc) return;
const raf = options.requestAnimationFrame
?? (typeof requestAnimationFrame !== "undefined"
? requestAnimationFrame
: (callback: () => void) => {
callback();
return undefined;
});
const scheduleTimeout = options.setTimeout
?? (typeof setTimeout !== "undefined"
? setTimeout
: (callback: () => void) => {
callback();
return undefined;
});
const retryDelays = options.retryDelays ?? [50];
const paneSelector = `[data-session-id="${escapeAttributeValue(sessionId)}"]`;
const focusTarget = () => {
if (hasOpenAppDialog(doc)) {
dispatchTerminalSessionRestoreFocus(sessionId);
return;
}
const pane = doc.querySelector(paneSelector) as QueryTarget | null;
const textarea = pane?.querySelector("textarea.xterm-helper-textarea") as FocusableTarget | null;
textarea?.focus?.();
dispatchTerminalSessionRestoreFocus(sessionId);
};
raf(() => {
focusTarget();
retryDelays.forEach((delay) => {
scheduleTimeout(focusTarget, delay);
});
});
};

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { lineHasUntrackedTrailingInput } from "./autocomplete/ghostTextConsistency.ts";
test("keeps the ghost when the line matches the tracked input (in sync)", () => {
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network int"), false);
});
test("hides the ghost when the device echoed untracked trailing input (#1013)", () => {
// Tracked is one char behind what the device actually shows.
assert.equal(lineHasUntrackedTrailingInput("network in", "ecOS# network int"), true);
});
test("keeps the ghost during echo latency (line is behind the tracked input)", () => {
// The tracked input hasn't been fully echoed yet — reality being behind
// never corrupts, so the ghost must stay.
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network in"), false);
});
test("ignores trailing whitespace after the tracked input", () => {
assert.equal(lineHasUntrackedTrailingInput("git", "$ git "), false);
});
test("hides when untracked non-space input follows the tracked input", () => {
assert.equal(lineHasUntrackedTrailingInput("git", "$ git push"), true);
});
test("uses the last occurrence so a repeated token earlier on the line is ignored", () => {
// Prompt contains 'int'; the real typed 'int' is the one at the end.
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ int"), false);
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ intf"), true);
});
test("skips non-ASCII input (wide-char column mapping is ambiguous)", () => {
assert.equal(lineHasUntrackedTrailingInput("网络", "$ 网络口"), false);
});
test("skips single-character input", () => {
assert.equal(lineHasUntrackedTrailingInput("l", "$ lx"), false);
});
test("returns false when the tracked input isn't on the line yet (latency)", () => {
assert.equal(lineHasUntrackedTrailingInput("systemctl", "$ sys"), false);
});

View File

@@ -0,0 +1,369 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import xterm from "@xterm/xterm";
import serializeMod from "@xterm/addon-serialize";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
applyHibernateWakeToTerminal,
resolveHibernateWakeHistory,
} from "./terminalHibernateRuntime.ts";
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
const { Terminal } = xterm;
const { SerializeAddon } = serializeMod;
const readActiveBufferText = (term: XTerm): string => {
const buffer = term.buffer.active;
const lines: string[] = [];
for (let index = 0; index < buffer.length; index += 1) {
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
}
return lines.join("\n");
};
const writeAndWait = (term: XTerm, data: string): Promise<void> =>
new Promise((resolve) => {
term.write(data, () => resolve());
});
test("hibernate wake pauses flow before replay and resumes only after reattach", () => {
// #2762 / Codex: full-history wake must not race the capped pending buffer or
// the 64 KiB preload backlog. Pause+wait drains into pending, then stop the
// data listener, take pending once, replay, reattach, and only then resume.
const mountSource = readFileSync(new URL("./terminalRuntimeMount.ts", import.meta.url), "utf8");
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
assert.match(mountSource, /prepareWakeFlow\?:\s*\(\) => Promise<boolean>/);
assert.match(mountSource, /restoreAfterFailedWake\?:\s*\(takenPending: string\) => void/);
assert.match(mountSource, /resumeAfterReattach\?:\s*\(\) => void/);
assert.match(mountSource, /takePendingBuffer: \(\) => string/);
assert.match(mountSource, /stopHibernateDataListener: \(\) => void/);
assert.match(mountSource, /const drainOk = \(await prepareWakeFlow\?\.\(\)\) \?\? true;/);
assert.match(mountSource, /const takeAndTrackPending = \(\): string =>/);
assert.match(mountSource, /const pendingAtApplyStart = takeAndTrackPending\(\);/);
assert.match(mountSource, /const pendingTail = takeAndTrackPending\(\);/);
assert.match(mountSource, /if \(!pendingTail\) break;/);
assert.match(mountSource, /restoreAfterFailedWake\?\.\(takenPendingForRestore\)/);
assert.doesNotMatch(mountSource, /pending\.slice\(replayedPendingLength\)/);
assert.doesNotMatch(mountSource, /for \(let drainPass = 0;/);
assert.doesNotMatch(mountSource, /finalPendingDelta/);
assert.doesNotMatch(mountSource, /preTeardownTail/);
const prepareIndex = mountSource.indexOf("const drainOk = (await prepareWakeFlow?.()) ?? true;");
const disableCapIndex = mountSource.indexOf("setHibernatePendingCapDisabled?.(true);");
const stopDataBeforeReplay = mountSource.indexOf("if (drainOk) {\n stopHibernateDataListener();");
const pendingIndex = mountSource.indexOf("const pendingAtApplyStart = takeAndTrackPending();");
const applyIndex = mountSource.indexOf("await applyHibernateWakeToTerminal(");
const stopDataBeforeTail = mountSource.indexOf(
"stopHibernateDataListener();",
applyIndex,
);
const pendingTailIndex = mountSource.indexOf("const pendingTail = takeAndTrackPending();");
const emptyBreakIndex = mountSource.indexOf("if (!pendingTail) break;");
const shouldReattachIndex = mountSource.indexOf(
"const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);",
);
const stopAllIndex = mountSource.indexOf("stopHibernateListeners();", shouldReattachIndex);
const reattachIndex = mountSource.indexOf("reattachSession(term);", shouldReattachIndex);
const failedRestoreIndex = mountSource.indexOf("restoreAfterFailedWake?.(takenPendingForRestore);");
const resumeIndex = mountSource.indexOf("resumeAfterReattach?.();");
assert.ok(prepareIndex >= 0, "wake must pause backend flow before history replay");
assert.ok(
disableCapIndex > prepareIndex,
"drain failure must disable the pending cap before keeping the live listener",
);
assert.ok(stopDataBeforeReplay > prepareIndex, "successful drain stops the data listener before replay");
assert.ok(pendingIndex > stopDataBeforeReplay, "pending take must run after drain handling");
assert.ok(applyIndex > pendingIndex, "history replay follows the pending capture");
assert.ok(
stopDataBeforeTail > applyIndex,
"data listener must stop again before residual pending drain",
);
assert.ok(
pendingTailIndex > stopDataBeforeTail,
"residual pending drain must run after history replay",
);
assert.ok(
emptyBreakIndex > pendingTailIndex,
"residual drain must end with an empty take before teardown",
);
assert.ok(
shouldReattachIndex > emptyBreakIndex,
"reattach decision must run after until-empty pending drain",
);
assert.ok(
stopAllIndex > shouldReattachIndex,
"full hibernate listener teardown must wait until after the reattach decision",
);
assert.ok(reattachIndex > stopAllIndex, "reattach runs after hibernate listeners are cleared");
assert.ok(failedRestoreIndex >= 0, "failed wakes must restore take-and-cleared pending");
assert.ok(resumeIndex > reattachIndex, "flow resume must wait until after reattach");
assert.match(
mountSource,
/if \(!wakeSucceeded\) \{[\s\S]*?restoreAfterFailedWake\?\.\(takenPendingForRestore\);[\s\S]*?\} else if \(didReattach\) \{[\s\S]*?resumeAfterReattach\?\.\(\);/,
);
assert.match(
terminalSource,
/takePendingBuffer:\s*\(\)\s*=>\s*\{\s*const pending = hibernatePendingBufferRef\.current \+ oscNotificationScannerRef\.current\.flush\(\);\s*hibernatePendingBufferRef\.current = "";\s*return pending;\s*\}/,
);
assert.match(
terminalSource,
/setSessionFlowPausedAndWait\(backendId,\s*true\)/,
);
// Reconnect wakes (sessionConnected=false) must still pause when a backend
// session exists; otherwise stopping the hibernate listener drops live output.
assert.doesNotMatch(
terminalSource,
/prepareWakeFlow: async \(\) => \{\s*if \(!options\.sessionConnected\) return true;/,
);
assert.match(
terminalSource,
/stopHibernateListeners\(\{\s*keepPaused:\s*true\s*\}\)/,
);
assert.match(
terminalSource,
/restoreAfterFailedWake:\s*\(takenPending\)\s*=>\s*\{[\s\S]*?disposeRuntimeOnly\(\);[\s\S]*?beginHibernatedSessionListeners\(backendId\)/,
);
assert.match(
terminalSource,
/appendHibernatePendingBuffer\(\s*takenPending \|\| "",\s*pendingStillInRef,\s*\)/,
);
assert.match(
terminalSource,
/resumeAfterReattach:\s*\(\)\s*=>\s*\{[\s\S]*?setSessionFlowPaused\?\.\(backendId,\s*false\)/,
);
assert.match(
terminalSource,
/hibernatePendingCapDisabledRef\.current\s*\?\s*hibernatePendingBufferRef\.current \+ scanned\.remainder/,
);
assert.match(
terminalSource,
/result\?\.success === true/,
);
});
test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", async () => {
const writes: string[] = [];
const term = {
write: (data: string, cb: () => void) => {
writes.push(data);
cb();
},
} as unknown as XTerm;
const payload = "y".repeat(50_000);
await writeTerminalPayloadChunked(term, payload, { chunkBytes: 8_192 });
assert.ok(writes.length >= 2, `expected multiple chunks, got ${writes.length}`);
assert.equal(writes.join(""), payload);
});
test("resolveHibernateWakeHistory prefers the coherent full snapshot", () => {
assert.equal(
resolveHibernateWakeHistory({
snapshot: "FULL",
viewportSnapshot: "VIEW",
scrollbackSnapshot: "SCROLL",
pendingBuffer: "",
alternateScreen: false,
}),
"FULL",
);
});
test("resolveHibernateWakeHistory falls back to scrollback before viewport with a seam newline", () => {
assert.equal(
resolveHibernateWakeHistory({
snapshot: "",
viewportSnapshot: "VIEWPORT_END\r\n",
scrollbackSnapshot: "SCROLLBACK_START",
pendingBuffer: "",
alternateScreen: false,
}),
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
);
assert.equal(
resolveHibernateWakeHistory({
snapshot: "",
viewportSnapshot: "VIEWPORT_END\r\n",
scrollbackSnapshot: "SCROLLBACK_START\r\n",
pendingBuffer: "",
alternateScreen: false,
}),
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
);
});
test("applyHibernateWakeToTerminal replays snapshot then pending without idle append", async () => {
const writes: string[] = [];
const term = {
rows: 24,
write: (data: string, cb?: () => void) => {
writes.push(data);
cb?.();
},
refresh: () => {},
} as unknown as XTerm;
const runtime = {
ensureWebglRenderer: () => {},
clearTextureAtlas: () => {},
};
let idleScheduled = false;
const originalRic = globalThis.requestIdleCallback;
// @ts-expect-error test override
globalThis.requestIdleCallback = (cb: () => void) => {
idleScheduled = true;
setTimeout(cb, 0);
return 1;
};
try {
const snapshot = "SCROLLBACK_START\r\nVIEWPORT_END\r\n";
const pending = "PENDING_TAIL\r\n";
await applyHibernateWakeToTerminal(
term,
runtime as never,
{
snapshot,
viewportSnapshot: "VIEWPORT_END\r\n",
scrollbackSnapshot: "SCROLLBACK_START\r\n",
pendingBuffer: pending,
alternateScreen: false,
},
{ replayOptions: { chunkBytes: 8_192 } },
);
assert.equal(writes.join(""), `${snapshot}${pending}`);
assert.equal(
idleScheduled,
false,
"scrollback must not be deferred to idle after viewport (append would evict the end)",
);
} finally {
if (originalRic) {
globalThis.requestIdleCallback = originalRic;
} else {
// @ts-expect-error cleanup
delete globalThis.requestIdleCallback;
}
}
});
test("hibernate wake keeps newest rows from a SerializeAddon snapshot under a finite scrollback cap", async () => {
// #2762: idle-appending older scrollback after viewport under scrollback=N
// evicts the newest rows. Prefer the full SerializeAddon snapshot.
const rows = 5;
const scrollbackCap = 8;
const source = new Terminal({
cols: 40,
rows,
scrollback: 50,
allowProposedApi: true,
});
const serializeAddon = new SerializeAddon();
source.loadAddon(serializeAddon);
const olderLines = Array.from({ length: 20 }, (_, index) => `old-${index}`);
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
await writeAndWait(source, `${olderLines.join("\r\n")}\r\n${newestLines.join("\r\n")}\r\n`);
const snapshot = serializeAddon.serialize();
const bufferLength = source.buffer.active.length;
const viewportStart = Math.max(0, bufferLength - rows);
const scrollbackSnapshot = serializeAddon.serialize({
range: { start: Math.max(0, viewportStart - 20), end: viewportStart - 1 },
});
const viewportSnapshot = serializeAddon.serialize({
range: { start: viewportStart, end: bufferLength - 1 },
});
source.dispose();
// Range concat is not byte-identical to the full snapshot (missing seam newline).
assert.notEqual(scrollbackSnapshot + viewportSnapshot, snapshot);
const term = new Terminal({
cols: 40,
rows,
scrollback: scrollbackCap,
allowProposedApi: true,
});
const runtime = {
ensureWebglRenderer: () => {},
clearTextureAtlas: () => {},
};
try {
await applyHibernateWakeToTerminal(
term,
runtime as never,
{
snapshot,
viewportSnapshot,
scrollbackSnapshot,
pendingBuffer: "",
alternateScreen: false,
},
{ replayOptions: { chunkBytes: 1024 } },
);
await new Promise((resolve) => setTimeout(resolve, 30));
const text = readActiveBufferText(term);
for (const line of newestLines) {
assert.match(text, new RegExp(line), `newest viewport line missing after wake: ${line}`);
}
assert.doesNotMatch(text, /new-0new-1/, "seam must not merge adjacent snapshot lines");
} finally {
term.dispose();
}
});
test("wrong wake order (viewport then scrollback append) drops newest rows under scrollback cap", async () => {
// Guardrail: documents why idle-append-after-viewport is unsafe.
const rows = 5;
const scrollbackCap = 10;
const term = new Terminal({
cols: 40,
rows,
scrollback: scrollbackCap,
allowProposedApi: true,
});
try {
const olderLines = Array.from({ length: scrollbackCap + rows }, (_, index) => `old-${index}`);
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
await writeAndWait(term, `${newestLines.join("\r\n")}\r\n`);
await writeAndWait(term, `${olderLines.join("\r\n")}\r\n`);
const text = readActiveBufferText(term);
for (const line of newestLines) {
assert.equal(
text.includes(line),
false,
`viewport-first append must evict newest line under the cap: ${line}`,
);
}
assert.match(text, /old-14/);
} finally {
term.dispose();
}
});
test("hibernate runtime source prefers resolveHibernateWakeHistory on the wake path", () => {
const source = readFileSync(new URL("./terminalHibernateRuntime.ts", import.meta.url), "utf8");
assert.match(source, /export function resolveHibernateWakeHistory/);
assert.match(
source,
/writeTerminalReplaySequence\(\s*term,\s*\[\s*history,\s*payload\.pendingBuffer\s*\]/,
);
assert.doesNotMatch(
source,
/scheduleIdle\(\(\)\s*=>\s*\{\s*void writeTerminalPayloadChunked\(term, scrollback/,
);
});

View File

@@ -0,0 +1,8 @@
/** @deprecated Import from `@/application/state/useServerStats` instead. */
export {
useServerStats,
type DiskInfo,
type NetInterfaceInfo,
type ProcessInfo,
type ServerStats,
} from "../../../application/state/useServerStats";

View File

@@ -0,0 +1,186 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { RefObject } from "react";
import type { Host, TerminalSession } from "../../../types";
import type { PendingAuth } from "../runtime/createTerminalSessionStarters";
import type { TerminalAuthMethod } from "../TerminalAuthDialog";
import { logger } from "../../../lib/logger";
/**
* Password auth is valid when the user typed something — including a single
* space. SSH passwords may be whitespace-only; do not trim before this check
* (issue #2036).
*/
export const isAuthPasswordProvided = (password: string): boolean =>
password.length > 0;
export const buildSavedAuthHostUpdate = (
host: Host,
auth: {
authMethod: TerminalAuthMethod;
username: string;
password: string;
keyId: string | null;
},
): Host => ({
...host,
username: auth.username,
authMethod: auth.authMethod,
password: auth.authMethod === "password" ? auth.password : undefined,
savePassword: auth.authMethod === "password" ? true : host.savePassword,
identityFileId:
auth.authMethod === "key" || auth.authMethod === "certificate"
? (auth.keyId ?? undefined)
: undefined,
// Detach stale Keychain identity on explicit credential save (#1956):
// resolveHostAuth prefers identity credentials over host fields.
// Empty string (not undefined) so applyGroupDefaults treats this as an explicit
// host-level override and does not re-inherit a group-level identity; consumers
// check host.identityId truthiness so "" behaves as "no identity".
identityId: "",
});
export const useTerminalAuthState = ({
host,
pendingAuthRef,
termRef,
onUpdateHost,
onStartSession,
setStatus,
setProgressLogs,
}: {
host: Host;
pendingAuthRef: RefObject<PendingAuth>;
termRef: RefObject<XTerm | null>;
onUpdateHost?: (host: Host) => void;
onStartSession: (term: XTerm) => void;
setStatus: (status: TerminalSession["status"]) => void;
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
}) => {
const [needsAuth, setNeedsAuth] = useState(false);
const [authRetryMessage, setAuthRetryMessage] = useState<string | null>(null);
const [authUsername, setAuthUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
const [authPassword, setAuthPassword] = useState("");
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
const [authPassphrase, setAuthPassphrase] = useState("");
const [showAuthPassword, setShowAuthPassword] = useState(false);
const [showAuthPassphrase, setShowAuthPassphrase] = useState(false);
const [saveCredentials, setSaveCredentials] = useState(true);
useEffect(() => {
setNeedsAuth(false);
setAuthRetryMessage(null);
setAuthUsername(host.username || "root");
setAuthPassword("");
setAuthKeyId(null);
setAuthPassphrase("");
setShowAuthPassword(false);
setShowAuthPassphrase(false);
setSaveCredentials(true);
}, [host.id, host.username]);
const isValid = useMemo(() => {
if (!authUsername.trim()) return false;
if (authMethod === "password") return isAuthPasswordProvided(authPassword);
if (authMethod === "key" || authMethod === "certificate") return !!authKeyId;
return false;
}, [authKeyId, authMethod, authPassword, authUsername]);
const resetForRetry = useCallback(() => {
setNeedsAuth(false);
setAuthRetryMessage(null);
pendingAuthRef.current = null;
}, [pendingAuthRef]);
const submit = useCallback(
(opts?: { saveToHost?: boolean }) => {
if (!isValid) return;
const shouldSave = opts?.saveToHost ?? saveCredentials;
pendingAuthRef.current = {
authMethod,
username: authUsername,
password: authMethod === "password" ? authPassword : undefined,
keyId:
authMethod === "key" || authMethod === "certificate"
? (authKeyId ?? undefined)
: undefined,
passphrase:
authMethod === "key" || authMethod === "certificate"
? authPassphrase || undefined
: undefined,
savedToHost: shouldSave && Boolean(onUpdateHost),
};
if (shouldSave && onUpdateHost) {
onUpdateHost(
buildSavedAuthHostUpdate(host, {
authMethod,
username: authUsername,
password: authPassword,
keyId: authKeyId,
}),
);
}
setNeedsAuth(false);
setAuthRetryMessage(null);
setStatus("connecting");
setProgressLogs(["Authenticating with provided credentials..."]);
const term = termRef.current;
if (!term) return;
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal", err);
}
onStartSession(term);
},
[
authKeyId,
authMethod,
authPassphrase,
authPassword,
authUsername,
host,
isValid,
onStartSession,
onUpdateHost,
pendingAuthRef,
saveCredentials,
setProgressLogs,
setStatus,
termRef,
],
);
return {
needsAuth,
setNeedsAuth,
authRetryMessage,
setAuthRetryMessage,
authUsername,
setAuthUsername,
authMethod,
setAuthMethod,
authPassword,
setAuthPassword,
authKeyId,
setAuthKeyId,
authPassphrase,
setAuthPassphrase,
showAuthPassword,
setShowAuthPassword,
showAuthPassphrase,
setShowAuthPassphrase,
saveCredentials,
setSaveCredentials,
isValid,
resetForRetry,
submit,
};
};

View File

@@ -0,0 +1,250 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback } from "react";
import type { RefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
import { clearTerminalViewportAndSyncPty } from "../clearTerminalViewport";
import {
handleRemoteClipboardImageUpload,
type RemoteClipboardImageUploadResult,
} from "../clipboardImagePaste";
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
import { pulseCopyOnSelectUserCommand } from "../copyOnSelect";
import { getTerminalSelectionForClipboard } from "../normalizeTerminalSelection";
import {
getHistoryPreviewSelectionFromRoot,
requestHistoryPreviewHide,
selectHistoryPreviewAll,
findHistoryPreviewOverlay,
} from "../runtime/terminalHistoryScrollOverride";
type BroadcastPasteRefs = {
sourceSessionId: string;
sessionRef: RefObject<string | null>;
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
passwordPromptActiveRef?: RefObject<boolean | undefined>;
};
export const broadcastTerminalPasteData = (
data: string,
{
sourceSessionId,
sessionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
}: BroadcastPasteRefs,
): boolean => {
if (
passwordPromptActiveRef?.current !== true
&& sessionRef.current
&& isBroadcastEnabledRef?.current
&& onBroadcastInputRef?.current
) {
onBroadcastInputRef.current(data, sourceSessionId);
return true;
}
return false;
};
export const useTerminalContextActions = ({
termRef,
sourceSessionId,
sessionRef,
onHasSelectionChange,
scrollOnPasteRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
clearWipesScrollbackRef,
normalizeTextOnCopyRef,
terminalBackend,
getRemoteCwd,
scrollToBottomAfterProgrammaticInput,
onClipboardImageUploadResult,
}: {
termRef: RefObject<XTerm | null>;
sourceSessionId: string;
sessionRef: RefObject<string | null>;
onHasSelectionChange?: (hasSelection: boolean) => void;
scrollOnPasteRef?: RefObject<boolean>;
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
passwordPromptActiveRef?: RefObject<boolean | undefined>;
isLocalConnection: boolean;
supportsRemoteImagePaste: boolean;
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
autoUploadClipboardImageOnPasteRef?: RefObject<boolean | undefined>;
clearWipesScrollbackRef?: RefObject<boolean | undefined>;
/** When false, copy uses raw getSelection(). Default true when unset. */
normalizeTextOnCopyRef?: RefObject<boolean | undefined>;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
clearSessionPtyBuffer?: (sessionId: string) => void;
};
getRemoteCwd?: () => Promise<string | null | undefined>;
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
}) => {
const broadcastUserPasteData = useCallback((data: string) => {
return broadcastTerminalPasteData(data, {
sourceSessionId,
sessionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
});
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, sessionRef, sourceSessionId]);
const onCopy = useCallback(() => {
const term = termRef.current;
if (!term) return;
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|| getTerminalSelectionForClipboard(
term,
normalizeTextOnCopyRef?.current ?? true,
);
if (selection) {
navigator.clipboard.writeText(selection);
}
}, [normalizeTextOnCopyRef, termRef]);
const onPaste = useCallback(async () => {
const term = termRef.current;
if (!term) return;
requestHistoryPreviewHide(term.element?.parentElement);
term.focus();
try {
const bridge = netcattyBridge.get();
await handleTerminalClipboardPaste({
bridge,
autoUploadClipboardImage:
supportsRemoteImagePaste && autoUploadClipboardImageOnPasteRef?.current === true,
clipboardImageBridge: bridge ?? undefined,
getRemoteCwd,
isLocalConnection,
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
sessionId: sessionRef.current,
scrollToBottomAfterProgrammaticInput,
terminalBackend,
term,
});
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}
}, [
autoUploadClipboardImageOnPasteRef,
broadcastUserPasteData,
getRemoteCwd,
isLocalConnection,
onClipboardImageUploadResult,
passwordPromptActiveRef,
sessionRef,
supportsRemoteImagePaste,
termRef,
scrollOnPasteRef,
scrollToBottomAfterProgrammaticInput,
terminalBackend,
]);
const onUploadClipboardImage = useCallback(async () => {
const term = termRef.current;
if (!term) return;
try {
const bridge = netcattyBridge.get();
const result = await handleRemoteClipboardImageUpload({
bridge,
getRemoteCwd: getRemoteCwd ?? (async () => undefined),
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
sessionId: supportsRemoteImagePaste ? sessionRef.current : null,
terminalBackend,
term,
scrollToBottomAfterProgrammaticInput,
});
onClipboardImageUploadResult?.(result);
} catch (err) {
logger.warn("Failed to upload clipboard image", err);
onClipboardImageUploadResult?.({ ok: false, reason: "upload-failed" });
}
}, [
getRemoteCwd,
passwordPromptActiveRef,
onClipboardImageUploadResult,
scrollToBottomAfterProgrammaticInput,
sessionRef,
supportsRemoteImagePaste,
termRef,
terminalBackend,
]);
const onPasteSelection = useCallback(() => {
const term = termRef.current;
if (!term) return;
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|| getTerminalSelectionForClipboard(
term,
normalizeTextOnCopyRef?.current ?? true,
);
if (!selection || !sessionRef.current) return;
requestHistoryPreviewHide(term.element?.parentElement);
term.focus();
pasteTextIntoTerminal(term, selection, {
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
});
}, [broadcastUserPasteData, normalizeTextOnCopyRef, sessionRef, termRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;
if (!term) return;
pulseCopyOnSelectUserCommand(term);
const previewOverlay = findHistoryPreviewOverlay(term.element?.parentElement);
if (previewOverlay && selectHistoryPreviewAll(previewOverlay)) {
onHasSelectionChange?.(true);
return;
}
term.selectAll();
onHasSelectionChange?.(true);
}, [onHasSelectionChange, termRef]);
const onClear = useCallback(() => {
const term = termRef.current;
if (!term) return;
clearTerminalViewportAndSyncPty(term, {
wipeScrollback: clearWipesScrollbackRef?.current ?? true,
syncPty: () => {
const id = sessionRef.current;
if (id) {
terminalBackend.clearSessionPtyBuffer?.(id);
}
},
});
}, [clearWipesScrollbackRef, sessionRef, termRef, terminalBackend]);
const onSelectWord = useCallback(() => {
const term = termRef.current;
if (!term) return;
pulseCopyOnSelectUserCommand(term);
term.selectAll();
onHasSelectionChange?.(true);
}, [onHasSelectionChange, termRef]);
return {
onCopy,
onPaste,
onUploadClipboardImage: supportsRemoteImagePaste ? onUploadClipboardImage : undefined,
onPasteSelection,
onSelectAll,
onClear,
onSelectWord,
};
};

View File

@@ -0,0 +1,430 @@
import { Terminal as XTerm } from "@xterm/xterm";
import type React from "react";
import { useRef, useState } from "react";
import { logger } from "../../../lib/logger";
import {
buildZmodemDragDropFiles,
buildZmodemDragDropUploadCommand,
containsZmodemRzMissingMarker,
createZmodemRzMissingToken,
supportsZmodemDragDropSftpFallback,
supportsZmodemTerminalDragDrop,
type ZmodemDragDropFile,
} from "../../../lib/zmodemDragDrop";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import type { Host, TerminalSession } from "../../../types";
import { resolveSftpReuseSourceSessionId } from "../../../application/state/terminalConnectionReuse";
import {
resolveTerminalDropSftpHost,
TerminalDropNeedsSudoError,
} from "../../../domain/sftpDropElevation";
import { toast } from "../../ui/toast";
import {
extractRootPathsFromDropEntries,
type TerminalProps,
} from "../terminalHelpers";
interface UseTerminalDragDropOptions {
host: Host;
/** Password already resolved through host auth (host or Keychain identity). */
resolvedSudoPassword?: string;
/** Login username already resolved through host auth (host or Keychain identity). */
resolvedLoginUsername?: string;
isLocalConnection: boolean;
isNetworkDevice?: boolean;
onOpenSftp?: TerminalProps["onOpenSftp"];
resolveSftpInitialPath: (options?: {
preferFreshBackend?: boolean;
requireActiveShellCwd?: boolean;
}) => Promise<string | undefined>;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
sessionId: string;
sessionRef: React.MutableRefObject<string | null>;
status: TerminalSession["status"];
t: (key: string) => string;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
onZmodemEvent?: (
sessionId: string,
cb: (event: { type: string; transferType?: string }) => void,
) => () => void;
startZmodemDragDropUpload?: (
sessionId: string,
files: ZmodemDragDropFile[],
uploadCommand?: string,
) => Promise<{ success: boolean; error?: string }>;
};
isSensitiveInput?: () => boolean;
rzMissingFallbackTimeoutMs?: number;
termRef: React.MutableRefObject<XTerm | null>;
}
// Keep this aligned with the main-process drag-drop start watchdog. Falling
// back sooner interrupts valid rz handshakes on slow shells and jump routes.
export const DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS = 15_000;
export class ActiveTerminalCwdUnavailableError extends Error {
constructor() {
super("Could not determine the active terminal directory");
this.name = "ActiveTerminalCwdUnavailableError";
}
}
export function resolveTerminalDropErrorMessage(
error: unknown,
t: UseTerminalDragDropOptions["t"],
): string {
if (error instanceof ActiveTerminalCwdUnavailableError) {
return t("terminal.dragDrop.destinationUnknown");
}
if (error instanceof TerminalDropNeedsSudoError) {
return t("terminal.dragDrop.needsSudoElevation");
}
if (error instanceof Error && error.message === "No files to upload") {
return t("terminal.dragDrop.noFiles");
}
return t("terminal.dragDrop.errorMessage");
}
async function openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
}: {
dropEntries: DropEntry[];
host: Host;
onOpenSftp: NonNullable<UseTerminalDragDropOptions["onOpenSftp"]>;
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"];
resolvedLoginUsername?: string;
resolvedSudoPassword?: string;
sessionId: string;
}): Promise<void> {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
const uploadHost = resolveTerminalDropSftpHost(host, initialPath, {
password: resolvedSudoPassword ?? host.password,
username: resolvedLoginUsername ?? host.username,
});
onOpenSftp(
uploadHost,
initialPath,
dropEntries,
sessionId,
resolveSftpReuseSourceSessionId(host, sessionId),
);
}
export async function resolveTerminalDropUploadInitialPath(
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
): Promise<string | undefined> {
const initialPath = await resolveSftpInitialPath({
preferFreshBackend: true,
requireActiveShellCwd: true,
});
if (!initialPath) {
throw new ActiveTerminalCwdUnavailableError();
}
return initialPath;
}
function createRzMissingWatcher({
sessionId,
terminalBackend,
token,
timeoutMs = DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS,
}: {
sessionId: string;
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
token: string;
timeoutMs?: number;
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let buffer = "";
let unsubscribeData: (() => void) | undefined;
let unsubscribeZmodem: (() => void) | undefined;
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
const cleanup = () => {
if (timeout) clearTimeout(timeout);
timeout = undefined;
unsubscribeData?.();
unsubscribeData = undefined;
unsubscribeZmodem?.();
unsubscribeZmodem = undefined;
};
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
settle = (result) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
buffer = `${buffer}${chunk}`.slice(-512);
if (containsZmodemRzMissingMarker(buffer, token)) {
settle("missing");
}
});
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
if (event.type === "detect" && event.transferType === "upload") {
settle("detected");
}
});
timeout = setTimeout(() => settle("timeout"), timeoutMs);
});
return {
promise,
stop: () => settle("detected"),
};
}
export async function handleTerminalDropEntries({
dropEntries,
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
isSensitiveInput,
rzMissingFallbackTimeoutMs,
termRef,
}: Pick<
UseTerminalDragDropOptions,
| "host"
| "resolvedLoginUsername"
| "resolvedSudoPassword"
| "isLocalConnection"
| "isNetworkDevice"
| "onOpenSftp"
| "resolveSftpInitialPath"
| "scrollToBottomAfterProgrammaticInput"
| "sessionId"
| "sessionRef"
| "terminalBackend"
| "isSensitiveInput"
| "rzMissingFallbackTimeoutMs"
| "termRef"
> & {
dropEntries: DropEntry[];
}): Promise<void> {
if (dropEntries.length === 0) {
return;
}
if (isLocalConnection) {
const paths = extractRootPathsFromDropEntries(dropEntries);
if (paths.length > 0 && termRef.current && sessionRef.current) {
const pathsText = paths.join(" ");
terminalBackend.writeToSession(sessionRef.current, pathsText, {
sensitive: isSensitiveInput?.() === true,
});
scrollToBottomAfterProgrammaticInput(pathsText);
termRef.current.focus();
}
return;
}
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
entry.isDirectory || /[\\/]/.test(entry.relativePath)
));
if (
requiresSftpForDirectoryDrop
&& onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
) {
await openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
});
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
const files = await buildZmodemDragDropFiles(dropEntries);
if (files.length === 0) {
throw new Error("No files to upload");
}
if (!terminalBackend.startZmodemDragDropUpload) {
throw new Error("ZMODEM drag-drop upload is unavailable");
}
const shouldFallbackToSftpWhenRzMissing = Boolean(
onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
&& terminalBackend.onSessionData
&& terminalBackend.cancelZmodem,
);
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
? createZmodemRzMissingToken()
: undefined;
const rzMissingWatcher = rzMissingToken
? createRzMissingWatcher({
sessionId,
terminalBackend,
token: rzMissingToken,
timeoutMs: rzMissingFallbackTimeoutMs,
})
: undefined;
const uploadCommand = rzMissingToken
? buildZmodemDragDropUploadCommand(rzMissingToken)
: undefined;
let result: { success: boolean; error?: string };
try {
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
} catch (error) {
rzMissingWatcher?.stop();
throw error;
}
if (!result.success) {
rzMissingWatcher?.stop();
throw new Error(result.error || "ZMODEM upload failed");
}
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
if (fallbackResult === "missing" || fallbackResult === "timeout") {
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
if (onOpenSftp) {
await openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
});
}
}
} else if (onOpenSftp) {
await openSftpForTerminalDrop({
dropEntries,
host,
onOpenSftp,
resolveSftpInitialPath,
resolvedLoginUsername,
resolvedSudoPassword,
sessionId,
});
}
}
export function useTerminalDragDrop({
host,
resolvedLoginUsername,
resolvedSudoPassword,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
status,
t,
terminalBackend,
isSensitiveInput,
rzMissingFallbackTimeoutMs,
termRef,
}: UseTerminalDragDropOptions) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes("Files")) {
setIsDraggingOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) {
e.dataTransfer.dropEffect = "copy";
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDraggingOver(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDraggingOver(false);
if (!e.dataTransfer.types.includes("Files")) {
return;
}
if (status !== "connected") {
toast.error(t("terminal.dragDrop.notConnected"), t("terminal.dragDrop.errorTitle"));
return;
}
try {
const dropEntries = await extractDropEntries(e.dataTransfer);
await handleTerminalDropEntries({
dropEntries,
host,
resolvedLoginUsername,
resolvedSudoPassword,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
isSensitiveInput,
rzMissingFallbackTimeoutMs,
termRef,
});
} catch (error) {
logger.error("Failed to handle file drop", error);
const message = resolveTerminalDropErrorMessage(error, t);
toast.error(message, t("terminal.dragDrop.errorTitle"));
}
};
return {
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
isDraggingOver,
};
}

View File

@@ -0,0 +1,111 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type React from "react";
import { useEffect } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import type { TerminalSession } from "../../../types";
import type { RemoteClipboardImageUploadResult } from "../clipboardImagePaste";
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
interface UseTerminalFilePasteOptions {
isLocalConnection: boolean;
status: TerminalSession["status"];
termRef: React.MutableRefObject<XTerm | null>;
sessionRef: React.MutableRefObject<string | null>;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
};
isSensitiveInput?: () => boolean;
scrollOnPasteRef?: React.RefObject<boolean>;
onPasteData?: (data: string) => boolean | void;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
/** Remote sessions only: auto-upload a clipboard image on paste. */
autoUploadClipboardImage?: boolean;
getRemoteCwd?: () => Promise<string | null | undefined>;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
}
export function useTerminalFilePaste({
isLocalConnection,
status,
termRef,
sessionRef,
terminalBackend,
isSensitiveInput,
scrollOnPasteRef,
onPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
autoUploadClipboardImage = false,
getRemoteCwd,
onClipboardImageUploadResult,
}: UseTerminalFilePasteOptions) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handlePaste = (event: ClipboardEvent) => {
if (status !== "connected") return;
const bridge = netcattyBridge.get();
const wantsImageUpload =
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
const canHandleLocalPaste =
isLocalConnection && !!(bridge?.readClipboardFiles || bridge?.hasClipboardImage);
if (!wantsImageUpload && !canHandleLocalPaste) return;
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
// is synchronous; calling it after an await is too late and the
// browser will have already performed the default paste action.
event.preventDefault();
event.stopPropagation();
void (async () => {
try {
const term = termRef.current;
if (!term) return;
await handleTerminalClipboardPaste({
bridge,
autoUploadClipboardImage: wantsImageUpload,
clipboardImageBridge: bridge ?? undefined,
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData,
sessionId: sessionRef.current,
terminalBackend,
term,
scrollToBottomAfterProgrammaticInput,
});
} catch (error) {
logger.error("Failed to handle file paste", error);
}
})();
};
container.addEventListener("paste", handlePaste, true);
return () => {
container.removeEventListener("paste", handlePaste, true);
};
}, [
autoUploadClipboardImage,
containerRef,
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
onClipboardImageUploadResult,
onPasteData,
scrollOnPasteRef,
scrollToBottomAfterProgrammaticInput,
sessionRef,
status,
terminalBackend,
termRef,
]);
}

View File

@@ -0,0 +1,580 @@
import type { SearchAddon } from "@xterm/addon-search";
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
import { STORAGE_KEY_TERMINAL_SEARCH_OPEN } from "../../../infrastructure/config/storageKeys";
type SearchMatchCount = { current: number; total: number } | null;
type SearchAddonResetTarget = Pick<SearchAddon, "findNext" | "clearDecorations"> | null;
type TerminalSearchVisualElement = {
querySelectorAll: (selector: string) => ArrayLike<{ remove: () => void }>;
};
type TerminalSearchResetTarget = Pick<XTerm, "refresh" | "rows" | "clearSelection"> & {
element?: TerminalSearchVisualElement | null;
clearTextureAtlas?: () => void;
} | null;
type TerminalSearchGuardTarget = TerminalSearchResetTarget;
const SEARCH_DECORATIONS = {
matchBackground: "#FFFF0044",
matchBorder: "#FFFF00",
matchOverviewRuler: "#FFFF00",
activeMatchBackground: "#FF880088",
activeMatchBorder: "#FF8800",
activeMatchColorOverviewRuler: "#FF8800",
} as const;
const SEARCH_DECORATION_BACKGROUNDS = new Set<string>([
SEARCH_DECORATIONS.matchBackground.toLowerCase(),
SEARCH_DECORATIONS.activeMatchBackground.toLowerCase(),
]);
type StaleSearchDecoration = {
dispose: () => void;
options?: { backgroundColor?: string };
element?: {
classList?: { contains: (name: string) => boolean };
style?: { backgroundColor?: string };
};
};
type CellDecorationService = {
decorations?: Iterable<StaleSearchDecoration>;
forEachDecorationAtCell?: (
x: number,
y: number,
layer: "bottom" | "top" | undefined,
callback: (decoration: StaleSearchDecoration) => void,
) => void;
};
type SearchAddonInternals = {
clearDecorations?: () => void;
clearActiveDecoration?: () => void;
_highlightTimeout?: { clear?: () => void };
_state?: { reset?: () => void };
};
type TerminalDecorationHost = {
_core?: { _decorationService?: CellDecorationService };
_decorationService?: CellDecorationService;
};
export const SEARCH_DECORATION_TRACKER_KEY = "__netcattySearchDecorationTracker";
export type SearchDecorationTracker = {
disposeAll: () => number;
size: () => number;
markSearched: () => void;
hasSearched: () => boolean;
consumeSearched: () => boolean;
noteEmptyQueryReset: () => void;
consumeCloseSweep: () => boolean;
};
type TrackableTerminal = Pick<XTerm, "registerDecoration"> & {
[SEARCH_DECORATION_TRACKER_KEY]?: SearchDecorationTracker;
};
const SEARCH_OPTIONS = {
regex: false,
caseSensitive: false,
wholeWord: false,
decorations: SEARCH_DECORATIONS,
} as const;
/**
* SearchAddon schedules `_updateMatches` 200ms after writes/resizes and does
* not cancel that timer from `clearDecorations()`. A timeout that already
* captured the prior term can revive yellow match decorations after reset —
* re-clear once past that window (issue #2980).
*/
export const SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS = 250;
/**
* SearchAddon paints matches as HTML overlays (`.xterm-find-result-decoration`).
* Disposing the addon decoration does not always detach that node — after Esc
* closes the search bar and the terminal refits, the first two cells of the
* last hit can stay outlined on top of the buffer.
*/
export const SEARCH_DECORATION_NODE_SELECTOR =
".xterm-find-result-decoration, .xterm-find-active-result-decoration";
export const stripStaleSearchDecorationNodes = (
term?: { element?: TerminalSearchVisualElement | null } | null,
): void => {
const nodes = term?.element?.querySelectorAll(SEARCH_DECORATION_NODE_SELECTOR);
if (!nodes) return;
for (let i = 0; i < nodes.length; i += 1) {
nodes[i]?.remove();
}
};
export const isSearchDecorationBackground = (color?: string): boolean => (
Boolean(color) && SEARCH_DECORATION_BACKGROUNDS.has(color.trim().toLowerCase())
);
export const installSearchDecorationTracker = (
term: TrackableTerminal,
): SearchDecorationTracker => {
const existing = term[SEARCH_DECORATION_TRACKER_KEY];
if (existing) return existing;
const tracked = new Set<{ dispose: () => void }>();
let searched = false;
let pendingCloseSweep = false;
const originalRegister = term.registerDecoration.bind(term);
term.registerDecoration = (options) => {
// Keep search fill on the HTML overlay only. Passing backgroundColor into
// xterm lets WebGL bake the yellow into the glyph atlas, and those cells
// stay stained after Esc even when the decoration handle is gone.
const searchBackground = isSearchDecorationBackground(options.backgroundColor)
? options.backgroundColor
: undefined;
const decoration = originalRegister(
searchBackground ? { ...options, backgroundColor: undefined } : options,
);
if (!decoration) return decoration;
if (!searchBackground) return decoration;
tracked.add(decoration);
decoration.onRender((element) => {
element.style.backgroundColor = searchBackground;
});
decoration.onDispose(() => {
tracked.delete(decoration);
});
return decoration;
};
const tracker: SearchDecorationTracker = {
disposeAll: () => {
const leftover = [...tracked];
tracked.clear();
for (const decoration of leftover) decoration.dispose();
return leftover.length;
},
size: () => tracked.size,
markSearched: () => {
searched = true;
pendingCloseSweep = true;
},
hasSearched: () => searched,
consumeSearched: () => {
const value = searched;
searched = false;
return value;
},
noteEmptyQueryReset: () => {
searched = false;
},
consumeCloseSweep: () => {
const value = pendingCloseSweep;
pendingCloseSweep = false;
return value;
},
};
term[SEARCH_DECORATION_TRACKER_KEY] = tracker;
return tracker;
};
export const getSearchDecorationTracker = (term?: unknown): SearchDecorationTracker | null => {
if (!term || typeof term !== "object") return null;
const tracker = (term as TrackableTerminal)[SEARCH_DECORATION_TRACKER_KEY];
return tracker && typeof tracker.disposeAll === "function" ? tracker : null;
};
const isStaleSearchDecoration = (decoration: StaleSearchDecoration): boolean => (
isSearchDecorationBackground(decoration.options?.backgroundColor)
|| isSearchDecorationBackground(decoration.element?.style?.backgroundColor)
|| decoration.element?.classList?.contains("xterm-find-result-decoration") === true
|| decoration.element?.classList?.contains("xterm-find-active-result-decoration") === true
);
const readDecorationService = (term?: unknown): CellDecorationService | null => {
const host = term as TerminalDecorationHost & {
_core?: Record<string, unknown>;
} | null | undefined;
const direct = host?._core?._decorationService ?? host?._decorationService;
if (direct && (direct.decorations || direct.forEachDecorationAtCell)) {
return direct;
}
const core = host?._core;
if (!core || typeof core !== "object") return null;
for (const value of Object.values(core)) {
const candidate = value as CellDecorationService | undefined;
if (candidate && typeof candidate.forEachDecorationAtCell === "function") {
return candidate;
}
}
return null;
};
const readDecorationIterable = (
term?: unknown,
): Iterable<StaleSearchDecoration> | null => (
readDecorationService(term)?.decorations ?? null
);
export const disposeSearchDecorationsInViewport = (term?: unknown): number => {
const service = readDecorationService(term);
const view = term as {
cols?: number;
rows?: number;
buffer?: { active?: { viewportY?: number } };
} | null | undefined;
if (!service?.forEachDecorationAtCell || !view?.cols || !view.rows) return 0;
const viewportY = view.buffer?.active?.viewportY ?? 0;
const stale = new Set<StaleSearchDecoration>();
for (let y = viewportY; y < viewportY + view.rows; y += 1) {
for (let x = 0; x < view.cols; x += 1) {
service.forEachDecorationAtCell(x, y, undefined, (decoration) => {
if (isStaleSearchDecoration(decoration)) stale.add(decoration);
});
}
}
for (const decoration of stale) decoration.dispose();
return stale.size;
};
/**
* WebGL paints decoration backgroundColor into the cell. SearchAddon can lose
* a couple of those handles on Esc+refit, so walk the terminal decoration
* service and dispose anything still using the search yellow/orange.
*/
export const disposeStaleSearchDecorations = (term?: unknown): number => {
if (term && typeof term === "object" && "registerDecoration" in term) {
installSearchDecorationTracker(term as TrackableTerminal);
}
const trackedCount = getSearchDecorationTracker(term)?.disposeAll() ?? 0;
const viewportCount = disposeSearchDecorationsInViewport(term);
const decorations = readDecorationIterable(term);
if (!decorations) return trackedCount + viewportCount;
const stale: StaleSearchDecoration[] = [];
for (const decoration of decorations) {
if (isStaleSearchDecoration(decoration)) stale.push(decoration);
}
for (const decoration of stale) decoration.dispose();
return trackedCount + viewportCount + stale.length;
};
/** Cancel SearchAddon's 200ms _updateMatches timer and drop cached term/options. */
export const disarmSearchAddonRevival = (searchAddon?: unknown): void => {
const addon = searchAddon as SearchAddonInternals | null | undefined;
if (!addon) return;
addon._highlightTimeout?.clear?.();
addon._state?.reset?.();
addon.clearActiveDecoration?.();
addon.clearDecorations?.();
};
/**
* Delayed re-clear for addon decoration revival only. Do not clearSelection
* here: reset already cleared the search selection, and a user may have made
* a new manual selection during the guard window.
*/
export const clearTerminalSearchHighlights = (
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
term?: Pick<XTerm, "refresh" | "rows"> & {
element?: TerminalSearchVisualElement | null;
clearTextureAtlas?: () => void;
} | null,
): void => {
disarmSearchAddonRevival(searchAddon);
disposeStaleSearchDecorations(term);
stripStaleSearchDecorationNodes(term);
if (term && term.rows > 0) {
term.refresh(0, term.rows - 1);
}
};
/**
* After the search bar unmounts the terminal grows and is force-fitted.
* Re-sweep leftover overlays and the addon selection that a resize can revive
* as a 2-cell sliver of the last match.
*/
export const settleTerminalSearchAfterLayout = (
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
term?: TerminalSearchResetTarget,
onRepaint?: () => void,
): void => {
// Search-open state is shared across terminals. A sibling that never
// searched still sees the bar close and would otherwise lose a manual
// selection via clearSelection(). Emptying the query resets highlights
// while the bar stays open; keep the close-time repaint, but do not
// treat that stale search as a reason to wipe a later manual selection.
const tracker = getSearchDecorationTracker(term);
const shouldClearSelection = tracker ? tracker.consumeSearched() : true;
const shouldSweepLeftovers = tracker ? tracker.consumeCloseSweep() : true;
const shouldSweep = !tracker || shouldClearSelection || shouldSweepLeftovers;
if (!shouldSweep) return;
clearTerminalSearchHighlights(searchAddon, term);
if (shouldClearSelection) term?.clearSelection();
term?.clearTextureAtlas?.();
onRepaint?.();
};
export const resetTerminalSearch = (
searchAddon: SearchAddonResetTarget,
searchTermRef: { current: string },
term?: TerminalSearchResetTarget,
): void => {
searchTermRef.current = "";
// Drop decorations and cachedSearchTerm first so any not-yet-running addon
// `_updateMatches` timeout observes an empty cache and does not revive.
disarmSearchAddonRevival(searchAddon);
// clearDecorations() leaves the active-match selection; clear it explicitly.
term?.clearSelection();
// Empty find clears selection via the addon path. Do NOT pass SEARCH_OPTIONS:
// findNext always assigns lastSearchOptions, and decoration options would
// keep that latch armed for later write/resize updates.
try {
searchAddon?.findNext("");
} catch {
// Addon not activated yet.
}
// findNext("") assigns cachedSearchTerm back to "". Clear again so the cache
// is undefined rather than an empty string.
searchAddon?.clearDecorations();
// SearchAddon can drop a couple of decoration handles on Esc+refit. Those
// leftover yellow cells are still in xterm's decoration service and WebGL
// keeps painting them until we dispose them directly.
disposeStaleSearchDecorations(term);
// Disposing search decorations does not always detach the overlay nodes
// (Esc close leaves the first two cells of the last hit). Sweep them
// before refresh so WebGL/DOM cannot keep the yellow outline.
stripStaleSearchDecorationNodes(term);
// Disposing search decorations does not always repaint cells (observed on
// Windows after clearing or closing search). Keyword highlighting already
// forces a refresh after dispose; do the same here so yellow match
// backgrounds cannot linger.
if (term && term.rows > 0) {
term.refresh(0, term.rows - 1);
}
};
/**
* Pointer listeners used to tell a user-created selection apart from the
* addon's delayed findPrevious re-select. Keyboard selections in this 250ms
* window are rare enough that treating them as addon revival is acceptable.
*/
export const subscribeTerminalUserSelection = (
term: Pick<XTerm, "element"> | null | undefined,
mark: () => void,
): (() => void) => {
const el = term?.element;
if (!el) return () => {};
const onPointer = () => mark();
el.addEventListener("mousedown", onPointer);
el.addEventListener("touchstart", onPointer);
return () => {
el.removeEventListener("mousedown", onPointer);
el.removeEventListener("touchstart", onPointer);
};
};
export const armSearchHighlightRevivalGuard = ({
getSearchAddon,
getTerm,
subscribeUserSelection,
delayMs = SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
}: {
getSearchAddon: () => Pick<SearchAddon, "clearDecorations"> | null;
getTerm: () => TerminalSearchGuardTarget;
subscribeUserSelection?: (mark: () => void) => () => void;
delayMs?: number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
}): { arm: () => void; dispose: () => void; markUserSelection: () => void } => {
let timer: ReturnType<typeof setTimeout> | null = null;
let userTouchedSelection = false;
let unsubscribeUserSelection: (() => void) | null = null;
const markUserSelection = () => {
userTouchedSelection = true;
};
const dispose = () => {
if (timer !== null) {
clearTimeoutFn(timer);
timer = null;
}
unsubscribeUserSelection?.();
unsubscribeUserSelection = null;
};
const arm = () => {
dispose();
userTouchedSelection = false;
if (subscribeUserSelection) {
unsubscribeUserSelection = subscribeUserSelection(markUserSelection);
}
timer = setTimeoutFn(() => {
timer = null;
unsubscribeUserSelection?.();
unsubscribeUserSelection = null;
const term = getTerm();
clearTerminalSearchHighlights(getSearchAddon(), term);
// Addon findPrevious re-selects the prior active match. Clear that
// revived selection unless the user started a new one in this window.
if (!userTouchedSelection) {
term?.clearSelection();
}
}, delayMs);
};
return { arm, dispose, markUserSelection };
};
/** True when this terminal has a local query that shared search-close must clear. */
export const shouldResetOnSharedSearchClose = (localSearchTerm: string): boolean =>
localSearchTerm !== "";
export const useTerminalSearch = ({
searchAddonRef,
termRef,
}: {
searchAddonRef: RefObject<SearchAddon | null>;
termRef: RefObject<XTerm | null>;
}) => {
const [isSearchOpen, setIsSearchOpen] = useStoredBoolean(
STORAGE_KEY_TERMINAL_SEARCH_OPEN,
false,
);
const [searchMatchCount, setSearchMatchCount] = useState<SearchMatchCount>(null);
// Bumped each time the search hotkey fires. The SearchBar watches this token
// to refocus its input — without it, calling setIsSearchOpen(true) when
// already open is a no-op (React bails on the unchanged boolean) and focus
// never returns to the input. See issue #1789.
const [searchFocusToken, setSearchFocusToken] = useState(0);
const searchTermRef = useRef<string>("");
const revivalGuardRef = useRef<ReturnType<typeof armSearchHighlightRevivalGuard> | null>(null);
// Existing sessions (and Vite HMR) never go back through createXTermRuntime.
// Install on the live term so Esc can still find leaked decorations.
if (termRef.current) {
installSearchDecorationTracker(termRef.current);
}
if (revivalGuardRef.current === null) {
revivalGuardRef.current = armSearchHighlightRevivalGuard({
getSearchAddon: () => searchAddonRef.current,
getTerm: () => termRef.current,
subscribeUserSelection: (mark) => subscribeTerminalUserSelection(termRef.current, mark),
});
}
useEffect(() => () => {
revivalGuardRef.current?.dispose();
}, []);
const runReset = useCallback(() => {
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
revivalGuardRef.current?.arm();
}, [searchAddonRef, termRef]);
// Search open state is shared via localStorage across terminal sessions. When
// another session closes search, this session's bar unmounts without going
// through handleCloseSearch — clear leftover decorations only when this
// terminal actually searched (otherwise shared false would wipe unrelated
// manual selections in other splits).
useEffect(() => {
if (isSearchOpen) return;
setSearchMatchCount(null);
if (!shouldResetOnSharedSearchClose(searchTermRef.current)) return;
runReset();
}, [isSearchOpen, runReset]);
// Invoked by the searchTerminal hotkey (Cmd/Ctrl+F). Always opens the bar
// and bumps the focus token: when closed, setIsSearchOpen(true) mounts the
// SearchBar (whose isOpen effect focuses the input); when open, the token
// bump makes the SearchBar re-run its focus effect and refocus. Doing both
// unconditionally avoids reading `isSearchOpen` here — the xterm runtime
// captures this callback once at creation (it only re-runs on host.id /
// sessionId change), so a stale `isSearchOpen` closure would otherwise pick
// the wrong branch.
const requestSearchFocus = useCallback(() => {
setIsSearchOpen(true);
setSearchFocusToken((n) => n + 1);
}, [setIsSearchOpen]);
const handleToggleSearch = useCallback(() => {
const next = !isSearchOpen;
setIsSearchOpen(next);
if (!next) {
setSearchMatchCount(null);
runReset();
}
}, [isSearchOpen, runReset, setIsSearchOpen]);
const handleSearch = useCallback(
(term: string): boolean => {
const searchAddon = searchAddonRef.current;
if (!searchAddon || !term) {
runReset();
if (termRef.current) installSearchDecorationTracker(termRef.current);
getSearchDecorationTracker(termRef.current)?.noteEmptyQueryReset();
setSearchMatchCount(null);
return false;
}
searchTermRef.current = term;
revivalGuardRef.current?.dispose();
// Incremental typing (ro -> root) can leave the previous term's
// decorations in xterm even after clearDecorations(). Drop our tracked
// leftovers before painting the new matches.
if (termRef.current) installSearchDecorationTracker(termRef.current);
getSearchDecorationTracker(termRef.current)?.markSearched();
disposeStaleSearchDecorations(termRef.current);
searchAddon.clearDecorations();
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
if (found) {
setSearchMatchCount({ current: 1, total: 1 });
} else {
setSearchMatchCount({ current: 0, total: 0 });
}
return found;
},
[runReset, searchAddonRef, termRef],
);
const handleFindNext = useCallback((): boolean => {
const searchAddon = searchAddonRef.current;
const term = searchTermRef.current;
if (!searchAddon || !term) return false;
return searchAddon.findNext(term, SEARCH_OPTIONS);
}, [searchAddonRef]);
const handleFindPrevious = useCallback((): boolean => {
const searchAddon = searchAddonRef.current;
const term = searchTermRef.current;
if (!searchAddon || !term) return false;
return searchAddon.findPrevious(term, SEARCH_OPTIONS);
}, [searchAddonRef]);
const handleCloseSearch = useCallback(() => {
setIsSearchOpen(false);
setSearchMatchCount(null);
runReset();
termRef.current?.focus();
}, [runReset, setIsSearchOpen, termRef]);
return {
isSearchOpen,
setIsSearchOpen,
searchMatchCount,
searchFocusToken,
requestSearchFocus,
handleToggleSearch,
handleSearch,
handleFindNext,
handleFindPrevious,
handleCloseSearch,
};
};

View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface ZmodemTransferEvent {
type: 'detect' | 'progress' | 'complete' | 'error';
sessionId: string;
transferType?: 'upload' | 'download';
filename?: string;
transferred?: number;
total?: number;
fileIndex?: number;
fileCount?: number;
finalizing?: boolean;
error?: string;
}
export interface ZmodemTransferState {
active: boolean;
transferType: 'upload' | 'download' | null;
filename: string | null;
transferred: number;
total: number;
fileIndex: number;
fileCount: number;
finalizing: boolean;
completed: boolean;
startedAt: number | null;
updatedAt: number | null;
bytesPerSecond: number | null;
error: string | null;
}
const initialState: ZmodemTransferState = {
active: false,
transferType: null,
filename: null,
transferred: 0,
total: 0,
fileIndex: 0,
fileCount: 0,
finalizing: false,
completed: false,
startedAt: null,
updatedAt: null,
bytesPerSecond: null,
error: null,
};
export function reduceZmodemTransferState(
prev: ZmodemTransferState,
event: ZmodemTransferEvent,
now: number = Date.now(),
): ZmodemTransferState {
switch (event.type) {
case 'detect':
return {
...initialState,
active: true,
transferType: event.transferType ?? null,
startedAt: now,
updatedAt: now,
};
case 'progress': {
const transferred = event.transferred ?? prev.transferred;
const fileChanged = (
prev.filename !== null
&& (
(typeof event.fileIndex === 'number' && event.fileIndex !== prev.fileIndex)
|| (typeof event.filename === 'string' && event.filename !== prev.filename)
)
);
const previousUpdatedAt = fileChanged ? now : (prev.updatedAt ?? now);
const elapsedSeconds = Math.max((now - previousUpdatedAt) / 1000, 0);
const deltaBytes = Math.max(transferred - prev.transferred, 0);
const bytesPerSecond = elapsedSeconds > 0 && deltaBytes > 0
? deltaBytes / elapsedSeconds
: fileChanged
? null
: prev.bytesPerSecond;
return {
...prev,
active: true,
transferType: event.transferType ?? prev.transferType,
filename: event.filename ?? prev.filename,
transferred,
total: event.total ?? prev.total,
fileIndex: event.fileIndex ?? prev.fileIndex,
fileCount: event.fileCount ?? prev.fileCount,
finalizing: !!event.finalizing,
completed: false,
startedAt: prev.startedAt ?? now,
updatedAt: now,
bytesPerSecond,
error: null,
};
}
case 'complete':
return {
...prev,
active: false,
finalizing: false,
completed: true,
updatedAt: now,
};
case 'error':
return {
...prev,
active: false,
finalizing: false,
completed: false,
updatedAt: now,
error: event.error ?? 'Unknown error',
};
}
}
export function useZmodemTransfer(sessionId: string | null) {
const [state, setState] = useState<ZmodemTransferState>(initialState);
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
const disposeRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
if (!bridge?.onZmodemEvent) return;
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
setState((prev) => reduceZmodemTransferState(prev, event));
});
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
});
// If the session exits mid-transfer (disconnect, shell exit, etc.),
// reset state so the progress indicator doesn't stay stuck.
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
setState(initialState);
});
return () => {
disposeRef.current?.();
disposeRef.current = null;
disposeOverwrite?.();
disposeExitRef.current?.();
disposeExitRef.current = null;
setState(initialState);
setOverwriteRequest(null);
};
}, [sessionId]);
const cancel = useCallback(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId);
}, [sessionId]);
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
setOverwriteRequest((req) => {
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
return null;
});
}, []);
return { ...state, cancel, overwriteRequest, respondOverwrite };
}

View File

@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./hostKeyVerification";
test("host key verification keeps the existing known host id when saving", () => {
const hostKeyInfo = toHostKeyInfo({
hostname: "switch.local",
port: 22,
keyType: "unknown",
fingerprint: "new-fingerprint",
status: "changed",
knownHostId: "kh-existing",
knownFingerprint: "old-fingerprint",
});
const knownHost = createKnownHostFromHostKeyInfo(
hostKeyInfo,
{ port: 2200 },
200,
"generated",
);
assert.equal(hostKeyInfo.knownHostId, "kh-existing");
assert.deepEqual(knownHost, {
id: "kh-existing",
hostname: "switch.local",
port: 22,
keyType: "unknown",
publicKey: "SHA256:new-fingerprint",
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
});

View File

@@ -0,0 +1,17 @@
import type { Host, KnownHost } from "../../types";
import type { HostKeyInfo } from "../../domain/hostKey";
import { createKnownHostFromHostKeyInfo as createKnownHostFromHostKeyInfoDomain } from "../../domain/knownHosts";
export type { HostKeyInfo, HostKeyVerificationRequest } from "../../domain/hostKey";
export { toHostKeyInfo } from "../../domain/hostKey";
export const createKnownHostFromHostKeyInfo = (
hostKeyInfo: HostKeyInfo,
host: Pick<Host, "port">,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => createKnownHostFromHostKeyInfoDomain(hostKeyInfo, {
defaultPort: host.port,
now,
idSuffix,
});

Some files were not shown because too many files have changed in this diff Show More