mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(terminal): snapshot-replay on tab switches (xterm serialize + live pane capture)
Switching away from a session and back replayed only the server's byte history. For TUI modes (codex especially) that shows just the latest repaint — the idle banner — because the TUI drops earlier conversation from its current frame. This restores the actual on-screen view. Two complementary mechanisms: - Client: load xterm's SerializeAddon and snapshot the rendered state (viewport + scrollback + colors) per session on switch-away, restoring it for an instant first paint on switch-back. The snapshot is only the first paint — the canonical /terminal frame is still fetched and reconciled (restoredSnapshot/clearedForBusy force the replay). Snapshots are LRU-bounded in memory (<=20) and persisted to localStorage (<=256KB each, <=10 sessions, stale-pruned) so they survive tab discard. - Server: GET /api/sessions/:id/terminal prepends the live tmux pane buffer (via the existing captureActivePaneBuffer) ahead of the byte history, cleared between, so replay reflects the current frame. Also fix formatPaneSnapshot dropping the rightmost column of every captured row: it painted to cols - 1 out of caution about last-column autowrap, but every row is followed by an absolute cursor-position CSI that cancels xterm's pending-wrap, so painting the full width is safe. The SerializeAddon is built from @xterm/addon-serialize (new dependency) into the vendor bundle by postinstall.js (dev) and build.mjs (prod), matching how the other xterm addons are vendored.
This commit is contained in:
+24
-10
@@ -164,10 +164,24 @@ describe('TmuxManager (unit)', () => {
|
||||
cursorY: 1,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`);
|
||||
// Full pane width is painted (10 cols); autowrap is avoided by the
|
||||
// absolute cursor positioning, not by dropping the last column.
|
||||
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(10)}\x1b[2;1Hnext line\x1b[2;3H`);
|
||||
expect(snapshot).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('preserves the rightmost column of each captured row', () => {
|
||||
const snapshot = formatPaneSnapshot(['abcd'], {
|
||||
cols: 4,
|
||||
rows: 1,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
// Previously truncated to cols - 1 ('abc'); the full width is now kept.
|
||||
expect(snapshot).toBe('\x1b[1;1Habcd\x1b[1;1H');
|
||||
});
|
||||
|
||||
it('preserves SGR color while stripping non-style pane controls', () => {
|
||||
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
|
||||
cols: 40,
|
||||
@@ -190,18 +204,18 @@ describe('TmuxManager (unit)', () => {
|
||||
cursorY: 0,
|
||||
});
|
||||
|
||||
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H');
|
||||
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabcd\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');
|
||||
// cols 5 = 'abc' (3) + full-width \u754c (2) fits exactly; with cols 4 the
|
||||
// wide glyph would straddle the boundary and is dropped.
|
||||
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 5, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
|
||||
'\x1b[1;1Habc\u754c\x1b[1;1H'
|
||||
);
|
||||
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 4, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
|
||||
'\x1b[1;1Habc\x1b[1;1H'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps combining marks attached without consuming a terminal column', () => {
|
||||
|
||||
Reference in New Issue
Block a user