Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 7ec53c5b1c fix(terminal): separate archive fill across a wrapped draft
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:24:18 +00:00
Cursor Agentand陈大猫 8ac71e1c86 fix(terminal): separate archive fill from text after the cursor
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:18:41 +00:00
Cursor Agentand陈大猫 b49a78db42 fix(terminal): anchor wrapped archive links on the continuation row
A token that starts on the next physical row was mapped to the previous
row's exclusive end cell. Inclusive offsets now move to the next segment,
while an end-exclusive offset stays on the row where the match stops.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 07:10:49 +00:00
Cursor Agentand陈大猫 f896627e82 feat(terminal): click archive names to fill extraction command
Paste an absolute tar or unzip command when a Linux session
Ctrl/Cmd-clicks a printed archive name. Cwd comes from OSC 7
markers only, so a later directory change cannot retarget an
old relative name.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 06:54:02 +00:00
16 changed files with 1490 additions and 16 deletions

No files matched your search

+1
View File
@@ -130,6 +130,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
- **分割ペイン** — 水平・垂直分割でマルチタスク
- **セッション管理** — 複数の接続を並行して扱う
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
+1
View File
@@ -133,6 +133,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Session management** — run multiple connections side-by-side
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
+1
View File
@@ -130,6 +130,7 @@
- **分屏** —— 水平/垂直分割,多任务并行
- **多会话管理** —— 多连接并排处理
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
+1
View File
@@ -130,6 +130,7 @@
- **分割窗格** —— 水平與垂直分割,方便多工
- **工作階段管理** —— 多條連線並排處理
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
+13 -4
View File
@@ -163,7 +163,10 @@ import {
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
} from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import {
applyUserCursorPreference,
@@ -599,18 +602,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const onSessionExitRef = useRef(onSessionExit);
const commandBufferRef = useRef<string>("");
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
const term = termRef.current;
if (!term) return false;
const prompt = detectPrompt(term);
return isIdleShellReadyForCommandInjection({
const readiness = {
sensitiveInputActive: passwordPromptActiveRef.current,
hasLiveTerminal: true,
alternateScreenActive: isTerminalAlternateScreenActive(term),
isAtPrompt: prompt.isAtPrompt,
userInputLength: prompt.userInput.length,
pendingTypedInputLength: commandBufferRef.current.length,
});
};
return options?.allowDraft
? isShellPromptReadyForInsertion(readiness)
: isIdleShellReadyForCommandInjection(readiness);
}), [sessionId]);
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
@@ -2341,6 +2347,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}), []);
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
@@ -4336,6 +4344,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
xTermRuntimeContextRef.current = {
host,
getHost: () => archiveHostRef.current,
fontFamilyId,
resolvedFontFamily,
fontSize: effectiveFontSize,
@@ -8,6 +8,10 @@ import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
import { createTerminalCwdMarkers } from "./terminalCwdMarkers";
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
@@ -315,6 +319,8 @@ export const resetKittyKeyboardModeStateForSession = (
export type CreateXTermRuntimeContext = {
container: HTMLDivElement;
host: Host;
/** Host OS facts can arrive after the runtime is created. */
getHost?: () => Host;
fontFamilyId: string;
resolvedFontFamily: string;
fontSize: number;
@@ -959,6 +965,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
terminalLinkHandler.activate(event, uri);
});
term.loadAddon(webLinksAddon);
const cwdMarkers = createTerminalCwdMarkers(term);
const archiveLinks = registerTerminalArchiveLinks({
term,
cwdMarkers,
isConnected: () => ctx.statusRef.current === "connected",
isBroadcasting: () => ctx.isBroadcastEnabledRef.current === true,
isHostEligible: () => supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId, { allowDraft: true }),
linkModifier: () => ctx.terminalSettingsRef.current?.linkModifier ?? "none",
});
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
? registerPluginTerminalLinkProvider({
term,
@@ -3229,12 +3245,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (path && path.length > 0) {
currentCwd = path;
ctx.onCwdChange?.(path);
cwdMarkers.note({ cwd: path, host: url.hostname });
logger.debug('[XTerm] OSC 7 CWD update:', path);
}
} else if (data.startsWith('/')) {
// Some shells send just the path without file:// prefix
currentCwd = data;
ctx.onCwdChange?.(data);
cwdMarkers.note({ cwd: data, host: '' });
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
}
} catch (err) {
@@ -3483,6 +3501,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
keywordHighlighter.dispose();
cursorLineHighlighter.dispose();
pluginLinkProviderHost?.dispose();
archiveLinks.dispose();
cwdMarkers.dispose();
pluginProviderHost?.dispose();
eraseScrollbackDisposable.dispose();
for (const disposable of cursorPositionReportRequestDisposables) {
@@ -0,0 +1,477 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks.ts";
import type { LinkModifier } from "../../../domain/models/terminal.ts";
type PrintedLine = { text: string; wrapped: boolean };
function click(partial: Partial<MouseEvent> = {}): MouseEvent {
return {
button: 0,
ctrlKey: false,
altKey: false,
metaKey: false,
...partial,
} as MouseEvent;
}
function makeBufferLine(line: PrintedLine) {
const chars = Array.from(line.text);
return {
isWrapped: line.wrapped,
length: chars.length,
translateToString(trim?: boolean) {
return trim ? line.text.trimEnd() : line.text;
},
getCell(index: number) {
const ch = chars[index];
if (ch === undefined) return undefined;
return {
getChars: () => ch,
getWidth: () => 1,
};
},
};
}
function fixture() {
let lines: PrintedLine[] = [{ text: "/tmp/backup.tar.gz", wrapped: false }];
let cursorX = 0;
let cursorY = 0;
let baseY = 0;
let cols = 80;
let cursorLine = 0;
let type: "normal" | "alternate" = "normal";
let connected = true;
let broadcast = false;
let eligible = true;
let ready = true;
let modifier: LinkModifier = "none";
const pasted: string[] = [];
let provider!: ILinkProvider;
const term = {
get cols() {
return cols;
},
buffer: {
active: {
get type() {
return type;
},
get cursorX() {
return cursorX;
},
get cursorY() {
return cursorY;
},
get baseY() {
return baseY;
},
getLine(index: number) {
const line = lines[index];
return line ? makeBufferLine(line) : undefined;
},
},
},
registerMarker(offset: number) {
return {
line: cursorLine + offset,
isDisposed: false,
dispose() {
this.isDisposed = true;
},
};
},
paste(value: string) {
pasted.push(value);
},
focus() {},
registerLinkProvider(next: ILinkProvider) {
provider = next;
return { dispose() {} };
},
};
const cwdMarkers = createTerminalCwdMarkers(term as never);
registerTerminalArchiveLinks({
term: term as unknown as Terminal,
cwdMarkers,
isConnected: () => connected,
isBroadcasting: () => broadcast,
isHostEligible: () => eligible,
isInputReady: () => ready,
linkModifier: () => modifier,
});
const linksAt = (y: number) => {
let value: ILink[] | undefined;
provider.provideLinks(y, (result) => {
value = result;
});
return value;
};
return {
pasted,
linksAt,
noteAt(line: number, report: { cwd: string; host: string }, x = 0) {
cursorLine = line;
const previousX = cursorX;
cursorX = x;
cwdMarkers.note(report);
cursorX = previousX;
},
setLines(next: PrintedLine[]) {
lines = next;
},
setLine(index: number, text: string, wrapped = false) {
lines[index] = { text, wrapped };
},
setCursor(line: number, x: number) {
cursorY = line;
cursorX = x;
baseY = 0;
},
atPrompt() {
const prompt = lines.length;
lines = [...lines, { text: "", wrapped: false }];
cursorY = prompt;
cursorX = 0;
baseY = 0;
},
setBroadcast(value: boolean) {
broadcast = value;
},
setReady(value: boolean) {
ready = value;
},
setEligible(value: boolean) {
eligible = value;
},
setConnected(value: boolean) {
connected = value;
},
setModifier(value: LinkModifier) {
modifier = value;
},
setAltBuffer(value: boolean) {
type = value ? "alternate" : "normal";
},
};
}
const ctrl = click({ ctrlKey: true });
test("no modifier is a no-op, Ctrl fills one command, and Cmd works on macOS", () => {
const linux = fixture();
const link = linux.linksAt(1)?.[0];
assert.ok(link);
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 18, y: 1 } });
linux.atPrompt();
link.activate(click(), link.text);
assert.deepEqual(linux.pasted, []);
link.activate(ctrl, link.text);
assert.deepEqual(linux.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
assert.equal(linux.pasted[0]?.includes("\r"), false);
assert.equal(linux.pasted[0]?.includes("\n"), false);
const mac = fixture();
const macLink = mac.linksAt(1)?.[0];
assert.ok(macLink);
mac.atPrompt();
macLink.activate(click({ metaKey: true }), macLink.text);
assert.deepEqual(mac.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
});
test("a configured alt modifier is required for archive links", () => {
const f = fixture();
f.setModifier("alt");
const link = f.linksAt(1)?.[0];
assert.ok(link);
link.activate(ctrl, link.text);
assert.deepEqual(f.pasted, []);
link.activate(click({ altKey: true }), link.text);
assert.equal(f.pasted.length, 1);
assert.equal(f.pasted[0]?.includes("\r"), false);
});
test("broadcast, readiness, host eligibility, buffer, and stale text gate provide and activate", () => {
const f = fixture();
const link = f.linksAt(1)?.[0];
assert.ok(link);
f.setBroadcast(true);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setBroadcast(false);
f.setReady(false);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setReady(true);
f.setEligible(false);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setEligible(true);
f.setConnected(false);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setConnected(true);
f.setAltBuffer(true);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setAltBuffer(false);
f.setLine(0, "changed.tar.gz");
link.activate(ctrl, link.text);
assert.deepEqual(f.pasted, []);
});
test("a non-space before the cursor is kept and separated with one space", () => {
const draft = fixture();
draft.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo", wrapped: false },
]);
draft.setCursor(1, 4);
const link = draft.linksAt(1)?.[0];
assert.ok(link);
link.activate(ctrl, link.text);
assert.equal(draft.pasted[0], " tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
const spaced = fixture();
spaced.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "sudo ", wrapped: false },
]);
spaced.setCursor(1, 5);
const spacedLink = spaced.linksAt(1)?.[0];
assert.ok(spacedLink);
spacedLink.activate(ctrl, spacedLink.text);
assert.equal(spaced.pasted[0], "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
});
test("a non-space after the cursor is separated with one space", () => {
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
const before = fixture();
before.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo", wrapped: false },
]);
before.setCursor(1, 0);
const beforeLink = before.linksAt(1)?.[0];
assert.ok(beforeLink);
beforeLink.activate(ctrl, beforeLink.text);
assert.equal(before.pasted[0], `${command} `);
assert.equal(before.pasted[0]?.includes("\r"), false);
const middle = fixture();
middle.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo", wrapped: false },
]);
middle.setCursor(1, 2);
const middleLink = middle.linksAt(1)?.[0];
assert.ok(middleLink);
middleLink.activate(ctrl, middleLink.text);
assert.equal(middle.pasted[0], ` ${command} `);
const already = fixture();
already.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo foo", wrapped: false },
]);
already.setCursor(1, 4);
const alreadyLink = already.linksAt(1)?.[0];
assert.ok(alreadyLink);
alreadyLink.activate(ctrl, alreadyLink.text);
assert.equal(already.pasted[0], ` ${command}`);
});
test("a wrapped draft separates the command from the previous row", () => {
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
const full = " ".repeat(79);
const glued = fixture();
glued.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: `${full}o`, wrapped: false },
{ text: " ", wrapped: true },
]);
glued.setCursor(2, 0);
const gluedLink = glued.linksAt(1)?.[0];
assert.ok(gluedLink);
gluedLink.activate(ctrl, gluedLink.text);
assert.equal(glued.pasted[0], ` ${command}`);
const spaced = fixture();
spaced.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: `${full} `, wrapped: false },
{ text: " ", wrapped: true },
]);
spaced.setCursor(2, 0);
const spacedLink = spaced.linksAt(1)?.[0];
assert.ok(spacedLink);
spacedLink.activate(ctrl, spacedLink.text);
assert.equal(spaced.pasted[0], command);
const separate = fixture();
separate.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: `${full}o`, wrapped: false },
{ text: " ", wrapped: false },
]);
separate.setCursor(2, 0);
const separateLink = separate.linksAt(1)?.[0];
assert.ok(separateLink);
separateLink.activate(ctrl, separateLink.text);
assert.equal(separate.pasted[0], command);
const continued = fixture();
continued.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "e".repeat(80), wrapped: false },
{ text: "x", wrapped: true },
]);
continued.setCursor(1, 80);
const continuedLink = continued.linksAt(1)?.[0];
assert.ok(continuedLink);
continuedLink.activate(ctrl, continuedLink.text);
assert.equal(continued.pasted[0], ` ${command} `);
});
test("a name that starts on the next wrapped row is not anchored on the previous row", () => {
const f = fixture();
f.setLines([
{ text: "x ", wrapped: false },
{ text: "/tmp/a.tar", wrapped: true },
]);
const link = f.linksAt(1)?.[0];
assert.ok(link);
assert.equal(link.text, "/tmp/a.tar");
assert.deepEqual(link.range, {
start: { x: 1, y: 2 },
end: { x: 10, y: 2 },
});
const ended = fixture();
ended.setLines([
{ text: "/a.tar", wrapped: false },
{ text: " rest", wrapped: true },
]);
const endedLink = ended.linksAt(1)?.[0];
assert.ok(endedLink);
assert.equal(endedLink.text, "/a.tar");
assert.deepEqual(endedLink.range, {
start: { x: 1, y: 1 },
end: { x: 6, y: 1 },
});
});
test("a wrapped logical line is one multi-row link and fills the full path", () => {
const f = fixture();
f.setLines([
{ text: "/tmp/back", wrapped: false },
{ text: "up.tar.gz", wrapped: true },
]);
const link = f.linksAt(1)?.[0];
assert.ok(link);
assert.deepEqual(link.range, {
start: { x: 1, y: 1 },
end: { x: 9, y: 2 },
});
f.atPrompt();
link.activate(ctrl, link.text);
assert.deepEqual(f.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
});
test("absolute names link without markers and relative names do not", () => {
const relative = fixture();
relative.setLine(0, "bundle.tar.gz");
assert.equal(relative.linksAt(1), undefined);
const absolute = fixture();
absolute.setLine(0, "/var/pack.zip");
const link = absolute.linksAt(1)?.[0];
assert.ok(link);
absolute.atPrompt();
link.activate(ctrl, link.text);
assert.deepEqual(absolute.pasted, ["unzip '/var/pack.zip' -d '/var'"]);
const open = fixture();
open.setLines([
{ text: "prompt", wrapped: false },
{ text: "/var/pack.zip", wrapped: false },
]);
open.noteAt(0, { cwd: "/var", host: "box" });
const openLink = open.linksAt(2)?.[0];
assert.ok(openLink);
open.atPrompt();
openLink.activate(ctrl, openLink.text);
assert.equal(open.pasted[0], "unzip '/var/pack.zip' -d '/var'");
open.setLine(1, "pack.zip");
assert.equal(open.linksAt(2), undefined);
const mismatched = fixture();
mismatched.setLines([
{ text: "prompt", wrapped: false },
{ text: "/tmp/a.tar.gz", wrapped: false },
{ text: "prompt", wrapped: false },
]);
mismatched.noteAt(0, { cwd: "/tmp", host: "box" });
mismatched.noteAt(2, { cwd: "/other", host: "box" });
const mismatchLink = mismatched.linksAt(2)?.[0];
assert.ok(mismatchLink);
mismatched.atPrompt();
mismatchLink.activate(ctrl, mismatchLink.text);
assert.equal(mismatched.pasted[0], "tar -xvzf '/tmp/a.tar.gz' -C '/tmp'");
mismatched.setLine(1, "a.tar.gz");
assert.equal(mismatched.linksAt(2), undefined);
});
test("an absolute path from another host is not a link", () => {
const f = fixture();
f.setLines([
{ text: "prompt", wrapped: false },
{ text: "/tmp/a.tar.gz", wrapped: false },
{ text: "prompt", wrapped: false },
]);
f.noteAt(0, { cwd: "/tmp", host: "outer" });
f.noteAt(2, { cwd: "/tmp", host: "outer" });
f.noteAt(4, { cwd: "/home", host: "inner" });
assert.equal(f.linksAt(2), undefined);
});
test("ls -l links only the archive name and a symlink line links both names", () => {
const listing = fixture();
listing.setLines([
{ text: "$ ls -l", wrapped: false },
{ text: "-rw-r--r-- 1 me me 10 Jan 1 00:00 bundle.tar.gz", wrapped: false },
{ text: "$", wrapped: false },
]);
listing.noteAt(0, { cwd: "/data", host: "box" });
listing.noteAt(2, { cwd: "/data", host: "box" });
const links = listing.linksAt(2);
assert.deepEqual(links?.map((link) => link.text), ["bundle.tar.gz"]);
listing.atPrompt();
links?.[0]?.activate(ctrl, links[0].text);
assert.deepEqual(listing.pasted, ["tar -xvzf '/data/bundle.tar.gz' -C '/data'"]);
const symlink = fixture();
symlink.setLines([
{ text: "$ ls", wrapped: false },
{ text: "a.tar.gz -> b.tar.gz", wrapped: false },
{ text: "$", wrapped: false },
]);
symlink.noteAt(0, { cwd: "/data", host: "box" });
symlink.noteAt(2, { cwd: "/data", host: "box" });
assert.deepEqual(symlink.linksAt(2)?.map((link) => link.text), ["a.tar.gz", "b.tar.gz"]);
});
@@ -0,0 +1,231 @@
import type { IDisposable, ILink, ILinkProvider, Terminal } from "@xterm/xterm";
import {
buildArchiveExtractCommand,
findTerminalArchiveTokens,
resolveArchivePath,
} from "../../../domain/terminalArchives";
import type { LinkModifier } from "../../../domain/models/terminal";
import {
readPluginTerminalBufferText,
type PluginTerminalBufferText,
} from "../pluginTerminalBufferText";
import type { TerminalCwdInspection, TerminalCwdMarkers } from "./terminalCwdMarkers";
const MAX_LOGICAL_LINE_CHARS = 8192;
export interface TerminalArchiveLinkHost {
term: Terminal;
cwdMarkers: TerminalCwdMarkers;
isConnected(): boolean;
isBroadcasting(): boolean;
isHostEligible(): boolean;
isInputReady(): boolean;
linkModifier(): LinkModifier;
}
interface LogicalSegment {
lineNo: number;
text: PluginTerminalBufferText;
stringStart: number;
}
interface LogicalLine {
text: string;
segments: LogicalSegment[];
}
interface BufferCell {
line: number;
cell: number;
}
function readLogicalLine(term: Terminal, oneBasedY: number): LogicalLine | null {
const buffer = term.buffer.active;
const index = oneBasedY - 1;
if (!buffer.getLine(index)) return null;
let start = index;
while (start > 0 && buffer.getLine(start)?.isWrapped) start -= 1;
const segments: LogicalSegment[] = [];
let total = 0;
for (let lineNo = start; ; lineNo += 1) {
const line = buffer.getLine(lineNo);
if (!line) break;
if (lineNo > start && !line.isWrapped) break;
const next = buffer.getLine(lineNo + 1);
const isLast = !next?.isWrapped;
const text = readPluginTerminalBufferText(line, isLast);
if (total + text.text.length > MAX_LOGICAL_LINE_CHARS) return null;
segments.push({ lineNo, text, stringStart: total });
total += text.text.length;
if (isLast) break;
}
if (segments.length === 0) return null;
return {
text: segments.map((segment) => segment.text.text).join(""),
segments,
};
}
function cellAt(
segments: readonly LogicalSegment[],
offset: number,
bound: "inclusive" | "exclusive",
): BufferCell | null {
for (const segment of segments) {
const local = offset - segment.stringStart;
if (local < 0) return null;
const length = segment.text.text.length;
// A boundary offset is the next row's first cell for a token start, and the
// previous row's exclusive end for a match that stops on that row.
if (bound === "inclusive" ? local >= length : local > length) continue;
const cell = segment.text.cellAtStringOffset[local];
if (!Number.isInteger(cell)) return null;
return { line: segment.lineNo, cell };
}
return null;
}
function sameSpan(left: TerminalCwdInspection, right: TerminalCwdInspection): boolean {
if (left.kind !== right.kind) return false;
if (left.kind === "ambiguous") return false;
if (left.kind === "outside") return true;
if (left.kind === "open") return right.kind === "open" && left.host === right.host;
return right.kind === "closed" && left.key === right.key;
}
function modifierMatches(event: MouseEvent, modifier: LinkModifier): boolean {
if (event.button !== 0) return false;
if (modifier === "ctrl") return event.ctrlKey;
if (modifier === "alt") return event.altKey;
if (modifier === "meta") return event.metaKey;
// "none" still requires Ctrl or Cmd. A configured modifier replaces that default.
return event.ctrlKey || event.metaKey;
}
function neighborNeedsSpace(term: Terminal, direction: -1 | 1): boolean {
const buffer = term.buffer.active;
let lineIndex = buffer.baseY + buffer.cursorY;
let line = buffer.getLine(lineIndex);
if (!line || typeof line.getCell !== "function") return false;
let index = buffer.cursorX + (direction < 0 ? -1 : 0);
for (let steps = 0; steps < term.cols + 1; steps += 1) {
if (index < 0 || index >= term.cols) {
if (direction < 0) {
if (!line.isWrapped) return false;
lineIndex -= 1;
} else {
lineIndex += 1;
const next = buffer.getLine(lineIndex);
if (!next?.isWrapped) return false;
}
line = buffer.getLine(lineIndex);
if (!line || typeof line.getCell !== "function") return false;
index = direction < 0 ? term.cols - 1 : 0;
continue;
}
const cell = line.getCell(index);
if (!cell) return false;
if (cell.getWidth() === 0) {
index += direction;
continue;
}
return (cell.getChars() || " ") !== " ";
}
return false;
}
/**
* Ctrl/Cmd-click archive names to paste an absolute extract command.
* Directory labels come only from OSC 7 markers; input is never observed.
*/
export function registerTerminalArchiveLinks(options: TerminalArchiveLinkHost): IDisposable {
let disposed = false;
const { term, cwdMarkers } = options;
const sessionAllowsLinks = (): boolean => options.isConnected()
&& !options.isBroadcasting()
&& options.isHostEligible()
&& options.isInputReady()
&& term.buffer.active.type === "normal";
const cwdFor = (path: string, start: BufferCell, end: BufferCell): string | null | undefined => {
const left = cwdMarkers.inspectCell(start.line, start.cell);
const right = cwdMarkers.inspectCell(end.line, end.cell);
if (!sameSpan(left, right) || left.kind === "ambiguous") return undefined;
const absolute = path.startsWith("/");
if (absolute) {
if (left.kind === "outside") return null;
const latest = cwdMarkers.latestHost();
if (latest != null && left.host !== latest) return undefined;
return null;
}
if (left.kind !== "closed" || !left.trusted) return undefined;
const latest = cwdMarkers.latestHost();
if (latest != null && left.host !== latest) return undefined;
return left.cwd;
};
const provider: ILinkProvider = {
provideLinks(bufferLineNumber, callback) {
if (disposed || !sessionAllowsLinks()) {
callback(undefined);
return;
}
const logical = readLogicalLine(term, bufferLineNumber);
if (!logical || logical.text.length === 0) {
callback(undefined);
return;
}
const links: ILink[] = [];
for (const token of findTerminalArchiveTokens(logical.text)) {
const start = cellAt(logical.segments, token.start, "inclusive");
const endExclusive = cellAt(logical.segments, token.start + token.length, "exclusive");
const last = cellAt(logical.segments, token.start + token.length - 1, "inclusive");
if (!start || !endExclusive || !last) continue;
const cwd = cwdFor(token.path, start, last);
if (cwd === undefined) continue;
const abs = resolveArchivePath(token.path, cwd);
const command = abs ? buildArchiveExtractCommand(abs) : null;
if (!command) continue;
const capturedText = logical.text;
links.push({
range: {
start: { x: start.cell + 1, y: start.line + 1 },
end: { x: endExclusive.cell, y: endExclusive.line + 1 },
},
text: token.text,
decorations: { pointerCursor: true, underline: true },
activate(event) {
if (disposed || !modifierMatches(event, options.linkModifier())) return;
if (!sessionAllowsLinks()) return;
const fresh = readLogicalLine(term, start.line + 1);
if (!fresh || fresh.text !== capturedText) return;
const freshStart = cellAt(fresh.segments, token.start, "inclusive");
const freshLast = cellAt(fresh.segments, token.start + token.length - 1, "inclusive");
if (!freshStart || !freshLast) return;
const freshCwd = cwdFor(token.path, freshStart, freshLast);
if (freshCwd === undefined) return;
const freshAbs = resolveArchivePath(token.path, freshCwd);
const freshCommand = freshAbs ? buildArchiveExtractCommand(freshAbs) : null;
if (!freshCommand || /[\r\n]/u.test(freshCommand)) return;
const lead = neighborNeedsSpace(term, -1) ? " " : "";
const trail = neighborNeedsSpace(term, 1) ? " " : "";
term.paste(`${lead}${freshCommand}${trail}`);
term.focus();
},
});
}
callback(links.length > 0 ? links : undefined);
},
};
const registration = term.registerLinkProvider(provider);
return {
dispose() {
disposed = true;
registration.dispose();
},
};
}
@@ -1,8 +1,16 @@
const commandInjectionReadyReaders = new Map<string, () => boolean>();
export interface TerminalCommandInjectionReadyOptions {
/** Keep a typed draft. Sensitive prompts, alt-screen, and non-prompts still block. */
allowDraft?: boolean;
}
const commandInjectionReadyReaders = new Map<
string,
(options?: TerminalCommandInjectionReadyOptions) => boolean
>();
export function registerTerminalCommandInjectionReadyReader(
sessionId: string,
reader: () => boolean,
reader: (options?: TerminalCommandInjectionReadyOptions) => boolean,
): () => void {
commandInjectionReadyReaders.set(sessionId, reader);
return () => {
@@ -12,7 +20,10 @@ export function registerTerminalCommandInjectionReadyReader(
};
}
/** True when the live terminal reports an idle shell prompt ready for injection. */
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
return commandInjectionReadyReaders.get(sessionId)?.() === true;
/** True when the live terminal reports a shell prompt ready for injection. */
export function isTerminalReadyForCommandInjection(
sessionId: string,
options?: TerminalCommandInjectionReadyOptions,
): boolean {
return commandInjectionReadyReaders.get(sessionId)?.(options) === true;
}
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
type FakeMarker = {
line: number;
isDisposed: boolean;
dispose: () => void;
};
function harness() {
let cursorLine = 0;
let cursorX = 0;
let cols = 80;
let type: "normal" | "alternate" = "normal";
const created: FakeMarker[] = [];
const term = {
get cols() {
return cols;
},
buffer: {
active: {
get type() {
return type;
},
get cursorX() {
return cursorX;
},
},
},
registerMarker(offset: number) {
const marker: FakeMarker = {
line: cursorLine + offset,
isDisposed: false,
dispose() {
this.isDisposed = true;
},
};
created.push(marker);
return marker;
},
};
const markers = createTerminalCwdMarkers(term as never);
return {
markers,
created,
setCursor(line: number, x = 0) {
cursorLine = line;
cursorX = x;
},
setCols(next: number) {
cols = next;
},
setType(next: "normal" | "alternate") {
type = next;
},
};
}
test("prompt-only intervals are trusted only when both ends report the same cwd", () => {
const same = harness();
same.setCursor(0);
same.markers.note({ cwd: "/a", host: "box" });
same.setCursor(3);
same.markers.note({ cwd: "/a", host: "box" });
assert.deepEqual(same.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
const changed = harness();
changed.setCursor(0);
changed.markers.note({ cwd: "/a", host: "box" });
changed.setCursor(3);
changed.markers.note({ cwd: "/a/x", host: "box" });
assert.equal(changed.markers.resolveCell(1, 0), null);
const thenCd = harness();
thenCd.setCursor(0);
thenCd.markers.note({ cwd: "/a", host: "box" });
thenCd.setCursor(4);
thenCd.markers.note({ cwd: "/a/x", host: "box" });
assert.equal(thenCd.markers.resolveCell(2, 0), null);
});
test("a chpwd emitter trusts the interval after the directory change", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(2);
h.markers.note({ cwd: "/a/x", host: "box" });
h.setCursor(5);
h.markers.note({ cwd: "/a/x", host: "box" });
assert.equal(h.markers.resolveCell(1, 0), null);
assert.deepEqual(h.markers.resolveCell(3, 0), { cwd: "/a/x", host: "box" });
});
test("the open interval after the latest marker is not trusted", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(2);
h.markers.note({ cwd: "/a", host: "box" });
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
assert.equal(h.markers.resolveCell(4, 0), null);
});
test("a host mismatch between interval ends is not trusted and latestHost follows the last marker", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "outer" });
h.setCursor(2);
h.markers.note({ cwd: "/a", host: "inner" });
assert.equal(h.markers.resolveCell(1, 0), null);
assert.equal(h.markers.latestHost(), "inner");
});
test("a disposed start or end marker drops the interval", () => {
const start = harness();
start.setCursor(0);
start.markers.note({ cwd: "/a", host: "box" });
start.setCursor(2);
start.markers.note({ cwd: "/a", host: "box" });
start.created[0]?.dispose();
assert.equal(start.markers.resolveCell(1, 0), null);
const end = harness();
end.setCursor(0);
end.markers.note({ cwd: "/a", host: "box" });
end.setCursor(2);
end.markers.note({ cwd: "/a", host: "box" });
end.created[1]?.dispose();
assert.equal(end.markers.resolveCell(1, 0), null);
});
test("a new marker above existing ones drops the later markers", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(5);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(10);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(3);
h.markers.note({ cwd: "/a", host: "box" });
assert.equal(h.created[1]?.isDisposed, true);
assert.equal(h.created[2]?.isDisposed, true);
assert.equal(h.created[0]?.isDisposed, false);
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
assert.equal(h.markers.resolveCell(6, 0), null);
});
test("cells left of a marker belong to the previous interval and a cols change makes that row ambiguous", () => {
const split = harness();
split.setCursor(0, 0);
split.markers.note({ cwd: "/old", host: "box" });
split.setCursor(5, 10);
split.markers.note({ cwd: "/new", host: "box" });
split.setCursor(9, 0);
split.markers.note({ cwd: "/new", host: "box" });
assert.equal(split.markers.resolveCell(5, 9), null);
assert.deepEqual(split.markers.resolveCell(5, 10), { cwd: "/new", host: "box" });
assert.deepEqual(split.markers.resolveCell(7, 0), { cwd: "/new", host: "box" });
const resized = harness();
resized.setCursor(0, 0);
resized.markers.note({ cwd: "/a", host: "box" });
resized.setCursor(5, 4);
resized.markers.note({ cwd: "/a", host: "box" });
resized.setCursor(9, 0);
resized.markers.note({ cwd: "/a", host: "box" });
resized.setCols(100);
assert.equal(resized.markers.resolveCell(5, 0), null);
assert.equal(resized.markers.resolveCell(5, 4), null);
assert.equal(resized.markers.resolveCell(0, 1), null);
assert.deepEqual(resized.markers.resolveCell(3, 0), { cwd: "/a", host: "box" });
});
test("the alternate buffer does not record cwd markers", () => {
const h = harness();
h.setType("alternate");
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
assert.equal(h.created.length, 0);
assert.equal(h.markers.resolveCell(0, 0), null);
assert.equal(h.markers.latestHost(), null);
});
@@ -0,0 +1,153 @@
const MAX_CWD_MARKERS = 5000;
interface CwdMarker {
readonly line: number;
readonly isDisposed: boolean;
dispose(): void;
}
export interface TerminalCwdMarkerTerminal {
readonly cols: number;
readonly buffer: {
readonly active: {
readonly type: "normal" | "alternate";
readonly cursorX: number;
};
};
registerMarker(cursorYOffset: number): CwdMarker | undefined;
}
interface CwdMarkerEntry {
id: number;
marker: CwdMarker;
x: number;
cols: number;
cwd: string;
host: string;
}
export interface TerminalCwdCell {
cwd: string;
host: string;
}
export type TerminalCwdInspection =
| { kind: "outside" }
| { kind: "ambiguous" }
| { kind: "open"; host: string; cwd: string }
| { kind: "closed"; host: string; cwd: string; trusted: boolean; key: string };
export interface TerminalCwdMarkers {
note(report: { cwd: string; host: string }): void;
resolveCell(row: number, col: number): TerminalCwdCell | null;
inspectCell(row: number, col: number): TerminalCwdInspection;
latestHost(): string | null;
dispose(): void;
}
/**
* OSC 7 cwd labels. Output is trusted only between two markers that agree.
* Nothing here watches keyboard or paste input.
*/
export function createTerminalCwdMarkers(term: TerminalCwdMarkerTerminal): TerminalCwdMarkers {
let entries: CwdMarkerEntry[] = [];
let nextId = 1;
let disposed = false;
const liveEntries = (): CwdMarkerEntry[] => {
if (entries.some((entry) => entry.marker.isDisposed)) {
entries = entries.filter((entry) => !entry.marker.isDisposed);
}
for (let index = 1; index < entries.length; index += 1) {
if (entries[index - 1].marker.line > entries[index].marker.line) {
entries.sort((left, right) => left.marker.line - right.marker.line);
break;
}
}
return entries;
};
const relation = (
row: number,
col: number,
entry: CwdMarkerEntry,
): "before" | "atOrAfter" | "ambiguous" => {
const line = entry.marker.line;
if (row < line) return "before";
if (row > line) return "atOrAfter";
if (term.cols !== entry.cols) return "ambiguous";
return col < entry.x ? "before" : "atOrAfter";
};
const inspectCell = (row: number, col: number): TerminalCwdInspection => {
const list = liveEntries();
if (list.length === 0) return { kind: "outside" };
let start: CwdMarkerEntry | null = null;
for (const entry of list) {
const where = relation(row, col, entry);
if (where === "ambiguous") return { kind: "ambiguous" };
if (where === "before") {
if (!start) return { kind: "outside" };
if (start.host !== entry.host) return { kind: "ambiguous" };
return {
kind: "closed",
host: start.host,
cwd: start.cwd,
trusted: start.cwd === entry.cwd,
key: `${start.id}:${entry.id}`,
};
}
start = entry;
}
if (!start) return { kind: "outside" };
return { kind: "open", host: start.host, cwd: start.cwd };
};
return {
note(report) {
if (disposed || term.buffer.active.type !== "normal") return;
const marker = term.registerMarker(0);
if (!marker) return;
const fresh: CwdMarkerEntry = {
id: nextId,
marker,
x: term.buffer.active.cursorX,
cols: term.cols,
cwd: report.cwd,
host: report.host,
};
nextId += 1;
const kept: CwdMarkerEntry[] = [];
for (const entry of entries) {
if (entry.marker.isDisposed || entry.marker.line >= marker.line) {
if (!entry.marker.isDisposed) entry.marker.dispose();
continue;
}
kept.push(entry);
}
kept.push(fresh);
while (kept.length > MAX_CWD_MARKERS) {
const removed = kept.shift();
if (removed && !removed.marker.isDisposed) removed.marker.dispose();
}
entries = kept;
},
inspectCell,
resolveCell(row, col) {
const inspection = inspectCell(row, col);
if (inspection.kind !== "closed" || !inspection.trusted) return null;
return { cwd: inspection.cwd, host: inspection.host };
},
latestHost() {
const list = liveEntries();
return list.length > 0 ? list[list.length - 1].host : null;
},
dispose() {
disposed = true;
for (const entry of entries) {
if (!entry.marker.isDisposed) entry.marker.dispose();
}
entries = [];
},
};
}
@@ -214,6 +214,8 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
@@ -529,6 +531,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const runtime = createXTermRuntime({
container: containerRef.current,
host,
getHost: () => archiveHostRef.current,
fontFamilyId,
resolvedFontFamily,
fontSize,
+162
View File
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import test from "node:test";
import type { Host } from "./models";
import {
buildArchiveExtractCommand,
findTerminalArchiveTokens,
resolveArchivePath,
supportsTerminalArchiveCommands,
} from "./terminalArchives.ts";
const host = {
id: "test",
label: "Test",
hostname: "host",
username: "me",
tags: [],
os: "linux",
} as Host;
function commandFor(printed: string, cwd: string | null = "/work"): string | null {
const token = findTerminalArchiveTokens(printed)[0];
if (!token) return null;
const abs = resolveArchivePath(token.path, cwd);
if (!abs) return null;
return buildArchiveExtractCommand(abs);
}
function splitShell(shell: "sh" | "fish", command: string): string[] | null {
if (shell === "fish") {
const probe = spawnSync("fish", ["-c", "true"], { encoding: "utf8" });
if (probe.error || probe.status !== 0) return null;
const out = execFileSync(
"fish",
["-c", `set argv ${command}; printf '%s\\0' $argv`],
{ encoding: "utf8" },
);
return out.split("\0").slice(0, -1);
}
const out = execFileSync(
"/bin/sh",
["-c", `set -- ${command}; printf '%s\\0' "$@"`],
{ encoding: "utf8" },
);
return out.split("\0").slice(0, -1);
}
test("Linux archive commands exclude Windows, WSL, macOS, and non-shell protocols", () => {
assert.equal(supportsTerminalArchiveCommands(host, "darwin"), false);
assert.equal(supportsTerminalArchiveCommands(host, "win32"), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, distro: "ubuntu" }, "darwin"), true);
assert.equal(supportsTerminalArchiveCommands({ ...host, osOverride: "linux" }, "darwin"), true);
for (const osOverride of ["unknown", "windows", "macos", "freebsd"] as const) {
assert.equal(
supportsTerminalArchiveCommands({ ...host, osOverride, distro: "ubuntu" }, "linux"),
false,
);
}
assert.equal(
supportsTerminalArchiveCommands({ ...host, distro: "ubuntu", deviceType: "network" }, "linux"),
false,
);
for (const protocol of ["telnet", "serial", "plugin:test"] as const) {
assert.equal(
supportsTerminalArchiveCommands({ ...host, protocol, osOverride: "linux" }, "linux"),
false,
);
}
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local" }, "darwin"), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local" }, "win32"), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local", os: "linux" }, "linux"), true);
});
test("each agreed suffix maps to the right extraction command, including upper case", () => {
const cases: Array<[string, string]> = [
["a.tar", "tar -xvf '/work/a.tar' -C '/work'"],
["b.tar.gz", "tar -xvzf '/work/b.tar.gz' -C '/work'"],
["c.tgz", "tar -xvzf '/work/c.tgz' -C '/work'"],
["d.tar.bz2", "tar -xvjf '/work/d.tar.bz2' -C '/work'"],
["e.tbz2", "tar -xvjf '/work/e.tbz2' -C '/work'"],
["f.tar.xz", "tar -xvJf '/work/f.tar.xz' -C '/work'"],
["h.zip", "unzip '/work/h.zip' -d '/work'"],
["A.TAR", "tar -xvf '/work/A.TAR' -C '/work'"],
["B.TAR.GZ", "tar -xvzf '/work/B.TAR.GZ' -C '/work'"],
["C.TGZ", "tar -xvzf '/work/C.TGZ' -C '/work'"],
["D.TAR.BZ2", "tar -xvjf '/work/D.TAR.BZ2' -C '/work'"],
["E.TBZ2", "tar -xvjf '/work/E.TBZ2' -C '/work'"],
["F.TAR.XZ", "tar -xvJf '/work/F.TAR.XZ' -C '/work'"],
["H.ZIP", "unzip '/work/H.ZIP' -d '/work'"],
];
for (const [name, expected] of cases) {
assert.equal(commandFor(name), expected, name);
assert.equal(/[\r\n]/u.test(expected), false);
}
for (const name of ["a.gz", "b.rar", "c.7z", "d.txz", "tool.exe", ".tar", ".zip", ".tar.gz"]) {
assert.deepEqual(findTerminalArchiveTokens(name), [], name);
}
});
test("quoted, Unicode, and command-substitution names stay one literal argument", () => {
const line = `"it's $(touch NO).tar.gz" 'my file.tar.gz' 文件.tar "it's.zip"`;
const tokens = findTerminalArchiveTokens(line);
assert.deepEqual(tokens.map((token) => token.path), [
"it's $(touch NO).tar.gz",
"my file.tar.gz",
"文件.tar",
"it's.zip",
]);
for (const token of tokens) {
assert.equal(line.slice(token.start, token.start + token.length), token.text);
const abs = resolveArchivePath(token.path, "/opt");
assert.ok(abs);
const command = buildArchiveExtractCommand(abs);
assert.ok(command);
assert.equal(/[\r\n]/u.test(command), false);
for (const shell of ["sh", "fish"] as const) {
const words = splitShell(shell, command);
if (!words) continue;
assert.ok(words.includes(abs), `${shell} ${command}`);
assert.equal(words.filter((word) => word === abs).length, 1);
}
}
const escaped = findTerminalArchiveTokens(String.raw`my\ file.tar.gz`);
assert.equal(escaped.length, 1);
assert.equal(escaped[0]?.path, "my file.tar.gz");
assert.equal(escaped[0]?.text, String.raw`my\ file.tar.gz`);
});
test("rejects home, urls, control characters, backslashes, history, zip globs, and open quotes", () => {
for (const printed of [
"~/a.tar.gz",
"~user/a.tar",
"http://example.com/a.zip",
"user@h:/a.tar",
"file\u001b.tar.gz",
String.raw`fish\name.tar.gz`,
"'fish\\name.tar.gz'",
"hist!.tar.gz",
"a*.zip",
"a?.zip",
"a[.zip",
'"unfinished.tar.gz',
]) {
assert.deepEqual(findTerminalArchiveTokens(printed), [], printed);
}
assert.equal(findTerminalArchiveTokens("a*.tar.gz").length, 1);
assert.equal(findTerminalArchiveTokens("“pack.zip”")[0]?.path, "pack.zip");
assert.equal(findTerminalArchiveTokens("‘a.tar’")[0]?.path, "a.tar");
});
test("resolveArchivePath normalizes against the interval cwd and keeps absolute paths", () => {
assert.equal(resolveArchivePath("sub/../a.tar.gz", "/x"), "/x/a.tar.gz");
assert.equal(resolveArchivePath("a.tar.gz", null), null);
assert.equal(resolveArchivePath("/opt/a.tar.gz", null), "/opt/a.tar.gz");
assert.equal(resolveArchivePath("/opt/a.tar.gz", "/elsewhere"), "/opt/a.tar.gz");
assert.equal(buildArchiveExtractCommand("/a.tar"), "tar -xvf '/a.tar' -C '/'");
assert.equal(
buildArchiveExtractCommand("/abs/a.zip"),
"unzip '/abs/a.zip' -d '/abs'",
);
});
+180
View File
@@ -0,0 +1,180 @@
import { resolveHostOs } from "./host";
import type { Host } from "./models";
const REMOTE_SHELL_PROTOCOLS = new Set(["ssh", "mosh", "et"]);
/**
* Linux shells only. Windows, including WSL local shells (a win32 app hosting
* a Linux guest), macOS, telnet, serial, plugin protocols, and network devices
* are excluded.
*/
export function supportsTerminalArchiveCommands(host: Host, localPlatform: string): boolean {
if (host.protocol === "local") return localPlatform === "linux";
if (host.protocol && !REMOTE_SHELL_PROTOCOLS.has(host.protocol)) return false;
if (host.deviceType === "network") return false;
return resolveHostOs(host) === "linux";
}
const ARCHIVE_SUFFIXES = [
".tar.gz",
".tar.bz2",
".tar.xz",
".tbz2",
".tgz",
".tar",
".zip",
] as const;
const OPEN_TO_CLOSE: Record<string, string> = {
"'": "'",
'"': '"',
"\u201c": "\u201d",
"\u2018": "\u2019",
};
export interface TerminalArchiveToken {
start: number;
length: number;
text: string;
path: string;
}
function isWhitespace(char: string): boolean {
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
}
function hasControlChar(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0) ?? 0;
if (code < 32 || code === 127) return true;
}
return false;
}
function archiveSuffix(name: string): string | null {
const lower = name.toLowerCase();
for (const suffix of ARCHIVE_SUFFIXES) {
if (lower.endsWith(suffix) && name.length > suffix.length) return suffix;
}
return null;
}
function rejectedArchivePath(path: string, suffix: string): boolean {
if (!path || hasControlChar(path)) return true;
if (path.startsWith("~")) return true;
const slash = path.indexOf("/");
const head = slash === -1 ? path : path.slice(0, slash);
if (head.includes(":")) return true;
if (path.includes("\\")) return true;
if (path.includes("!")) return true;
if (suffix === ".zip" && /[*?[]/u.test(path)) return true;
return false;
}
/** Whitespace-delimited archive names, including one matched quote pair or escaped spaces. */
export function findTerminalArchiveTokens(line: string): TerminalArchiveToken[] {
const tokens: TerminalArchiveToken[] = [];
let index = 0;
while (index < line.length) {
const char = line[index] ?? "";
if (isWhitespace(char)) {
index += 1;
continue;
}
const closer = OPEN_TO_CLOSE[char];
if (closer) {
const end = line.indexOf(closer, index + 1);
if (end < 0) {
while (index < line.length && !isWhitespace(line[index] ?? "")) index += 1;
continue;
}
const path = line.slice(index + 1, end);
const text = line.slice(index, end + 1);
const suffix = archiveSuffix(path);
if (suffix && !rejectedArchivePath(path, suffix)) {
tokens.push({ start: index, length: text.length, text, path });
}
index = end + 1;
continue;
}
let cursor = index;
let path = "";
while (cursor < line.length && !isWhitespace(line[cursor] ?? "")) {
if (line[cursor] === "\\" && line[cursor + 1] === " ") {
path += " ";
cursor += 2;
continue;
}
path += line[cursor] ?? "";
cursor += 1;
}
const text = line.slice(index, cursor);
const suffix = archiveSuffix(path);
if (suffix && !rejectedArchivePath(path, suffix)) {
tokens.push({ start: index, length: text.length, text, path });
}
index = cursor;
}
return tokens;
}
function posixNormalize(input: string): string {
const absolute = input.startsWith("/");
const parts: string[] = [];
for (const part of input.split("/")) {
if (part === "" || part === ".") continue;
if (part === "..") {
if (parts.length > 0) parts.pop();
continue;
}
parts.push(part);
}
if (absolute) return parts.length > 0 ? `/${parts.join("/")}` : "/";
return parts.length > 0 ? parts.join("/") : ".";
}
function posixDirname(absPath: string): string {
const normalized = posixNormalize(absPath);
if (!normalized.startsWith("/") || normalized === "/") return "/";
const index = normalized.lastIndexOf("/");
if (index <= 0) return "/";
return normalized.slice(0, index);
}
/** Resolve a printed name against an OSC 7 cwd. Relative names without a cwd are unknown. */
export function resolveArchivePath(filePath: string, cwd: string | null): string | null {
if (!filePath) return null;
if (filePath.startsWith("/")) return posixNormalize(filePath);
if (!cwd || !cwd.startsWith("/")) return null;
const normalized = posixNormalize(`${cwd}/${filePath}`);
return normalized.startsWith("/") ? normalized : null;
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
/** Fill-only extract command. The archive is unpacked next to itself. */
export function buildArchiveExtractCommand(absPath: string): string | null {
const suffix = archiveSuffix(absPath);
if (!suffix) return null;
const quotedPath = shellQuote(posixNormalize(absPath));
const quotedDir = shellQuote(posixDirname(absPath));
switch (suffix) {
case ".tar":
return `tar -xvf ${quotedPath} -C ${quotedDir}`;
case ".tar.gz":
case ".tgz":
return `tar -xvzf ${quotedPath} -C ${quotedDir}`;
case ".tar.bz2":
case ".tbz2":
return `tar -xvjf ${quotedPath} -C ${quotedDir}`;
case ".tar.xz":
return `tar -xvJf ${quotedPath} -C ${quotedDir}`;
case ".zip":
return `unzip ${quotedPath} -d ${quotedDir}`;
default:
return null;
}
}
+29 -1
View File
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isIdleShellReadyForCommandInjection } from "./terminalCommandInjectionReady.ts";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
} from "./terminalCommandInjectionReady.ts";
test("idle shell is ready only at a clean primary-screen prompt", () => {
assert.equal(
@@ -47,3 +50,28 @@ test("rejects sensitive prompts, alt-screen TUIs, partial input, and missing ter
false,
);
});
test("draft insertion still blocks sensitive prompts, alt-screen, and a missing prompt", () => {
const draft = {
hasLiveTerminal: true,
alternateScreenActive: false,
isAtPrompt: true,
userInputLength: 4,
pendingTypedInputLength: 2,
};
assert.equal(isShellPromptReadyForInsertion(draft), true);
assert.equal(isIdleShellReadyForCommandInjection(draft), false);
assert.equal(
isShellPromptReadyForInsertion({ ...draft, sensitiveInputActive: true }),
false,
);
assert.equal(
isShellPromptReadyForInsertion({ ...draft, alternateScreenActive: true }),
false,
);
assert.equal(isShellPromptReadyForInsertion({ ...draft, isAtPrompt: false }), false);
assert.equal(
isShellPromptReadyForInsertion({ ...draft, hasLiveTerminal: false }),
false,
);
});
+17 -6
View File
@@ -1,19 +1,30 @@
/**
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
*/
export function isIdleShellReadyForCommandInjection(options: {
export interface ShellPromptReadiness {
sensitiveInputActive?: boolean;
hasLiveTerminal: boolean;
alternateScreenActive: boolean;
isAtPrompt: boolean;
userInputLength: number;
pendingTypedInputLength?: number;
}): boolean {
}
/**
* Prompt is showing on the primary screen and is not a password/TUI.
* A typed draft is allowed; archive clicks insert at the cursor.
*/
export function isShellPromptReadyForInsertion(options: ShellPromptReadiness): boolean {
if (options.sensitiveInputActive) return false;
if (!options.hasLiveTerminal) return false;
if (options.alternateScreenActive) return false;
if (!options.isAtPrompt) return false;
return true;
}
/**
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
*/
export function isIdleShellReadyForCommandInjection(options: ShellPromptReadiness): boolean {
if (!isShellPromptReadyForInsertion(options)) return false;
if (options.userInputLength > 0) return false;
if ((options.pendingTypedInputLength ?? 0) > 0) return false;
return true;