diff --git a/docs/api-reference.md b/docs/api-reference.md index 89a873b7..cf9378c1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -445,6 +445,20 @@ count against the same 16, not 16 of each. An abandoned request no longer holds slot, because the routes release the waiter when the client disconnects, but a client that opens many concurrent waits against one session will still hit the cap. +## Terminal capture (`GET /api/v1/sessions/:id/terminal`) + +What a session's terminal shows, for a client to replay: `data.terminalBuffer`, +with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`, +`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's +geometry was read. The capture runs synchronous tmux calls on the server; the +`Server-Timing` header reports `capture`, `prepare` and `total`. + +| Query | Meaning | +|---|---| +| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. | +| `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). | +| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | + ## Session lineage (`parentSessionId`) A create request may name the session that spawned it, which the web UI draws as a diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index ff4e07c8..11b449be 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -638,7 +638,7 @@ } const shell = this.sessionMode === 'shell'; let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1'; - if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}`; + if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`; // A deadline covering the body as well as the headers (the primary // pane's budgets, CodemanFetchDeadline): a capture that never answers // would otherwise hold this pane's single-flight flag, and in the grid @@ -807,9 +807,10 @@ }; try { armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS); - const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, { - signal: controller?.signal, - }); + const res = await fetch( + `/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`, + { signal: controller?.signal } + ); armDeadline(HISTORY_PULL_TIMEOUT_MS); // The cutoff below is the response's arrival, the same `since` rule the // primary pane uses (_finishBufferLoad). It is a client clock standing in @@ -893,6 +894,16 @@ } } + // A bounded load's `lines=` (grid tiles): tmux history beyond what this + // xterm keeps (its scrollback plus the screen) would only be captured to be + // thrown away, and a full capture is synchronous work on the server, about + // 0.7 s for a 30k-line history. Unbounded panes (the split's Pane B) ask + // for everything, as before. + _historyLinesQuery() { + if (!this.boundedLoad || !Number.isFinite(this.scrollback)) return ''; + return `&lines=${this.scrollback + (this.terminal?.rows || 0)}`; + } + // The `{t:'r'}` server-refresh path: clear, then replay. Two refresh // frames in a row used to start two concurrent replays, each clearing // the terminal under the other's chunked write. A refresh that arrives diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index a21b19f4..1292d00e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -775,6 +775,20 @@ export function resolveOmpConfigForCreate( return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig; } +/** + * The tmux history lines a full capture may read (`capture-pane -S -`): the + * optional `lines` query parameter, an integer of at least 1, never more than + * the configured history limit; absent or malformed, the limit itself, as + * before. A grid tile sends its own scrollback size: its xterm keeps no more + * than that, while a capture of the whole history (tens of thousands of lines, + * cut to `tail` only afterwards) is synchronous work on this event loop. + */ +function captureHistoryLines(raw: string | undefined, historyLimit: number): number { + if (typeof raw !== 'string' || !/^\d{1,9}$/.test(raw)) return historyLimit; + const lines = Number(raw); + return lines >= 1 ? Math.min(lines, historyLimit) : historyLimit; +} + /** * `RemoteHost` → the wake registry's host shape. They differ in one field name only * (`id` in host config vs `hostId` on a session's `remote`), but the rename is load- @@ -2986,7 +3000,7 @@ export function registerSessionRoutes( app.get('/api/sessions/:id/terminal', async (req, reply) => { const routeStartedAt = performance.now(); const { id } = req.params as { id: string }; - const query = req.query as { tail?: string; full?: string }; + const query = req.query as { tail?: string; full?: string; lines?: string }; const session = findSessionOrFail(ctx, id, req); // `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE @@ -3010,8 +3024,14 @@ export function registerSessionRoutes( // single reason: `capturedGeometry` comes BACK on it, and the response has // to tell the client what size the frame it is about to render was built // for. See PaneCaptureOptions.capturedGeometry. + // `lines` bounds only the history a FULL capture reads; the visible-frame + // path reads no history and is untouched by it. const captureOpts: PaneCaptureOptions = isFullReload - ? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes } + ? { + fullHistory: true, + historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit), + maxCaptureBytes: terminalBufferMaxBytes, + } : {}; const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 11b1e755..8db8703b 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1037,6 +1037,62 @@ describe('session-routes', () => { expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true); }); + it('`lines=` bounds the history a full capture reads, clamped to the configured limit', async () => { + // Grid tiles send it (TerminalTile._historyLinesQuery): without it a full + // capture reads the whole history limit synchronously, and `tail` cuts it + // only afterwards. Absent or malformed, the limit itself, as before. + const limit = (await harness.ctx.getTerminalHistoryConfig()).tmuxHistoryLimit; + harness.ctx._session.mode = 'claude'; + const captureSpy = vi.fn((_name: string, _opts?: { historyLimitLines?: number }) => 'captured frame'); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy; + const linesFor = async (query: string) => { + captureSpy.mockClear(); + await harness.app.inject({ method: 'GET', url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}` }); + return captureSpy.mock.calls[0]?.[1]?.historyLimitLines; + }; + + expect(await linesFor(`full=1&tail=${1024 * 1024}&lines=10040`)).toBe(10040); + expect(await linesFor('full=1&lines=1')).toBe(1); + expect(await linesFor('full=1')).toBe(limit); + expect(await linesFor(`full=1&lines=${limit + 5}`)).toBe(limit); + for (const bad of ['0', '-5', 'abc', '1.5', '', '1e4', '9999999999']) { + expect(await linesFor(`full=1&lines=${bad}`), `lines=${bad}`).toBe(limit); + } + }); + + it('`lines=` leaves a visible-frame capture (no full=1) exactly as it was', async () => { + harness.ctx._session.mode = 'shell'; + const captureSpy = vi.fn(() => 'only the visible frame'); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy; + await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=${1024 * 1024}&lines=500`, + }); + expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {}); + }); + + it('a capture bounded by `lines=` is still a full capture: rows kept, cursor restore last', async () => { + // Only how much history tmux reads changes. The row-preserving skips key + // on isFullCapture, and the relative cursor move must still end it. + const cursorRestore = '\x1b[3A\r\x1b[2C'; + const capture = `\r\n${['first row', 'second row', 'last row'].join('\r\n')}${cursorRestore}`; + harness.ctx._session.mode = 'claude'; + harness.ctx._session.terminalBuffer = 'byte history that must not be prepended'; + const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) => + opts?.fullHistory ? capture : 'only the visible frame' + ); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy; + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${1024 * 1024}&lines=40`, + }); + const body = JSON.parse(res.body); + expect(body.data.source).toBe('mux-full-history'); + expect(body.data.terminalBuffer.startsWith('\r\nfirst row')).toBe(true); + expect(body.data.terminalBuffer.endsWith(`last row${cursorRestore}`)).toBe(true); + expect(body.data.terminalBuffer).not.toContain('byte history'); + }); + it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => { // The full-history capture is the rendered form of everything already in // the byte buffer; prepending the byte history would replay the whole diff --git a/test/tile-grid-load-queue.test.ts b/test/tile-grid-load-queue.test.ts index 59b564e0..59cea824 100644 --- a/test/tile-grid-load-queue.test.ts +++ b/test/tile-grid-load-queue.test.ts @@ -165,6 +165,9 @@ vm.runInContext( const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp; const TileGrid = windowStub.CodemanTileGrid as { TILE_SCROLLBACK: number }; const TAIL = 1024 * 1024; +// A grid tile's full captures read no more tmux history than its xterm keeps: +// TILE_SCROLLBACK plus the screen (the fake terminal has 24 rows). +const LINES = `&lines=${TileGrid.TILE_SCROLLBACK + 24}`; type Tile = { sessionId: string; @@ -292,7 +295,7 @@ describe('initial loads', () => { await drain(); await Promise.all(connecting); expect(captures.map((c) => c.url)).toEqual([ - `/api/sessions/tui/terminal?full=1&tail=${TAIL}`, + `/api/sessions/tui/terminal?full=1&tail=${TAIL}${LINES}`, `/api/sessions/sh/terminal?tail=${TAIL}`, ]); }); @@ -361,9 +364,9 @@ describe('refreshes', () => { expect(inFlight()).toBe(1); await drain(); expect(captures.map((c) => c.url)).toEqual([ - `/api/sessions/a/terminal?full=1&tail=${TAIL}`, - `/api/sessions/sh/terminal?full=1&tail=${TAIL}`, - `/api/sessions/b/terminal?full=1&tail=${TAIL}`, + `/api/sessions/a/terminal?full=1&tail=${TAIL}${LINES}`, + `/api/sessions/sh/terminal?full=1&tail=${TAIL}${LINES}`, + `/api/sessions/b/terminal?full=1&tail=${TAIL}${LINES}`, ]); });