mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge master (PR #112 terminal pane-buffer rework) into v1-readiness-hardening
Conflict in src/web/public/app.js selectSession: combined #112's _clearTerminalLoadState cleanup on stale select with #113's {success,data} envelope unwrap of the terminal fetch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
<h2 align="center">The missing control plane for AI coding agents</h2>
|
<h2 align="center">The missing control plane for AI coding agents</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Agent Visualization • Zero-Lag Input • Autonomous Orchestrator • Respawn Controller • Mobile-First UI • Hardened Security</em>
|
<em>Agent Visualization • Zero-Lag Input • Mobile-First UI • Hardened Security</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -192,6 +192,12 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
getAttachArgs(muxName: string): string[];
|
getAttachArgs(muxName: string): string[];
|
||||||
|
|
||||||
|
/** Pin a mux window so client attaches do not automatically dictate its size. */
|
||||||
|
setManualWindowSize?(muxName: string): boolean;
|
||||||
|
|
||||||
|
/** Explicitly resize a mux window after Codeman accepts a terminal resize. */
|
||||||
|
resizeWindow?(muxName: string, cols: number, rows: number): boolean;
|
||||||
|
|
||||||
// ========== Availability ==========
|
// ========== Availability ==========
|
||||||
|
|
||||||
/** Check if the multiplexer binary is available on the system */
|
/** Check if the multiplexer binary is available on the system */
|
||||||
@@ -205,4 +211,10 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
|
|
||||||
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
||||||
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
||||||
|
|
||||||
|
/** Capture a pane's current tmux buffer with ANSI escape codes preserved. */
|
||||||
|
capturePaneBuffer?(muxName: string, paneTarget: string): string | null;
|
||||||
|
|
||||||
|
/** Capture the active pane's current tmux buffer with ANSI escape codes preserved. */
|
||||||
|
captureActivePaneBuffer?(muxName: string): string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -1004,7 +1004,12 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach to the mux session via PTY
|
// Attach to the mux session via PTY
|
||||||
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
|
// Prevent tmux from letting the newest browser attach dictate global window
|
||||||
|
// size; accepted Codeman resize events update it explicitly below.
|
||||||
|
mux.setManualWindowSize?.(this._muxSession!.muxName);
|
||||||
|
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
|
||||||
|
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
|
||||||
|
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
|
||||||
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
||||||
try {
|
try {
|
||||||
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||||
@@ -2057,6 +2062,9 @@ export class Session extends EventEmitter {
|
|||||||
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
||||||
this._ptyCols = cols;
|
this._ptyCols = cols;
|
||||||
this._ptyRows = rows;
|
this._ptyRows = rows;
|
||||||
|
if (this._mux && this._muxSession) {
|
||||||
|
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
|
||||||
|
}
|
||||||
this.ptyProcess.resize(cols, rows);
|
this.ptyProcess.resize(cols, rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+384
-6
@@ -164,6 +164,280 @@ export function parsePaneList(output: string): Map<string, number> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
|
||||||
|
* Prefers the active pane and falls back to the first valid pane.
|
||||||
|
*/
|
||||||
|
export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): string | null {
|
||||||
|
if (!isValidMuxName(muxName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (paneTarget === undefined || paneTarget === 'active') {
|
||||||
|
return muxName;
|
||||||
|
}
|
||||||
|
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${muxName}.${paneTarget}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
|
||||||
|
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
|
||||||
|
*/
|
||||||
|
export function resolveActivePaneTarget(output: string): string | null {
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const sep = line.indexOf(':');
|
||||||
|
if (sep === -1) continue;
|
||||||
|
const paneId = line.slice(0, sep).trim();
|
||||||
|
const active = line.slice(sep + 1).trim();
|
||||||
|
if (paneId && active === '1') return paneId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphemeSegmenter = {
|
||||||
|
segment(input: string): Iterable<{ segment: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRAPHEME_SEGMENTER: GraphemeSegmenter | null = (() => {
|
||||||
|
try {
|
||||||
|
const Segmenter = (
|
||||||
|
Intl as typeof Intl & {
|
||||||
|
Segmenter?: new (locale?: string, options?: { granularity: 'grapheme' }) => GraphemeSegmenter;
|
||||||
|
}
|
||||||
|
).Segmenter;
|
||||||
|
return Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function findEscapeEnd(text: string, start: number): number {
|
||||||
|
const type = text[start + 1];
|
||||||
|
if (type === '[') {
|
||||||
|
for (let i = start + 2; i < text.length; i++) {
|
||||||
|
const code = text.charCodeAt(i);
|
||||||
|
if (code >= 0x40 && code <= 0x7e) return i;
|
||||||
|
}
|
||||||
|
return text.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === ']') {
|
||||||
|
for (let i = start + 2; i < text.length; i++) {
|
||||||
|
if (text.charCodeAt(i) === 0x07) return i;
|
||||||
|
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
|
||||||
|
}
|
||||||
|
return text.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'P' || type === '^' || type === '_' || type === 'X') {
|
||||||
|
for (let i = start + 2; i < text.length; i++) {
|
||||||
|
if (text.charCodeAt(i) === 0x07) return i;
|
||||||
|
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
|
||||||
|
}
|
||||||
|
return text.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(start + 1, text.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePaneLineStyles(line: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
if (line[i] !== '\x1b') {
|
||||||
|
result += line[i];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = findEscapeEnd(line, i);
|
||||||
|
const sequence = line.slice(i, end + 1);
|
||||||
|
if (isSgrSequence(sequence)) {
|
||||||
|
result += sequence;
|
||||||
|
}
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSgrSequence(sequence: string): boolean {
|
||||||
|
return (
|
||||||
|
sequence.length >= 3 &&
|
||||||
|
sequence.charCodeAt(0) === 27 &&
|
||||||
|
sequence[1] === '[' &&
|
||||||
|
sequence.endsWith('m') &&
|
||||||
|
/^[0-9;:]*$/.test(sequence.slice(2, -1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZeroWidthCodePoint(codePoint: number): boolean {
|
||||||
|
return (
|
||||||
|
codePoint === 0x00ad ||
|
||||||
|
codePoint === 0x034f ||
|
||||||
|
codePoint === 0x061c ||
|
||||||
|
codePoint === 0x115f ||
|
||||||
|
codePoint === 0x1160 ||
|
||||||
|
codePoint === 0x17b4 ||
|
||||||
|
codePoint === 0x17b5 ||
|
||||||
|
codePoint === 0x180e ||
|
||||||
|
codePoint === 0x200b ||
|
||||||
|
codePoint === 0x200c ||
|
||||||
|
codePoint === 0x200d ||
|
||||||
|
codePoint === 0x2060 ||
|
||||||
|
codePoint === 0xfeff ||
|
||||||
|
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||||
|
(codePoint >= 0x0483 && codePoint <= 0x0489) ||
|
||||||
|
(codePoint >= 0x0591 && codePoint <= 0x05bd) ||
|
||||||
|
codePoint === 0x05bf ||
|
||||||
|
(codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
|
||||||
|
(codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
|
||||||
|
codePoint === 0x05c7 ||
|
||||||
|
(codePoint >= 0x0610 && codePoint <= 0x061a) ||
|
||||||
|
(codePoint >= 0x064b && codePoint <= 0x065f) ||
|
||||||
|
codePoint === 0x0670 ||
|
||||||
|
(codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
|
||||||
|
(codePoint >= 0x06df && codePoint <= 0x06e4) ||
|
||||||
|
(codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
|
||||||
|
(codePoint >= 0x06ea && codePoint <= 0x06ed) ||
|
||||||
|
codePoint === 0x0711 ||
|
||||||
|
(codePoint >= 0x0730 && codePoint <= 0x074a) ||
|
||||||
|
(codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
|
||||||
|
(codePoint >= 0x07eb && codePoint <= 0x07f3) ||
|
||||||
|
(codePoint >= 0x0816 && codePoint <= 0x0819) ||
|
||||||
|
(codePoint >= 0x081b && codePoint <= 0x0823) ||
|
||||||
|
(codePoint >= 0x0825 && codePoint <= 0x0827) ||
|
||||||
|
(codePoint >= 0x0829 && codePoint <= 0x082d) ||
|
||||||
|
(codePoint >= 0x0859 && codePoint <= 0x085b) ||
|
||||||
|
(codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
|
||||||
|
(codePoint >= 0x08e3 && codePoint <= 0x0902) ||
|
||||||
|
(codePoint >= 0x093a && codePoint <= 0x093c) ||
|
||||||
|
codePoint === 0x094d ||
|
||||||
|
(codePoint >= 0x0951 && codePoint <= 0x0957) ||
|
||||||
|
(codePoint >= 0x0962 && codePoint <= 0x0963) ||
|
||||||
|
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
||||||
|
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
||||||
|
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
||||||
|
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||||
|
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
|
||||||
|
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWideCodePoint(codePoint: number): boolean {
|
||||||
|
return (
|
||||||
|
codePoint >= 0x1100 &&
|
||||||
|
(codePoint <= 0x115f ||
|
||||||
|
codePoint === 0x2329 ||
|
||||||
|
codePoint === 0x232a ||
|
||||||
|
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
||||||
|
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||||
|
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||||
|
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
||||||
|
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
||||||
|
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||||
|
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||||
|
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
||||||
|
(codePoint >= 0x20000 && codePoint <= 0x3fffd))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextGrapheme(text: string, start: number): { value: string; nextIndex: number } {
|
||||||
|
if (GRAPHEME_SEGMENTER) {
|
||||||
|
const iterator = GRAPHEME_SEGMENTER.segment(text.slice(start))[Symbol.iterator]();
|
||||||
|
const next = iterator.next();
|
||||||
|
if (!next.done && next.value.segment) {
|
||||||
|
return { value: next.value.segment, nextIndex: start + next.value.segment.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = text.codePointAt(start);
|
||||||
|
if (first === undefined) return { value: '', nextIndex: start + 1 };
|
||||||
|
let value = String.fromCodePoint(first);
|
||||||
|
let nextIndex = start + value.length;
|
||||||
|
while (nextIndex < text.length) {
|
||||||
|
const codePoint = text.codePointAt(nextIndex);
|
||||||
|
if (codePoint === undefined || !isZeroWidthCodePoint(codePoint)) break;
|
||||||
|
const mark = String.fromCodePoint(codePoint);
|
||||||
|
value += mark;
|
||||||
|
nextIndex += mark.length;
|
||||||
|
}
|
||||||
|
return { value, nextIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalCellWidth(grapheme: string): number {
|
||||||
|
let hasVisible = false;
|
||||||
|
let hasWide = false;
|
||||||
|
for (let i = 0; i < grapheme.length; i++) {
|
||||||
|
const codePoint = grapheme.codePointAt(i);
|
||||||
|
if (codePoint === undefined) continue;
|
||||||
|
if (codePoint > 0xffff) i++;
|
||||||
|
if (isZeroWidthCodePoint(codePoint) || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hasVisible = true;
|
||||||
|
if (isWideCodePoint(codePoint)) hasWide = true;
|
||||||
|
}
|
||||||
|
if (!hasVisible) return 0;
|
||||||
|
return hasWide ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePaneLineByVisibleColumns(line: string, maxColumns: number): string {
|
||||||
|
let result = '';
|
||||||
|
let visibleColumns = 0;
|
||||||
|
let sawSgr = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
if (line[i] === '\x1b') {
|
||||||
|
const end = findEscapeEnd(line, i);
|
||||||
|
const sequence = line.slice(i, end + 1);
|
||||||
|
if (isSgrSequence(sequence)) {
|
||||||
|
result += sequence;
|
||||||
|
sawSgr = true;
|
||||||
|
}
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grapheme = nextGrapheme(line, i);
|
||||||
|
const width = terminalCellWidth(grapheme.value);
|
||||||
|
if (width === 0) {
|
||||||
|
result += grapheme.value;
|
||||||
|
} else if (visibleColumns + width <= maxColumns) {
|
||||||
|
result += grapheme.value;
|
||||||
|
visibleColumns += width;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = grapheme.nextIndex - 1;
|
||||||
|
if (visibleColumns >= maxColumns) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawSgr) {
|
||||||
|
result += '\x1b[0m';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPaneSnapshot(
|
||||||
|
lines: string[],
|
||||||
|
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||||
|
): string {
|
||||||
|
const cols = Math.max(1, geometry.cols);
|
||||||
|
const paintCols = Math.max(1, cols - 1);
|
||||||
|
const rows = Math.max(1, geometry.rows);
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let row = 0; row < Math.min(lines.length, rows); row++) {
|
||||||
|
const safeLine = truncatePaneLineByVisibleColumns(sanitizePaneLineStyles(lines[row]), paintCols);
|
||||||
|
parts.push(`\x1b[${row + 1};1H${safeLine}`);
|
||||||
|
}
|
||||||
|
const cursorX = Math.max(0, Math.min(cols - 1, geometry.cursorX));
|
||||||
|
const cursorY = Math.max(0, Math.min(rows - 1, geometry.cursorY));
|
||||||
|
parts.push(`\x1b[${cursorY + 1};${cursorX + 1}H`);
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
|
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
|
||||||
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
|
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
|
||||||
|
|
||||||
@@ -175,6 +449,10 @@ function isValidMuxName(name: string): boolean {
|
|||||||
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
|
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidTerminalDimension(value: number): boolean {
|
||||||
|
return Number.isSafeInteger(value) && value > 0 && value <= 1000;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a path contains only safe characters.
|
* Validates that a path contains only safe characters.
|
||||||
* Prevents command injection via malformed paths.
|
* Prevents command injection via malformed paths.
|
||||||
@@ -682,14 +960,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||||
const cleanEnv = { ...process.env };
|
const cleanEnv = { ...process.env };
|
||||||
delete cleanEnv.TMUX;
|
delete cleanEnv.TMUX;
|
||||||
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
|
// Create the session on the dedicated socket (${this.tmux()} = `tmux -L <socket>`),
|
||||||
// blips do not poison tmux's long-lived getcwd state.
|
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
|
||||||
|
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
|
||||||
|
// pane cd's into workingDir below via respawn-pane.
|
||||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
||||||
cwd: TMUX_LAUNCH_CWD,
|
cwd: TMUX_LAUNCH_CWD,
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: cleanEnv,
|
env: cleanEnv,
|
||||||
});
|
});
|
||||||
|
this.resizeWindow(muxName, 120, 40);
|
||||||
|
|
||||||
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
||||||
try {
|
try {
|
||||||
@@ -1689,8 +1970,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture the current buffer of a specific pane.
|
* Capture the current visible text and SGR styles of a specific pane.
|
||||||
* Returns the pane content with ANSI escape codes preserved.
|
*
|
||||||
|
* `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
|
||||||
|
* codes are preserved, while cursor/erase/scroll-region controls are stripped
|
||||||
|
* before rows are repainted at absolute positions in browser xterm.
|
||||||
*/
|
*/
|
||||||
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
|
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
|
||||||
if (IS_TEST_MODE) return '';
|
if (IS_TEST_MODE) return '';
|
||||||
@@ -1706,16 +1990,67 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
}).replace(/\n+$/g, '');
|
||||||
|
try {
|
||||||
|
const cursor = execSync(
|
||||||
|
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||||
|
{
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
).trim();
|
||||||
|
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
|
||||||
|
if (
|
||||||
|
Number.isFinite(cursorX) &&
|
||||||
|
Number.isFinite(cursorY) &&
|
||||||
|
Number.isFinite(cols) &&
|
||||||
|
Number.isFinite(rows) &&
|
||||||
|
cursorX >= 0 &&
|
||||||
|
cursorY >= 0 &&
|
||||||
|
cols > 0 &&
|
||||||
|
rows > 0
|
||||||
|
) {
|
||||||
|
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
|
||||||
|
}
|
||||||
|
} catch (cursorErr) {
|
||||||
|
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the active pane for a tmux session.
|
||||||
|
*
|
||||||
|
* Pane ids are not stable across respawns or restores, so callers should not
|
||||||
|
* assume the first pane remains `%0`.
|
||||||
|
*/
|
||||||
|
captureActivePaneBuffer(muxName: string): string | null {
|
||||||
|
if (IS_TEST_MODE) return '';
|
||||||
|
if (!isValidMuxName(muxName)) {
|
||||||
|
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = execSync(`${this.tmux()} list-panes -t ${shellescape(muxName)} -F '#{pane_id}:#{pane_active}'`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}).trim();
|
||||||
|
const target = resolveActivePaneTarget(output);
|
||||||
|
return target ? this.capturePaneBuffer(muxName, target) : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start piping pane output to a file using tmux pipe-pane.
|
* Start piping pane output to a file using tmux pipe-pane.
|
||||||
* Only pipes output direction (-O) to avoid echoing input.
|
* Only pipes output direction (-O) to avoid echoing input.
|
||||||
@@ -1785,6 +2120,49 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
|
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setManualWindowSize(muxName: string): boolean {
|
||||||
|
if (!isValidMuxName(muxName)) {
|
||||||
|
console.error('[TmuxManager] Invalid session name in setManualWindowSize:', muxName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`${this.tmux()} set-window-option -t ${shellescape(muxName)} window-size manual`, {
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TmuxManager] Failed to set manual window size:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeWindow(muxName: string, cols: number, rows: number): boolean {
|
||||||
|
if (!isValidMuxName(muxName)) {
|
||||||
|
console.error('[TmuxManager] Invalid session name in resizeWindow:', muxName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isValidTerminalDimension(cols) || !isValidTerminalDimension(rows)) {
|
||||||
|
console.error('[TmuxManager] Invalid resize dimensions:', { cols, rows });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget: this runs on the interactive resize path (WS {t:'z'} and
|
||||||
|
// HTTP /resize), so use a non-blocking exec — a slow/hung tmux must not stall
|
||||||
|
// the Fastify event loop while other sessions' input/SSE are served. The sole
|
||||||
|
// caller (Session.resize) ignores the result, and under `window-size manual`
|
||||||
|
// the subsequent ptyProcess.resize is subordinate to this authoritative size.
|
||||||
|
exec(
|
||||||
|
`${this.tmux()} resize-window -t ${shellescape(muxName)} -x ${cols} -y ${rows}`,
|
||||||
|
{ timeout: EXEC_TIMEOUT_MS },
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('[TmuxManager] Failed to resize tmux window:', err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
isAvailable(): boolean {
|
isAvailable(): boolean {
|
||||||
return TmuxManager.isTmuxAvailable();
|
return TmuxManager.isTmuxAvailable();
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-32
@@ -314,6 +314,7 @@ class CodemanApp {
|
|||||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||||
|
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
|
||||||
this.respawnStatus = {};
|
this.respawnStatus = {};
|
||||||
this.respawnTimers = {}; // Track timed respawn timers
|
this.respawnTimers = {}; // Track timed respawn timers
|
||||||
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
|
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
|
||||||
@@ -416,6 +417,8 @@ class CodemanApp {
|
|||||||
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
|
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
|
||||||
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
|
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
|
||||||
this._loadBufferQueue = null; // queued SSE events during buffer load
|
this._loadBufferQueue = null; // queued SSE events during buffer load
|
||||||
|
this._bufferLoadSeq = 0;
|
||||||
|
this._bufferLoadOwner = null;
|
||||||
|
|
||||||
// Flicker filter state (buffers output after screen clears)
|
// Flicker filter state (buffers output after screen clears)
|
||||||
this.flickerFilterBuffer = '';
|
this.flickerFilterBuffer = '';
|
||||||
@@ -486,7 +489,7 @@ class CodemanApp {
|
|||||||
// If stale, cleans up buffer-loading state and returns true.
|
// If stale, cleans up buffer-loading state and returns true.
|
||||||
_isStaleSelect(selectGen) {
|
_isStaleSelect(selectGen) {
|
||||||
if (selectGen !== this._selectGeneration) {
|
if (selectGen !== this._selectGeneration) {
|
||||||
if (this._isLoadingBuffer) this._finishBufferLoad();
|
if (this._isLoadingBuffer) this._finishBufferLoad(selectGen);
|
||||||
this._restoringFlushedState = false;
|
this._restoringFlushedState = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -649,6 +652,7 @@ class CodemanApp {
|
|||||||
this._disposeWebGLObserver();
|
this._disposeWebGLObserver();
|
||||||
this._webglAddon?.dispose();
|
this._webglAddon?.dispose();
|
||||||
this._webglAddon = null;
|
this._webglAddon = null;
|
||||||
|
this._scheduleTerminalRepaint();
|
||||||
});
|
});
|
||||||
this.terminal.loadAddon(this._webglAddon);
|
this.terminal.loadAddon(this._webglAddon);
|
||||||
console.log('[CRASH-DIAG] WebGL renderer enabled');
|
console.log('[CRASH-DIAG] WebGL renderer enabled');
|
||||||
@@ -679,7 +683,7 @@ class CodemanApp {
|
|||||||
this._disposeWebGLObserver();
|
this._disposeWebGLObserver();
|
||||||
this._webglAddon?.dispose();
|
this._webglAddon?.dispose();
|
||||||
this._webglAddon = null;
|
this._webglAddon = null;
|
||||||
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
|
this._scheduleTerminalRepaint();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
|
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
|
||||||
@@ -698,6 +702,22 @@ class CodemanApp {
|
|||||||
this._webglLongTaskObserver = null;
|
this._webglLongTaskObserver = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repaint the full terminal viewport after a renderer swap (WebGL → canvas/DOM).
|
||||||
|
* Scheduled on the next frame so it lands after the addon teardown settles, and
|
||||||
|
* debounced so the context-loss and long-task fallback paths can't double-fire.
|
||||||
|
* No-ops safely if the terminal isn't ready.
|
||||||
|
*/
|
||||||
|
_scheduleTerminalRepaint() {
|
||||||
|
if (this._terminalRepaintScheduled) return;
|
||||||
|
this._terminalRepaintScheduled = true;
|
||||||
|
const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 0);
|
||||||
|
raf(() => {
|
||||||
|
this._terminalRepaintScheduled = false;
|
||||||
|
try { this.terminal?.refresh(0, this.terminal.rows - 1); } catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_disableWebGLSticky(reason) {
|
_disableWebGLSticky(reason) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
|
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
|
||||||
@@ -2039,6 +2059,7 @@ class CodemanApp {
|
|||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
this._isLoadingBuffer = false;
|
this._isLoadingBuffer = false;
|
||||||
this._loadBufferQueue = null;
|
this._loadBufferQueue = null;
|
||||||
|
this._bufferLoadOwner = null;
|
||||||
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
|
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
|
||||||
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||||
// Preserve local echo overlay text across SSE reconnect — just hide until
|
// Preserve local echo overlay text across SSE reconnect — just hide until
|
||||||
@@ -2281,7 +2302,7 @@ class CodemanApp {
|
|||||||
|
|
||||||
renderSessionTabs() {
|
renderSessionTabs() {
|
||||||
// Don't re-render while user is typing in the inline rename input
|
// Don't re-render while user is typing in the inline rename input
|
||||||
if (this._activeRename) return;
|
if (this._inlineRenameActive) return;
|
||||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2299,6 +2320,45 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_setTerminalLoadState(sessionId, selectGen, phase) {
|
||||||
|
this.terminalLoadStates.set(sessionId, { generation: selectGen, phase });
|
||||||
|
this._updateTerminalLoadTab(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearTerminalLoadState(sessionId, selectGen) {
|
||||||
|
const state = this.terminalLoadStates.get(sessionId);
|
||||||
|
if (state && state.generation !== selectGen) return;
|
||||||
|
this.terminalLoadStates.delete(sessionId);
|
||||||
|
this._updateTerminalLoadTab(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateTerminalLoadTab(sessionId) {
|
||||||
|
const tab = this.$('sessionTabs')?.querySelector(`.session-tab[data-id="${sessionId}"]`);
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
const loadState = this.terminalLoadStates.get(sessionId);
|
||||||
|
tab.classList.toggle('tab-loading', !!loadState);
|
||||||
|
if (loadState) {
|
||||||
|
tab.setAttribute('aria-busy', 'true');
|
||||||
|
tab.dataset.loadPhase = loadState.phase;
|
||||||
|
if (!tab.querySelector('.tab-load-spinner')) {
|
||||||
|
const spinner = document.createElement('span');
|
||||||
|
spinner.className = 'tab-load-spinner';
|
||||||
|
spinner.setAttribute('aria-hidden', 'true');
|
||||||
|
const numberEl = tab.querySelector('.tab-number');
|
||||||
|
if (numberEl) {
|
||||||
|
numberEl.insertAdjacentElement('afterend', spinner);
|
||||||
|
} else {
|
||||||
|
tab.insertBefore(spinner, tab.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tab.setAttribute('aria-busy', 'false');
|
||||||
|
delete tab.dataset.loadPhase;
|
||||||
|
tab.querySelector('.tab-load-spinner')?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_renderSessionTabsImmediate() {
|
_renderSessionTabsImmediate() {
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||||
@@ -2320,6 +2380,7 @@ class CodemanApp {
|
|||||||
const name = this.getSessionName(session);
|
const name = this.getSessionName(session);
|
||||||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||||||
const hasRunningTasks = taskStats.running > 0;
|
const hasRunningTasks = taskStats.running > 0;
|
||||||
|
const loadState = this.terminalLoadStates.get(id);
|
||||||
|
|
||||||
// Update active class
|
// Update active class
|
||||||
if (isActive && !tab.classList.contains('active')) {
|
if (isActive && !tab.classList.contains('active')) {
|
||||||
@@ -2328,6 +2389,27 @@ class CodemanApp {
|
|||||||
tab.classList.remove('active');
|
tab.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tab.classList.toggle('tab-loading', !!loadState);
|
||||||
|
if (loadState) {
|
||||||
|
tab.setAttribute('aria-busy', 'true');
|
||||||
|
tab.dataset.loadPhase = loadState.phase;
|
||||||
|
if (!tab.querySelector('.tab-load-spinner')) {
|
||||||
|
const spinner = document.createElement('span');
|
||||||
|
spinner.className = 'tab-load-spinner';
|
||||||
|
spinner.setAttribute('aria-hidden', 'true');
|
||||||
|
const numberEl = tab.querySelector('.tab-number');
|
||||||
|
if (numberEl) {
|
||||||
|
numberEl.insertAdjacentElement('afterend', spinner);
|
||||||
|
} else {
|
||||||
|
tab.insertBefore(spinner, tab.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tab.setAttribute('aria-busy', 'false');
|
||||||
|
delete tab.dataset.loadPhase;
|
||||||
|
tab.querySelector('.tab-load-spinner')?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Update alert class
|
// Update alert class
|
||||||
const alertType = this.tabAlerts.get(id);
|
const alertType = this.tabAlerts.get(id);
|
||||||
const wantAction = alertType === 'action';
|
const wantAction = alertType === 'action';
|
||||||
@@ -2426,7 +2508,7 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_fullRenderSessionTabs() {
|
_fullRenderSessionTabs() {
|
||||||
if (this._activeRename) return;
|
if (this._inlineRenameActive) return;
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
|
|
||||||
// Clean up any orphaned dropdowns before re-rendering
|
// Clean up any orphaned dropdowns before re-rendering
|
||||||
@@ -2456,6 +2538,7 @@ class CodemanApp {
|
|||||||
const hasRunningTasks = taskStats.running > 0;
|
const hasRunningTasks = taskStats.running > 0;
|
||||||
const alertType = this.tabAlerts.get(id);
|
const alertType = this.tabAlerts.get(id);
|
||||||
const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : '';
|
const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : '';
|
||||||
|
const loadState = this.terminalLoadStates.get(id);
|
||||||
|
|
||||||
// Get minimized subagents for this session
|
// Get minimized subagents for this session
|
||||||
const minimizedAgents = this.minimizedSubagents.get(id);
|
const minimizedAgents = this.minimizedSubagents.get(id);
|
||||||
@@ -2467,8 +2550,9 @@ class CodemanApp {
|
|||||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||||
|
|
||||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.selectSession('${escapeHtml(id)}', { forceReload: true })" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
|
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
<span class="tab-info">
|
<span class="tab-info">
|
||||||
<span class="tab-name-row">
|
<span class="tab-name-row">
|
||||||
@@ -2516,7 +2600,7 @@ class CodemanApp {
|
|||||||
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
|
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sessionId = tabs[currentIndex].dataset.id;
|
const sessionId = tabs[currentIndex].dataset.id;
|
||||||
this.selectSession(sessionId);
|
this.selectSession(sessionId, { forceReload: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2761,6 +2845,7 @@ class CodemanApp {
|
|||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
this._isLoadingBuffer = false;
|
this._isLoadingBuffer = false;
|
||||||
this._loadBufferQueue = null;
|
this._loadBufferQueue = null;
|
||||||
|
this._bufferLoadOwner = null;
|
||||||
// Abort any in-flight chunkedTerminalWrite from the previous session.
|
// Abort any in-flight chunkedTerminalWrite from the previous session.
|
||||||
// Without this, old rAF-scheduled chunks continue writing stale data
|
// Without this, old rAF-scheduled chunks continue writing stale data
|
||||||
// into the terminal, interleaving with the new session's buffer.
|
// into the terminal, interleaving with the new session's buffer.
|
||||||
@@ -2811,15 +2896,31 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectSession(sessionId) {
|
_resetTerminalForReplay() {
|
||||||
|
this.terminal.reset();
|
||||||
|
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectSession(sessionId, options = {}) {
|
||||||
// If this session is popped out into its own window, raise that window
|
// If this session is popped out into its own window, raise that window
|
||||||
// instead of showing it inline (focus-on-click for detached tabs).
|
// instead of showing it inline (focus-on-click for detached tabs). If we
|
||||||
|
// owned a now-closed window, _raiseDetached re-docks and returns false so
|
||||||
|
// we fall through and load it inline.
|
||||||
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
|
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
|
||||||
// Raise the popup instead of showing inline. If we owned a now-closed
|
|
||||||
// window, _raiseDetached re-docks and returns false so we fall through.
|
|
||||||
if (this._raiseDetached(sessionId)) return;
|
if (this._raiseDetached(sessionId)) return;
|
||||||
}
|
}
|
||||||
if (this.activeSessionId === sessionId) return;
|
const forceReload = options?.forceReload === true;
|
||||||
|
if (this.activeSessionId === sessionId && !forceReload) return;
|
||||||
|
if (this.activeSessionId === sessionId && forceReload) {
|
||||||
|
this.terminalBufferCache?.delete(sessionId);
|
||||||
|
this._clearTimer('syncWaitTimeout');
|
||||||
|
this.pendingWrites = [];
|
||||||
|
this.writeFrameScheduled = false;
|
||||||
|
this._isLoadingBuffer = false;
|
||||||
|
this._loadBufferQueue = null;
|
||||||
|
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
|
||||||
|
this.activeSessionId = null;
|
||||||
|
}
|
||||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||||
// After the first await the gesture context is lost and focus() is silently
|
// After the first await the gesture context is lost and focus() is silently
|
||||||
@@ -2832,8 +2933,12 @@ class CodemanApp {
|
|||||||
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
|
||||||
|
|
||||||
const selectGen = ++this._selectGeneration;
|
const selectGen = ++this._selectGeneration;
|
||||||
|
this._setTerminalLoadState(sessionId, selectGen, 'resizing');
|
||||||
|
|
||||||
if (selectGen !== this._selectGeneration) return; // newer tab switch won
|
if (selectGen !== this._selectGeneration) {
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
|
return; // newer tab switch won
|
||||||
|
}
|
||||||
|
|
||||||
this._cleanupPreviousSession(sessionId);
|
this._cleanupPreviousSession(sessionId);
|
||||||
this.activeSessionId = sessionId;
|
this.activeSessionId = sessionId;
|
||||||
@@ -2905,39 +3010,71 @@ class CodemanApp {
|
|||||||
// Without this, SSE events arriving during the fetch() gap compete with
|
// Without this, SSE events arriving during the fetch() gap compete with
|
||||||
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
|
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
|
||||||
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
|
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
|
||||||
this._isLoadingBuffer = true;
|
const bufferLoadOwner = this._beginBufferLoad(selectGen);
|
||||||
this._loadBufferQueue = [];
|
|
||||||
try {
|
try {
|
||||||
// Fit terminal to container BEFORE writing any buffer data.
|
// Fit terminal to container BEFORE writing any buffer data.
|
||||||
// If the browser was resized while viewing another session, the terminal
|
// If the browser was resized while viewing another session, the terminal
|
||||||
// canvas may be at stale dimensions — content would render at wrong width.
|
// canvas may be at stale dimensions — content would render at wrong width.
|
||||||
if (this.fitAddon) this.fitAddon.fit();
|
if (this.fitAddon) this.fitAddon.fit();
|
||||||
|
|
||||||
|
// Also push the new dimensions to the PTY. Without this, codex/codeman
|
||||||
|
// sees the size that was set the last time the throttled resize handler
|
||||||
|
// fired (often the size of a different session's container, or the
|
||||||
|
// initial tmux default). The visible symptom is codex rendering inside
|
||||||
|
// a small region with empty rows below the status bar.
|
||||||
|
// sendResize is a no-op on the server when dims haven't changed, so
|
||||||
|
// calling it every tab switch is cheap.
|
||||||
|
const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false);
|
||||||
|
if (this._isStaleSelect(selectGen)) {
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||||
|
|
||||||
// Instant cache restore for IDLE sessions only.
|
// Instant cache restore for IDLE sessions only.
|
||||||
// For busy sessions, the cache is always stale — writing it first causes a
|
// For busy sessions, the cache is always stale — writing it first causes a
|
||||||
// jarring double-render: stale content appears, then the terminal flashes
|
// jarring double-render: stale content appears, then the terminal flashes
|
||||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||||
// buffer once for a single clean transition.
|
// buffer once for a single clean transition.
|
||||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
|
||||||
if (cachedBuffer && !sessionIsBusy) {
|
if (cachedBuffer && !sessionIsBusy) {
|
||||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||||
this.terminal.clear();
|
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||||
this.terminal.reset();
|
this._resetTerminalForReplay();
|
||||||
await this.chunkedTerminalWrite(cachedBuffer);
|
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||||
if (this._isStaleSelect(selectGen)) return;
|
if (this._isStaleSelect(selectGen)) {
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
_crashDiag.log('CACHE_DONE');
|
_crashDiag.log('CACHE_DONE');
|
||||||
} else if (sessionIsBusy) {
|
} else if (sessionIsBusy) {
|
||||||
// Clear stale content immediately — fresh buffer is being fetched
|
// Clear stale content immediately — fresh buffer is being fetched
|
||||||
this.terminal.clear();
|
this._resetTerminalForReplay();
|
||||||
this.terminal.reset();
|
|
||||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give TUI sessions a short chance to redraw after resize before the
|
||||||
|
// fresh buffer fetch. Only needed when the resize actually changed
|
||||||
|
// dimensions (a real SIGWINCH → Ink redraw); a same-size tab switch sent
|
||||||
|
// no resize, so waiting would just add latency. Shell sessions never need
|
||||||
|
// it, so terminal content can appear immediately when switching shells.
|
||||||
|
if (session?.mode !== 'shell' && dimsChanged) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, TUI_REDRAW_SETTLE_MS));
|
||||||
|
if (this._isStaleSelect(selectGen)) {
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||||
_crashDiag.log('FETCH_START');
|
_crashDiag.log('FETCH_START');
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||||
if (this._isStaleSelect(selectGen)) return;
|
if (this._isStaleSelect(selectGen)) {
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = (await res.json())?.data ?? {};
|
const data = (await res.json())?.data ?? {};
|
||||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||||
|
|
||||||
@@ -2945,18 +3082,23 @@ class CodemanApp {
|
|||||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||||
// very visible, causing the terminal to flash blank then repaint.
|
// very visible, causing the terminal to flash blank then repaint.
|
||||||
const needsRewrite = data.terminalBuffer !== cachedBuffer;
|
// Busy sessions skip cache restore and clear the terminal before fetching,
|
||||||
|
// so they must replay the fetched buffer even when it matches cache.
|
||||||
|
const needsRewrite = sessionIsBusy || data.terminalBuffer !== cachedBuffer;
|
||||||
if (needsRewrite) {
|
if (needsRewrite) {
|
||||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||||
this.terminal.clear();
|
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||||
this.terminal.reset();
|
this._resetTerminalForReplay();
|
||||||
// Show truncation indicator if buffer was cut
|
// Show truncation indicator if buffer was cut
|
||||||
if (data.truncated) {
|
if (data.truncated) {
|
||||||
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
|
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
|
||||||
}
|
}
|
||||||
// Use chunked write for large buffers to avoid UI jank
|
// Use chunked write for large buffers to avoid UI jank
|
||||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||||
if (this._isStaleSelect(selectGen)) return;
|
if (this._isStaleSelect(selectGen)) {
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Ensure terminal is scrolled to bottom after buffer load
|
// Ensure terminal is scrolled to bottom after buffer load
|
||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
}
|
}
|
||||||
@@ -2970,15 +3112,14 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
} else if (!cachedBuffer) {
|
} else if (!cachedBuffer) {
|
||||||
// No fresh buffer and no cache — clear any stale content
|
// No fresh buffer and no cache — clear any stale content
|
||||||
this.terminal.clear();
|
this._resetTerminalForReplay();
|
||||||
this.terminal.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buffer load complete — unblock live SSE writes (queued events are discarded
|
// Buffer load complete — unblock live SSE writes (queued events are discarded
|
||||||
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
|
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
|
||||||
// internally, but if we skipped the write (cache hit or empty), call it here.
|
// internally, but if we skipped the write (cache hit or empty), call it here.
|
||||||
if (this._isLoadingBuffer) {
|
if (this._isLoadingBuffer) {
|
||||||
this._finishBufferLoad();
|
this._finishBufferLoad(bufferLoadOwner);
|
||||||
}
|
}
|
||||||
// Drop the guard so user input clears state normally
|
// Drop the guard so user input clears state normally
|
||||||
this._restoringFlushedState = false;
|
this._restoringFlushedState = false;
|
||||||
@@ -3091,12 +3232,14 @@ class CodemanApp {
|
|||||||
|
|
||||||
_crashDiag.log('FOCUS');
|
_crashDiag.log('FOCUS');
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
this.terminal.scrollToBottom();
|
this.scrollToLastNonEmptyLine();
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this._isLoadingBuffer) this._finishBufferLoad();
|
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||||
this._restoringFlushedState = false;
|
this._restoringFlushedState = false;
|
||||||
|
this._setTerminalLoadState(sessionId, selectGen, 'failed');
|
||||||
console.error('Failed to load session terminal:', err);
|
console.error('Failed to load session terminal:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3126,6 +3269,7 @@ class CodemanApp {
|
|||||||
this.projectInsights.delete(sessionId);
|
this.projectInsights.delete(sessionId);
|
||||||
this.pendingHooks.delete(sessionId);
|
this.pendingHooks.delete(sessionId);
|
||||||
this.tabAlerts.delete(sessionId);
|
this.tabAlerts.delete(sessionId);
|
||||||
|
this.terminalLoadStates.delete(sessionId);
|
||||||
this.clearCountdownTimers(sessionId);
|
this.clearCountdownTimers(sessionId);
|
||||||
this.closeSessionLogViewerWindows(sessionId);
|
this.closeSessionLogViewerWindows(sessionId);
|
||||||
this.closeSessionImagePopups(sessionId);
|
this.closeSessionImagePopups(sessionId);
|
||||||
@@ -3286,6 +3430,7 @@ class CodemanApp {
|
|||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBufferCache.clear();
|
||||||
|
this.terminalLoadStates.clear();
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||||
this.respawnStatus = {};
|
this.respawnStatus = {};
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer l
|
|||||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||||
|
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
|
||||||
|
|
||||||
// Z-index base values for layered floating windows
|
// Z-index base values for layered floating windows
|
||||||
const ZINDEX_SUBAGENT_BASE = 1000;
|
const ZINDEX_SUBAGENT_BASE = 1000;
|
||||||
|
|||||||
@@ -927,9 +927,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
||||||
if (!tabName) return;
|
if (!tabName) return;
|
||||||
|
|
||||||
// If a previous rename somehow leaked (shouldn't happen, but defends against
|
// Prevent tab re-renders from destroying the input while renaming
|
||||||
// future code paths that throw before cleanup), abort it before starting fresh.
|
this._inlineRenameActive = true;
|
||||||
if (this._activeRename) this._activeRename.cancel();
|
|
||||||
|
|
||||||
const currentName = this.getSessionName(session);
|
const currentName = this.getSessionName(session);
|
||||||
const parsed = parseSessionPrefix(session.name);
|
const parsed = parseSessionPrefix(session.name);
|
||||||
@@ -957,14 +956,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
|
|
||||||
let settled = false;
|
|
||||||
const finishRename = async ({ commit }) => {
|
const finishRename = async ({ commit }) => {
|
||||||
if (settled) return;
|
if (!this._inlineRenameActive) return; // prevent double-fire
|
||||||
settled = true;
|
this._inlineRenameActive = false;
|
||||||
this._activeRename = null;
|
this._activeRename = null;
|
||||||
|
|
||||||
// Aborted (e.g. session was deleted mid-rename): just re-render so any
|
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
|
||||||
// ghost DOM left behind is replaced with the canonical tab list.
|
// so any ghost DOM is replaced with the canonical tab list, and skip the
|
||||||
|
// API call — a cancel must not fire a stale rename PUT.
|
||||||
if (!commit) {
|
if (!commit) {
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
position: relative;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -265,6 +266,40 @@ body {
|
|||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-tab.tab-loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
bottom: -2px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(96, 165, 250, 0.95), transparent);
|
||||||
|
animation: tab-load-sweep 0.9s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-load-spinner {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid rgba(96, 165, 250, 0.3);
|
||||||
|
border-top-color: rgba(96, 165, 250, 0.95);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 10px;
|
||||||
|
animation: tab-load-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tab-load-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tab-load-sweep {
|
||||||
|
0% { transform: translateX(-45%); opacity: 0.35; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { transform: translateX(45%); opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Tab switch feedback: bright green glow on the newly-active tab */
|
/* Tab switch feedback: bright green glow on the newly-active tab */
|
||||||
.session-tab.tab-glow {
|
.session-tab.tab-glow {
|
||||||
animation: tab-glow 0.35s ease-out;
|
animation: tab-glow 0.35s ease-out;
|
||||||
@@ -2070,6 +2105,8 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
will-change: contents;
|
will-change: contents;
|
||||||
}
|
}
|
||||||
.terminal-container .xterm {
|
.terminal-container .xterm {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-20
@@ -12,6 +12,24 @@
|
|||||||
* @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js
|
* @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
|
||||||
|
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
|
||||||
|
|
||||||
|
function isTerminalQueryResponse(data) {
|
||||||
|
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuppressTerminalQueryResponse(data) {
|
||||||
|
return isTerminalQueryResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.CodemanTerminalInput = {
|
||||||
|
isTerminalQueryResponse,
|
||||||
|
shouldSuppressTerminalQueryResponse,
|
||||||
|
};
|
||||||
|
})(window);
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Terminal Setup — xterm.js config and input handling
|
// Terminal Setup — xterm.js config and input handling
|
||||||
@@ -164,7 +182,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// whitespace) -- if so, xterm handled it and we should not double-send.
|
// whitespace) -- if so, xterm handled it and we should not double-send.
|
||||||
// Use a microtask to check after xterm's own handlers have run.
|
// Use a microtask to check after xterm's own handlers have run.
|
||||||
const data = e.data;
|
const data = e.data;
|
||||||
|
const pendingBefore = this._localEchoOverlay?.pendingText || '';
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
|
if (
|
||||||
|
this._lastTerminalData?.data === data &&
|
||||||
|
performance.now() - this._lastTerminalData.time < 100
|
||||||
|
) {
|
||||||
|
xtermTextarea.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pendingAfter = this._localEchoOverlay?.pendingText || '';
|
||||||
|
if (
|
||||||
|
this._localEchoEnabled &&
|
||||||
|
pendingAfter.length > pendingBefore.length &&
|
||||||
|
pendingAfter.endsWith(data)
|
||||||
|
) {
|
||||||
|
xtermTextarea.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
// If xterm cleared the textarea, it processed the input -- skip.
|
// If xterm cleared the textarea, it processed the input -- skip.
|
||||||
const val = xtermTextarea.value;
|
const val = xtermTextarea.value;
|
||||||
if (!val || (val.trim() === '' && data !== ' ')) return;
|
if (!val || (val.trim() === '' && data !== ' ')) return;
|
||||||
@@ -328,6 +363,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'touchmove',
|
'touchmove',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
if (ev.touches.length === 1 && isTouching) {
|
if (ev.touches.length === 1 && isTouching) {
|
||||||
|
ev.preventDefault();
|
||||||
didScroll = true;
|
didScroll = true;
|
||||||
const touchY = ev.touches[0].clientY;
|
const touchY = ev.touches[0].clientY;
|
||||||
const delta = touchLastY - touchY; // positive = scroll down
|
const delta = touchLastY - touchY; // positive = scroll down
|
||||||
@@ -343,7 +379,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ passive: true }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
@@ -382,6 +418,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
||||||
this._chunkedWriteGen = 0;
|
this._chunkedWriteGen = 0;
|
||||||
|
this._bufferLoadSeq = 0;
|
||||||
|
this._bufferLoadOwner = null;
|
||||||
|
|
||||||
// Handle resize with throttling for performance
|
// Handle resize with throttling for performance
|
||||||
this._resizeTimeout = null;
|
this._resizeTimeout = null;
|
||||||
@@ -498,11 +536,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// CJK input has focus — block xterm from sending to PTY
|
// CJK input has focus — block xterm from sending to PTY
|
||||||
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
|
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
|
||||||
if (this.activeSessionId) {
|
if (this.activeSessionId) {
|
||||||
// Filter out terminal query responses that xterm.js generates automatically.
|
// Filter terminal query replies generated by xterm.js itself.
|
||||||
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
|
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
|
||||||
// sent by tmux when attaching. Without this filter, they appear as typed text.
|
// into the foreground process as typed input (for example "0;276;0c").
|
||||||
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
|
if (
|
||||||
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
|
window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastTerminalData = { data, time: performance.now() };
|
||||||
|
|
||||||
// ── Local Echo Mode ──
|
// ── Local Echo Mode ──
|
||||||
// When enabled, keystrokes are buffered locally in the overlay for
|
// When enabled, keystrokes are buffered locally in the overlay for
|
||||||
@@ -1594,6 +1636,38 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
scrollToLastNonEmptyLine() {
|
||||||
|
if (!this.terminal?.buffer?.active) {
|
||||||
|
this.terminal?.scrollToBottom?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = this.terminal.buffer.active;
|
||||||
|
const totalLines = buffer.baseY + buffer.length;
|
||||||
|
let lastNonEmptyLine = -1;
|
||||||
|
|
||||||
|
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
|
||||||
|
const line = buffer.getLine(lineIndex);
|
||||||
|
if (line?.translateToString(true).trim()) {
|
||||||
|
lastNonEmptyLine = lineIndex;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastNonEmptyLine >= 0 && typeof this.terminal.scrollToLine === 'function') {
|
||||||
|
let targetLine = Math.max(0, lastNonEmptyLine - this.terminal.rows + 2);
|
||||||
|
const maxTargetLine = Math.max(0, lastNonEmptyLine);
|
||||||
|
while (targetLine < maxTargetLine) {
|
||||||
|
const line = buffer.getLine(targetLine);
|
||||||
|
if (line?.translateToString(true).trim()) break;
|
||||||
|
targetLine++;
|
||||||
|
}
|
||||||
|
this.terminal.scrollToLine(targetLine);
|
||||||
|
} else {
|
||||||
|
this.terminal.scrollToBottom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write large buffer to terminal in chunks to avoid UI jank.
|
* Write large buffer to terminal in chunks to avoid UI jank.
|
||||||
* Uses _safeYield to spread work across frames; falls back to setTimeout
|
* Uses _safeYield to spread work across frames; falls back to setTimeout
|
||||||
@@ -1602,22 +1676,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
||||||
* @returns {Promise<void>} - Resolves when all chunks written
|
* @returns {Promise<void>} - Resolves when all chunks written
|
||||||
*/
|
*/
|
||||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
|
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
|
||||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||||
// older writes abort instead of continuing to push stale data into the terminal.
|
// older writes abort instead of continuing to push stale data into the terminal.
|
||||||
const writeGen = ++this._chunkedWriteGen;
|
const writeGen = ++this._chunkedWriteGen;
|
||||||
|
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
this._finishBufferLoad();
|
this._finishBufferLoad(bufferLoadOwner);
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block live SSE writes during buffer load to prevent interleaving
|
|
||||||
this._isLoadingBuffer = true;
|
|
||||||
this._loadBufferQueue = [];
|
|
||||||
|
|
||||||
// Strip any DEC 2026 markers that might be in the buffer
|
// Strip any DEC 2026 markers that might be in the buffer
|
||||||
// (from historical SSE data that was stored with markers)
|
// (from historical SSE data that was stored with markers)
|
||||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||||
@@ -1625,15 +1696,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const finish = () => {
|
const finish = () => {
|
||||||
// Only finish if we're still the active write — a newer write owns buffer load state
|
// Only finish if we're still the active write — a newer write owns buffer load state
|
||||||
if (this._chunkedWriteGen === writeGen) {
|
if (this._chunkedWriteGen === writeGen) {
|
||||||
this._finishBufferLoad();
|
this._finishBufferLoad(bufferLoadOwner);
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
// For small buffers, write directly — single-frame render is fast enough
|
// For small buffers, write directly — single-frame render is fast enough
|
||||||
if (cleanBuffer.length <= chunkSize) {
|
if (cleanBuffer.length <= chunkSize) {
|
||||||
this.terminal.write(cleanBuffer);
|
this.terminal.write(cleanBuffer, finish);
|
||||||
finish();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1691,9 +1761,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* (especially Ink cursor-up redraws), corrupting the terminal display.
|
* (especially Ink cursor-up redraws), corrupting the terminal display.
|
||||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||||
*/
|
*/
|
||||||
_finishBufferLoad() {
|
_beginBufferLoad(owner) {
|
||||||
|
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||||
|
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
|
||||||
|
this._bufferLoadOwner = loadOwner;
|
||||||
|
this._isLoadingBuffer = true;
|
||||||
|
this._loadBufferQueue = [];
|
||||||
|
return loadOwner;
|
||||||
|
},
|
||||||
|
|
||||||
|
_finishBufferLoad(owner) {
|
||||||
|
if (owner !== undefined && this._bufferLoadOwner !== owner) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
this._isLoadingBuffer = false;
|
this._isLoadingBuffer = false;
|
||||||
this._loadBufferQueue = null;
|
this._loadBufferQueue = null;
|
||||||
|
this._bufferLoadOwner = null;
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -1814,23 +1898,30 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/**
|
/**
|
||||||
* Send resize to a session with minimum dimension enforcement.
|
* Send resize to a session with minimum dimension enforcement.
|
||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
|
* @param {{ forceHttp?: boolean }} [options]
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async sendResize(sessionId) {
|
async sendResize(sessionId, options = {}) {
|
||||||
// Fit terminal to container before reading dimensions — ensures local
|
// Fit terminal to container before reading dimensions — ensures local
|
||||||
// terminal size matches what we report to the server PTY.
|
// terminal size matches what we report to the server PTY.
|
||||||
if (this.fitAddon) this.fitAddon.fit();
|
if (this.fitAddon) this.fitAddon.fit();
|
||||||
const dims = this.getTerminalDimensions();
|
const dims = this.getTerminalDimensions();
|
||||||
if (!dims) return;
|
if (!dims) return false;
|
||||||
|
// Did the dimensions actually change since the last resize we sent? Callers
|
||||||
|
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
|
||||||
|
// real SIGWINCH was triggered — switching tabs at the same browser size is
|
||||||
|
// a no-op on the server and needs no redraw grace.
|
||||||
|
const prev = this._lastResizeDims;
|
||||||
|
const changed = !prev || prev.cols !== dims.cols || prev.rows !== dims.rows;
|
||||||
// Update _lastResizeDims so the throttledResize handler won't redundantly
|
// Update _lastResizeDims so the throttledResize handler won't redundantly
|
||||||
// clear the terminal for the same dimensions (which would blank the screen
|
// clear the terminal for the same dimensions (which would blank the screen
|
||||||
// without a subsequent Ink redraw to repaint it).
|
// without a subsequent Ink redraw to repaint it).
|
||||||
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||||||
// Fast path: WebSocket resize
|
// Fast path: WebSocket resize
|
||||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
|
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
|
||||||
return;
|
return changed;
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to HTTP POST
|
// Fall through to HTTP POST
|
||||||
}
|
}
|
||||||
@@ -1840,6 +1931,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(dims),
|
body: JSON.stringify(dims),
|
||||||
});
|
});
|
||||||
|
return changed;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -888,8 +888,9 @@ export function registerSessionRoutes(
|
|||||||
const query = req.query as { tail?: string };
|
const query = req.query as { tail?: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
|
const rawBuffer = session.terminalBuffer;
|
||||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||||
const fullSize = session.terminalBufferLength;
|
const fullSize = rawBuffer.length;
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
let cleanBuffer: string;
|
let cleanBuffer: string;
|
||||||
|
|
||||||
@@ -897,7 +898,7 @@ export function registerSessionRoutes(
|
|||||||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||||
// the terminal appears empty when switching tabs.
|
// the terminal appears empty when switching tabs.
|
||||||
const strippedBuffer = stripInkRedrawBloat(session.terminalBuffer);
|
const strippedBuffer = stripInkRedrawBloat(rawBuffer);
|
||||||
|
|
||||||
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||||||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function cssRuleBody(css: string, selector: string): string {
|
||||||
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`));
|
||||||
|
return match?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('terminal layout CSS', () => {
|
||||||
|
it('pins the xterm root to the terminal container width while scrollback moves', () => {
|
||||||
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||||
|
const xtermRule = cssRuleBody(css, '.terminal-container .xterm');
|
||||||
|
|
||||||
|
expect(xtermRule).toContain('width: 100%');
|
||||||
|
expect(xtermRule).toContain('min-width: 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('tmux styled pane capture', () => {
|
||||||
|
it('requests SGR style escapes for canonical browser replay frames', () => {
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||||
|
const captureMethod = source.indexOf('capturePaneBuffer(muxName: string');
|
||||||
|
const captureCommand = source.indexOf('capture-pane -p', captureMethod);
|
||||||
|
|
||||||
|
expect(captureMethod).toBeGreaterThan(-1);
|
||||||
|
expect(captureCommand).toBeGreaterThan(captureMethod);
|
||||||
|
expect(source.slice(captureCommand, captureCommand + 80)).toContain('capture-pane -p -e');
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
-2
@@ -8,8 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { TmuxManager, parsePaneList } from '../src/tmux-manager.js';
|
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync, exec } from 'node:child_process';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Unit Tests (mocked)
|
// Unit Tests (mocked)
|
||||||
@@ -64,6 +64,7 @@ vi.mock('node:fs/promises', async () => {
|
|||||||
describe('TmuxManager (unit)', () => {
|
describe('TmuxManager (unit)', () => {
|
||||||
let manager: TmuxManager;
|
let manager: TmuxManager;
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
|
const mockedExec = vi.mocked(exec);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -118,6 +119,109 @@ describe('TmuxManager (unit)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('window sizing', () => {
|
||||||
|
it('pins a tmux window to manual sizing before browser attach', () => {
|
||||||
|
expect(manager.setManualWindowSize('codeman-abc12345')).toBe(true);
|
||||||
|
|
||||||
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||||
|
"tmux -L 'codeman' set-window-option -t 'codeman-abc12345' window-size manual",
|
||||||
|
expect.objectContaining({ stdio: 'ignore' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resizes the tmux window when Codeman accepts a desktop resize', () => {
|
||||||
|
expect(manager.resizeWindow('codeman-abc12345', 140, 42)).toBe(true);
|
||||||
|
|
||||||
|
// Non-blocking exec (not execSync) on the interactive resize hot path.
|
||||||
|
expect(mockedExec).toHaveBeenCalledWith(
|
||||||
|
"tmux -L 'codeman' resize-window -t 'codeman-abc12345' -x 140 -y 42",
|
||||||
|
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('environment exports', () => {
|
||||||
|
it('keeps COLORTERM unset for OpenCode sessions', () => {
|
||||||
|
const exports = (
|
||||||
|
manager as unknown as {
|
||||||
|
buildEnvExports(sessionId: string, muxName: string, mode: string): string[];
|
||||||
|
}
|
||||||
|
).buildEnvExports('session-1', 'codeman-abc12345', 'opencode');
|
||||||
|
|
||||||
|
expect(exports).toContain('unset COLORTERM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPaneSnapshot', () => {
|
||||||
|
it('paints captured rows with absolute cursor positions to avoid newline autowrap scroll', () => {
|
||||||
|
const fullWidthLine = 'x'.repeat(10);
|
||||||
|
|
||||||
|
const snapshot = formatPaneSnapshot([fullWidthLine, 'next line'], {
|
||||||
|
cols: 10,
|
||||||
|
rows: 4,
|
||||||
|
cursorX: 2,
|
||||||
|
cursorY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`);
|
||||||
|
expect(snapshot).not.toContain('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves SGR color while stripping non-style pane controls', () => {
|
||||||
|
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
|
||||||
|
cols: 40,
|
||||||
|
rows: 2,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).toContain('\x1b[32mgreen\x1b[0m');
|
||||||
|
expect(snapshot).toContain('tail');
|
||||||
|
expect(snapshot).not.toContain('\x1b[2K');
|
||||||
|
expect(snapshot).not.toContain('\x1b[10;20H');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates styled rows by visible columns without cutting SGR escapes', () => {
|
||||||
|
const snapshot = formatPaneSnapshot(['\x1b[31mabcdef\x1b[0m'], {
|
||||||
|
cols: 4,
|
||||||
|
rows: 1,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let full-width glyphs cross the paint boundary', () => {
|
||||||
|
const snapshot = formatPaneSnapshot(['abc\u754cdef'], {
|
||||||
|
cols: 5,
|
||||||
|
rows: 1,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).toBe('\x1b[1;1Habc\x1b[1;1H');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps combining marks attached without consuming a terminal column', () => {
|
||||||
|
const snapshot = formatPaneSnapshot(['a\u0301bc'], {
|
||||||
|
cols: 4,
|
||||||
|
rows: 1,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).toBe('\x1b[1;1Ha\u0301bc\x1b[1;1H');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveActivePaneTarget', () => {
|
||||||
|
it('selects the active pane instead of assuming pane zero', () => {
|
||||||
|
expect(resolveActivePaneTarget('%1:0\n%18:1\n')).toBe('%18');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isAvailable', () => {
|
describe('isAvailable', () => {
|
||||||
it('should return true when tmux is found', () => {
|
it('should return true when tmux is found', () => {
|
||||||
mockedExecSync.mockImplementation((cmd: string) => {
|
mockedExecSync.mockImplementation((cmd: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user