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:
@@ -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;
|
||||
|
||||
207
domain/remoteHistory.test.ts
Normal file
207
domain/remoteHistory.test.ts
Normal 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
224
domain/remoteHistory.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user