feat(terminal): remote command history side panel (#1385)

* feat(terminal): add remote command history side panel

Read remote shell history over SSH/ET/Mosh exec channels, browse it in a virtualized side panel with search, paste, and save-as-snippet actions. Closes #1381.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): expand command detail inline below selected row

Move the detail strip from a fixed slot above the list into the row
immediately below the clicked entry so expansion reads top-to-bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): filter Netcatty AI PTY commands from remote history

Drop shell history lines containing the __NCMCP_ marker so AI exec noise
does not clutter the command history panel.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): tighten detail strip and add run action

Size the expanded row to its content, add a run-in-terminal button, and
use clearer snippet icon/tooltip for save-as-snippet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): address review findings before merge

Key cache by host+session, retry Mosh pending reads, and clamp virtual
list scroll position when filtered items shrink.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
陈大猫
2026-06-10 23:19:31 +08:00
committed by GitHub
parent a5b0efba75
commit ae209d37c1
31 changed files with 1702 additions and 7 deletions

View File

@@ -21,6 +21,19 @@ export interface ShellHistoryEntry {
timestamp: number;
}
// Remote Shell History - commands parsed from a remote host's own shell
// history file (~/.bash_history, ~/.zsh_history, fish_history), read on
// demand through the SSH/ET exec channel. Distinct from ShellHistoryEntry,
// which records commands typed inside Netcatty's own terminal sessions.
export type RemoteHistorySource = 'bash' | 'zsh' | 'fish';
export interface RemoteHistoryEntry {
id: string;
command: string;
source: RemoteHistorySource;
timestamp?: number; // Only set when the history file carries one (zsh EXTENDED_HISTORY, fish `when`)
}
// Connection Log - records connection history
export interface ConnectionLog {
id: string;

View File

@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseBashHistory,
parseZshHistory,
parseFishHistory,
parseShellHistory,
mergeRemoteHistory,
isNetcattyAiHistoryCommand,
} from './remoteHistory.ts';
test('parseBashHistory: plain lines', () => {
const out = parseBashHistory(['ls -la', 'cd /tmp', 'echo hi'].join('\n'));
assert.equal(out.length, 3);
assert.equal(out[0].command, 'ls -la');
assert.equal(out[0].source, 'bash');
assert.equal(out[0].timestamp, undefined);
});
test('parseBashHistory: HISTTIMEFORMAT timestamp lines', () => {
const text = ['#1700000000', 'ls -la', '#1700000100', 'pwd'].join('\n');
const out = parseBashHistory(text);
assert.equal(out.length, 2);
assert.equal(out[0].command, 'ls -la');
assert.equal(out[0].timestamp, 1700000000000);
assert.equal(out[1].command, 'pwd');
assert.equal(out[1].timestamp, 1700000100000);
});
test('parseBashHistory: skips blank lines and trims', () => {
const out = parseBashHistory('\n ls \n\necho hi\n');
assert.deepEqual(
out.map((e) => e.command),
['ls', 'echo hi'],
);
});
test('parseBashHistory: groups a multi-line command between timestamp markers', () => {
// Under HISTTIMEFORMAT + lithist, a multi-line command is stored with
// embedded newlines; the `#epoch` markers delimit one command from the next.
const text = [
'#1700000000',
'for i in 1 2 3',
'do',
' echo $i',
'done',
'#1700000100',
'pwd',
].join('\n');
const out = parseBashHistory(text);
assert.equal(out.length, 2);
assert.equal(out[0].command, 'for i in 1 2 3\ndo\n echo $i\ndone');
assert.equal(out[0].timestamp, 1700000000000);
assert.equal(out[1].command, 'pwd');
assert.equal(out[1].timestamp, 1700000100000);
});
test('parseZshHistory: plain lines', () => {
const out = parseZshHistory(['ls', 'cd /tmp'].join('\n'));
assert.equal(out.length, 2);
assert.equal(out[0].source, 'zsh');
assert.equal(out[0].timestamp, undefined);
});
test('parseZshHistory: EXTENDED_HISTORY format', () => {
const text = [': 1700000000:0;ls -la', ': 1700000100:0;pwd'].join('\n');
const out = parseZshHistory(text);
assert.equal(out.length, 2);
assert.equal(out[0].command, 'ls -la');
assert.equal(out[0].timestamp, 1700000000000);
assert.equal(out[1].command, 'pwd');
});
test('parseZshHistory: rejoins backslash line-continuations into one command', () => {
// zsh escapes each embedded newline with a trailing backslash. The three
// physical lines below are a single command; `pwd` is a separate record.
const text = [
': 1700000000:0;echo a\\',
'echo b\\',
'echo c',
': 1700000100:0;pwd',
].join('\n');
const out = parseZshHistory(text);
assert.equal(out.length, 2);
assert.equal(out[0].command, 'echo a\necho b\necho c');
assert.equal(out[0].timestamp, 1700000000000);
assert.equal(out[1].command, 'pwd');
});
test('parseFishHistory: cmd + when records', () => {
const text = [
'- cmd: ls -la',
' when: 1700000000',
'- cmd: pwd',
' when: 1700000100',
].join('\n');
const out = parseFishHistory(text);
assert.equal(out.length, 2);
assert.equal(out[0].command, 'ls -la');
assert.equal(out[0].source, 'fish');
assert.equal(out[0].timestamp, 1700000000000);
assert.equal(out[1].command, 'pwd');
assert.equal(out[1].timestamp, 1700000100000);
});
test('parseFishHistory: unescapes \\n and \\\\, ignores paths block', () => {
const text = [
'- cmd: echo foo\\nbar',
' when: 1700000000',
' paths:',
' - /tmp',
'- cmd: grep \\\\d file',
' when: 1700000100',
].join('\n');
const out = parseFishHistory(text);
assert.equal(out.length, 2);
assert.equal(out[0].command, 'echo foo\nbar');
assert.equal(out[1].command, 'grep \\d file');
});
test('parseFishHistory: tolerates a tail-truncated leading remnant', () => {
// `tail` may cut mid-record: leading when/paths lines with no cmd are ignored
const text = [
' when: 1699999999',
' paths:',
' - /x',
'- cmd: whoami',
' when: 1700000200',
].join('\n');
const out = parseFishHistory(text);
assert.equal(out.length, 1);
assert.equal(out[0].command, 'whoami');
assert.equal(out[0].timestamp, 1700000200000);
});
test('parseShellHistory: dispatches by source', () => {
assert.equal(parseShellHistory('bash', 'ls')[0].source, 'bash');
assert.equal(parseShellHistory('zsh', 'ls')[0].source, 'zsh');
assert.equal(parseShellHistory('fish', '- cmd: ls')[0].source, 'fish');
});
test('mergeRemoteHistory: dedupes keeping most recent occurrence', () => {
const a = parseBashHistory(['ls', 'pwd', 'ls'].join('\n'));
const b = parseZshHistory([': 1700000000:0;ls', ': 1700000100:0;whoami'].join('\n'));
const merged = mergeRemoteHistory([a, b]);
const commands = merged.map((e) => e.command);
// Newest-first, unique
assert.deepEqual(commands, ['whoami', 'ls', 'pwd']);
});
test('mergeRemoteHistory: caps to max', () => {
const entries = Array.from({ length: 50 }, (_, i) => `cmd-${i}`).join('\n');
const merged = mergeRemoteHistory([parseBashHistory(entries)], 10);
assert.equal(merged.length, 10);
// Newest-first means cmd-49 comes first
assert.equal(merged[0].command, 'cmd-49');
});
test('mergeRemoteHistory: orders by real timestamp, not concatenation order', () => {
// The zsh command (ts 200) is newer than the fish command (ts 100), even
// though fish is concatenated last. Newest-first must rank zsh first.
const zsh = parseZshHistory(': 1700000200:0;zsh-newer');
const fish = parseFishHistory(['- cmd: fish-older', ' when: 1700000100'].join('\n'));
const merged = mergeRemoteHistory([zsh, fish]);
assert.deepEqual(
merged.map((e) => e.command),
['zsh-newer', 'fish-older'],
);
});
test('mergeRemoteHistory: timestamped entries rank above untimestamped ones', () => {
const bash = parseBashHistory(['plain-a', 'plain-b'].join('\n')); // no timestamps
const zsh = parseZshHistory(': 1700000000:0;timed'); // carries a timestamp
const merged = mergeRemoteHistory([bash, zsh]);
// The timed entry wins; untimestamped entries keep file order (newest last).
assert.deepEqual(
merged.map((e) => e.command),
['timed', 'plain-b', 'plain-a'],
);
});
test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
assert.equal(
isNetcattyAiHistoryCommand('__NCMCP_abc123=0; ls -la'),
true,
);
assert.equal(
isNetcattyAiHistoryCommand('/opt/frp/frps.toml__NCMCP_mp56jbh6_3e30833'),
true,
);
assert.equal(isNetcattyAiHistoryCommand('ls -la'), false);
assert.equal(isNetcattyAiHistoryCommand('grep NCMCP log.txt'), false);
});
test('mergeRemoteHistory: drops Netcatty AI PTY history lines', () => {
const lists = [
parseBashHistory(
['ls -la', '__NCMCP_abc=0; pwd', 'git status'].join('\n'),
),
];
const merged = mergeRemoteHistory(lists);
assert.deepEqual(
merged.map((e) => e.command),
['git status', 'ls -la'],
);
});

224
domain/remoteHistory.ts Normal file
View File

@@ -0,0 +1,224 @@
import { RemoteHistoryEntry, RemoteHistorySource } from './models';
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
/** True when a shell history line came from Netcatty AI PTY exec, not the user. */
export function isNetcattyAiHistoryCommand(command: string): boolean {
return command.includes(NETCATTY_AI_HISTORY_MARKER);
}
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
// fish_history is a YAML subset: each record starts with `- cmd: <value>`,
// optionally followed by ` when: <epoch>` and a ` paths:` block.
const FISH_CMD_LINE = /^- cmd:\s?(.*)$/;
const FISH_WHEN_LINE = /^\s+when:\s*(\d+)/;
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `rh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
/**
* zsh writes a multi-line command across several physical lines, escaping each
* embedded newline with a trailing backslash. Reassemble those physical lines
* back into one logical record: a line continues onto the next when it ends
* with an odd number of backslashes (an even count is escaped literal
* backslashes, not a continuation). The escaping backslash is dropped.
*/
function joinContinuations(lines: string[]): string[] {
const records: string[] = [];
let buffer: string | null = null;
for (const line of lines) {
const trailingBackslashes = /\\*$/.exec(line)?.[0].length ?? 0;
const continues = trailingBackslashes % 2 === 1;
const body = continues ? line.slice(0, -1) : line;
buffer = buffer === null ? body : `${buffer}\n${body}`;
if (!continues) {
records.push(buffer);
buffer = null;
}
}
if (buffer !== null) records.push(buffer);
return records;
}
/**
* Reverse fish's history escaping: it stores commands on a single line,
* encoding backslash as `\\` and newline as `\n`.
*/
const unescapeFishValue = (value: string): string => {
let result = '';
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (ch === '\\' && i + 1 < value.length) {
const next = value[i + 1];
if (next === 'n') {
result += '\n';
i += 1;
continue;
}
if (next === '\\') {
result += '\\';
i += 1;
continue;
}
}
result += ch;
}
return result;
};
export function parseBashHistory(text: string): RemoteHistoryEntry[] {
if (!text) return [];
const result: RemoteHistoryEntry[] = [];
const lines = text.split(/\r?\n/).map((line) => line.replace(/\r$/, ''));
let pendingTimestamp: number | undefined;
let pendingLines: string[] = [];
let inTimestampedRecord = false;
const flush = () => {
if (pendingLines.length) {
const command = pendingLines.join('\n').trim();
if (command) {
result.push({ id: makeId(), command, source: 'bash', timestamp: pendingTimestamp });
}
}
pendingLines = [];
pendingTimestamp = undefined;
};
for (const line of lines) {
// Bash HISTTIMEFORMAT writes a `#<epoch>` line before each command. That
// marker also delimits records, which lets us regroup a multi-line command
// (stored with embedded newlines under `lithist`) back into one entry.
const tsMatch = /^#(\d{9,})$/.exec(line);
if (tsMatch) {
flush();
pendingTimestamp = Number(tsMatch[1]) * 1000;
inTimestampedRecord = true;
continue;
}
if (inTimestampedRecord) {
pendingLines.push(line);
continue;
}
// Without timestamp markers the file has no record delimiter, so fall back
// to one command per line (this is also how bash itself re-reads the file).
const command = line.trim();
if (command) {
result.push({ id: makeId(), command, source: 'bash', timestamp: undefined });
}
}
flush();
return result;
}
export function parseZshHistory(text: string): RemoteHistoryEntry[] {
if (!text) return [];
const result: RemoteHistoryEntry[] = [];
const lines = text.split(/\r?\n/).map((line) => line.replace(/\r$/, ''));
for (const record of joinContinuations(lines)) {
const extended = ZSH_EXTENDED_RECORD.exec(record);
if (extended) {
const command = (extended[2] ?? '').trim();
if (!command) continue;
result.push({
id: makeId(),
command,
source: 'zsh',
timestamp: Number(extended[1]) * 1000,
});
continue;
}
const command = record.trim();
if (!command) continue;
result.push({
id: makeId(),
command,
source: 'zsh',
});
}
return result;
}
export function parseFishHistory(text: string): RemoteHistoryEntry[] {
if (!text) return [];
const result: RemoteHistoryEntry[] = [];
const lines = text.split(/\r?\n/);
let current: RemoteHistoryEntry | null = null;
const flush = () => {
if (current) {
result.push(current);
current = null;
}
};
for (const raw of lines) {
const line = raw.replace(/\r$/, '');
const cmdMatch = FISH_CMD_LINE.exec(line);
if (cmdMatch) {
flush();
const command = unescapeFishValue(cmdMatch[1] ?? '').trim();
if (!command) continue; // skip empty command, stay outside a record
current = { id: makeId(), command, source: 'fish' };
continue;
}
if (current) {
const whenMatch = FISH_WHEN_LINE.exec(line);
if (whenMatch) {
current.timestamp = Number(whenMatch[1]) * 1000;
}
// ` paths:` and its ` - …` entries (and any leading remnant lines
// from a tail-truncated first record) are ignored.
}
}
flush();
return result;
}
export function parseShellHistory(
source: RemoteHistorySource,
text: string,
): RemoteHistoryEntry[] {
if (source === 'bash') return parseBashHistory(text);
if (source === 'fish') return parseFishHistory(text);
return parseZshHistory(text);
}
/**
* Merge multiple history lists into one newest-first, de-duplicated list.
*
* Entries are ordered by their real timestamp when they carry one (zsh
* EXTENDED_HISTORY, fish `when`, bash HISTTIMEFORMAT). Entries without a
* timestamp are treated as older than any timestamped entry and otherwise keep
* their original file order (later in the file = newer). This stops an
* always-timestamped source (e.g. fish) from leap-frogging another source
* purely because `flat()` placed it last. De-duplication is by exact command
* text, keeping the newest occurrence, and the result is capped to `max`.
*/
export function mergeRemoteHistory(
lists: RemoteHistoryEntry[][],
max = 1000,
): RemoteHistoryEntry[] {
const indexed = lists.flat().map((entry, index) => ({ entry, index }));
indexed.sort((a, b) => {
const ta = a.entry.timestamp ?? 0;
const tb = b.entry.timestamp ?? 0;
if (ta !== tb) return tb - ta; // newest timestamp first
return b.index - a.index; // same/no timestamp: later in the file first
});
const seen = new Set<string>();
const merged: RemoteHistoryEntry[] = [];
for (const { entry } of indexed) {
if (isNetcattyAiHistoryCommand(entry.command)) continue;
if (seen.has(entry.command)) continue;
seen.add(entry.command);
merged.push(entry);
if (merged.length >= max) break;
}
return merged;
}