diff --git a/src/output-accumulator.ts b/src/output-accumulator.ts index 18f49fa..453b737 100644 --- a/src/output-accumulator.ts +++ b/src/output-accumulator.ts @@ -20,7 +20,7 @@ export class OutputAccumulator { private decoder = new StringDecoder('utf8'); private ansi: AnsiCarry = { state: 'text' }; byteLength = 0; - /** Increments on every newline so a later pager marker is a new line. */ + /** Increments on every newline or CR repaint so a later pager marker is a new line. */ lineEpoch = 0; constructor(private readonly maxPromptLength = 256) {} @@ -124,6 +124,7 @@ export class OutputAccumulator { this.parts.push('\r'.repeat(this.pendingCr)); this.col = 0; this.pendingCr = 0; + this.lineEpoch += 1; } run += ch; diff --git a/src/pager.ts b/src/pager.ts index 4734f58..014d69f 100644 --- a/src/pager.ts +++ b/src/pager.ts @@ -1,6 +1,9 @@ import { SshClientError } from './errors.js'; -const MORE_RE = /---\s*MORE\s*---\s*$/i; +// DrayOS appends a key hint: `--- MORE --- ['q': Quit, ...] ---`. +const MARKER = String.raw`---\s*MORE\s*---(?:[ \t]*\[[^\]\n]*\](?:[ \t]*---)?)?`; +const MORE_RE = new RegExp(`${MARKER}\\s*$`, 'i'); +const MARKER_RE = new RegExp(MARKER, 'giu'); export interface PagerState { pages: number; @@ -31,7 +34,8 @@ export function handlePagerTail( state: PagerState, maxPages: number, ): boolean { - if (!markerOnTail(line) || state.answeredTail === line) { + // A tail that extends the answered one is the same marker still arriving. + if (!markerOnTail(line) || (state.answeredTail !== null && line.startsWith(state.answeredTail))) { return false; } @@ -64,7 +68,7 @@ export function removeHandledMarkers(text: string, handled: number): string { let left = handled; - return text.replace(/---\s*MORE\s*---/giu, (marker) => { + return text.replace(MARKER_RE, (marker) => { if (left <= 0) { return marker; } diff --git a/tests/pager.test.ts b/tests/pager.test.ts index f658ca6..710c87f 100644 --- a/tests/pager.test.ts +++ b/tests/pager.test.ts @@ -77,4 +77,30 @@ describe('handlePagerTail', () => { expect(markerOnTail('--- MORE ---\nb')).toBe(false); expect(markerOnTail('tail --- MORE ---')).toBe(true); }); + + it('recognizes the DrayOS marker with its key hint as the tail', () => { + const hint = "['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page]"; + + expect(markerOnTail(`--- MORE --- ${hint} --- `)).toBe(true); + expect(markerOnTail(`--- MORE --- ${hint} `)).toBe(true); + expect(markerOnTail(`--- MORE --- ${hint} ---\nb`)).toBe(false); + expect(markerOnTail('--- MORE --- trailing output')).toBe(false); + }); + + it('answers a marker once while its key hint is still arriving', () => { + const writes: string[] = []; + const state = createPagerState(); + const write = { write: (data: string) => writes.push(data) }; + + expect(handlePagerTail('--- MORE ---', write, state, 5)).toBe(true); + expect(handlePagerTail("--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] ---", write, state, 5)).toBe(false); + expect(writes).toEqual([' ']); + expect(state.handled).toBe(1); + }); + + it('removes the DrayOS key hint together with a handled marker', () => { + const marker = "--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] ---"; + + expect(removeHandledMarkers(`a\n${marker}\nb`, 1)).toBe('a\n\nb'); + }); }); diff --git a/tests/shell-io-extras.test.ts b/tests/shell-io-extras.test.ts index 23ca495..13cab77 100644 --- a/tests/shell-io-extras.test.ts +++ b/tests/shell-io-extras.test.ts @@ -151,6 +151,71 @@ describe('ShellIo extras', () => { io.detach(); }); + it('pages through the DrayOS marker and returns every page without the key hint', async () => { + const channel = new FakeSsh2Channel({ prompt }); + const marker = "--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] --- "; + const first = `first ${randomUUID()}`; + const second = `second ${randomUUID()}`; + const original = channel.write.bind(channel); + channel.write = (data) => { + if (String(data) === ' ') { + channel.writes.push(data); + channel.emitText(`\r\n${second}\r\n${prompt}`); + return true; + } + + return original(data); + }; + const io = new ShellIo(channel, { promptIdentity: prompt, settleMs: 0, idleBufferMaxBytes: 64_000 }); + const command = `wan status ${randomUUID()}`; + const run = io.runCommand(command, { + commandTimeoutMs: 500, + maxPages: 5, + maxOutputBytes: 1_000_000, + settleMs: 0, + }, { idleTimeoutMs: 200 }); + channel.emitText(`${command}\r\n${first}\r\n${marker}`); + + const output = await run; + expect(channel.writes.filter((data) => String(data) === ' ')).toHaveLength(1); + expect(output).toContain(first); + expect(output).toContain(second); + expect(output).not.toContain('MORE'); + expect(output).not.toContain('Space Bar'); + io.detach(); + }); + + it('answers a pager marker repainted after a carriage return', async () => { + const channel = new FakeSsh2Channel({ prompt }); + const marker = "--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] --- "; + const last = `last ${randomUUID()}`; + const original = channel.write.bind(channel); + let spaces = 0; + channel.write = (data) => { + if (String(data) === ' ') { + channel.writes.push(data); + spaces += 1; + channel.emitText(spaces === 1 ? `\r${marker}` : `\r\n${last}\r\n${prompt}`); + return true; + } + + return original(data); + }; + const io = new ShellIo(channel, { promptIdentity: prompt, settleMs: 0, idleBufferMaxBytes: 64_000 }); + const command = `wan status ${randomUUID()}`; + const run = io.runCommand(command, { + commandTimeoutMs: 500, + maxPages: 5, + maxOutputBytes: 1_000_000, + settleMs: 0, + }, { idleTimeoutMs: 200 }); + channel.emitText(`${command}\r\nfirst\r\n--- MORE ---`); + + await expect(run).resolves.toContain(last); + expect(spaces).toBe(2); + io.detach(); + }); + it('rejects when the channel closes mid-command with a string reason', async () => { const channel = new FakeSsh2Channel({ prompt }); const io = new ShellIo(channel, { promptIdentity: prompt, settleMs: 0, idleBufferMaxBytes: 64_000 });