{"version":3,"file":"scroll-buffer.d.ts","sourceRoot":"","sources":["../src/scroll-buffer.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE3C,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;CACf;AAED;;;;;;;GAOG;AACH,qBAAa,YAAY;IACxB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,WAAW,CAAU;IAC7B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAGlC,OAAO,CAAC,YAAY,CAAyB;IAC7C,OAAO,CAAC,WAAW,CAAK;IAExB,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,cAAc,CAAM;IAE5B,sFAAsF;IACtF,OAAO,CAAC,SAAS,CAAK;IAEtB,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,OAAO,CAAK;IAEpB,YAAY,IAAI,GAAE,mBAAwB,EAGzC;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAW5B;IAED;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAiBhD;IAED,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAahC;IAED,KAAK,IAAI,IAAI,CAMZ;IAED,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQpC;IAED,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CASnC;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAe5B;IAED,MAAM,IAAI,IAAI,CAEb;IAED,QAAQ,IAAI,IAAI,CAEf;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,YAAY,IAAI,IAAI,CAEnB;IAED,SAAS,IAAI,IAAI,CAIhB;IAED,UAAU,IAAI,IAAI,CAGjB;IAED,IAAI,QAAQ,IAAI,OAAO,CAGtB;IAED,IAAI,IAAI,IAAI,UAAU,CAErB;IAED,WAAW,IAAI,MAAM,CAEpB;IAED,8EAA8E;IAC9E,MAAM,IAAI,MAAM,EAAE,CAMjB;IAID,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,UAAU;IAqBlB,OAAO,CAAC,iBAAiB;CASzB","sourcesContent":["/**\n * In-app scroll buffer.\n *\n * Covers cavekit-fullscreen-viewport R5 (buffer semantics), R6 (scroll controls).\n * The buffer owns the session transcript; the TUI renders whatever slice the\n * buffer exposes for the current viewport.\n */\n\nimport { visibleWidth, wrapTextWithAnsi } from \"./utils.js\";\n\nexport type ScrollMode = \"tail\" | \"paused\";\n\nexport interface ScrollBufferOptions {\n\tmaxLines?: number;\n\twrap?: boolean;\n}\n\n/**\n * Session transcript with viewport and follow-tail semantics.\n *\n * The buffer stores raw \"logical\" lines as appended. For rendering, lines are\n * soft-wrapped to the current viewport width producing \"display\" lines. The\n * viewport is defined in display lines, so the scroll math operates on the\n * post-wrap stream and stays consistent after resize.\n */\nexport class ScrollBuffer {\n\tprivate logical: string[] = [];\n\tprivate wrapEnabled: boolean;\n\tprivate readonly maxLines: number;\n\n\t// Cache of wrapped lines — invalidated on width change or append.\n\tprivate wrappedCache: string[] | null = null;\n\tprivate cachedWidth = 0;\n\n\tprivate viewportWidth = 80;\n\tprivate viewportHeight = 24;\n\n\t/** Offset from the top of the wrapped stream (display-line index of viewport top). */\n\tprivate topOffset = 0;\n\n\tprivate _mode: ScrollMode = \"tail\";\n\tprivate _unseen = 0;\n\n\tconstructor(opts: ScrollBufferOptions = {}) {\n\t\tthis.maxLines = opts.maxLines ?? Number.POSITIVE_INFINITY;\n\t\tthis.wrapEnabled = opts.wrap !== false;\n\t}\n\n\tappend(lines: string[]): void {\n\t\tif (lines.length === 0) return;\n\t\tthis.logical.push(...lines);\n\t\tthis.trimToMax();\n\t\tthis.invalidateCache();\n\n\t\tif (this._mode === \"tail\") {\n\t\t\tthis.snapToTail();\n\t\t} else {\n\t\t\tthis._unseen += this.countDisplayLines(lines);\n\t\t}\n\t}\n\n\t/**\n\t * Replace the last `count` logical lines with `lines`. Used for streaming\n\t * updates to a single message that grows in-place. Tail/paused behavior\n\t * mirrors append().\n\t */\n\treplaceTail(count: number, lines: string[]): void {\n\t\tconst previousTotal = this.getWrapped().length;\n\t\tif (count > 0) {\n\t\t\tconst removeCount = Math.min(count, this.logical.length);\n\t\t\tthis.logical.splice(this.logical.length - removeCount, removeCount);\n\t\t}\n\t\tthis.logical.push(...lines);\n\t\tthis.trimToMax();\n\t\tthis.invalidateCache();\n\n\t\tif (this._mode === \"tail\") {\n\t\t\tthis.snapToTail();\n\t\t} else {\n\t\t\tthis._unseen += Math.max(0, this.getWrapped().length - previousTotal);\n\t\t\tthis.clampTopOffset();\n\t\t\tif (this.isAtTail) this.setMode(\"tail\");\n\t\t}\n\t}\n\n\treplaceAll(lines: string[]): void {\n\t\tconst previousTotal = this.getWrapped().length;\n\t\tthis.logical = [...lines];\n\t\tthis.trimToMax();\n\t\tthis.invalidateCache();\n\n\t\tif (this._mode === \"tail\") {\n\t\t\tthis.snapToTail();\n\t\t} else {\n\t\t\tthis._unseen += Math.max(0, this.getWrapped().length - previousTotal);\n\t\t\tthis.clampTopOffset();\n\t\t\tif (this.isAtTail) this.setMode(\"tail\");\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.logical = [];\n\t\tthis.invalidateCache();\n\t\tthis.topOffset = 0;\n\t\tthis._mode = \"tail\";\n\t\tthis._unseen = 0;\n\t}\n\n\tsetViewportHeight(rows: number): void {\n\t\tif (rows === this.viewportHeight) return;\n\t\tthis.viewportHeight = Math.max(1, rows);\n\t\tif (this._mode === \"tail\") this.snapToTail();\n\t\telse {\n\t\t\tthis.clampTopOffset();\n\t\t\tif (this.isAtTail) this.setMode(\"tail\");\n\t\t}\n\t}\n\n\tsetViewportWidth(cols: number): void {\n\t\tif (cols === this.viewportWidth) return;\n\t\tthis.viewportWidth = Math.max(1, cols);\n\t\tthis.invalidateCache();\n\t\tif (this._mode === \"tail\") this.snapToTail();\n\t\telse {\n\t\t\tthis.clampTopOffset();\n\t\t\tif (this.isAtTail) this.setMode(\"tail\");\n\t\t}\n\t}\n\n\tget totalLines(): number {\n\t\treturn this.getWrapped().length;\n\t}\n\n\tget viewHeight(): number {\n\t\treturn this.viewportHeight;\n\t}\n\n\tget viewWidth(): number {\n\t\treturn this.viewportWidth;\n\t}\n\n\tscrollBy(delta: number): void {\n\t\tif (delta === 0) return;\n\t\tconst next = this.topOffset + delta;\n\t\tconst maxTop = Math.max(0, this.getWrapped().length - this.viewportHeight);\n\t\tconst clamped = Math.max(0, Math.min(maxTop, next));\n\t\tif (clamped === this.topOffset) {\n\t\t\tif (clamped >= maxTop) this.setMode(\"tail\");\n\t\t\treturn;\n\t\t}\n\t\tthis.topOffset = clamped;\n\t\tif (this.topOffset >= maxTop) {\n\t\t\tthis.setMode(\"tail\");\n\t\t} else {\n\t\t\tthis.setMode(\"paused\");\n\t\t}\n\t}\n\n\tpageUp(): void {\n\t\tthis.scrollBy(-Math.max(1, this.viewportHeight - 1));\n\t}\n\n\tpageDown(): void {\n\t\tthis.scrollBy(Math.max(1, this.viewportHeight - 1));\n\t}\n\n\thalfPageUp(): void {\n\t\tthis.scrollBy(-Math.max(1, Math.floor(this.viewportHeight / 2)));\n\t}\n\n\thalfPageDown(): void {\n\t\tthis.scrollBy(Math.max(1, Math.floor(this.viewportHeight / 2)));\n\t}\n\n\tjumpToTop(): void {\n\t\tif (this.topOffset === 0) return;\n\t\tthis.topOffset = 0;\n\t\tthis.setMode(this.getWrapped().length <= this.viewportHeight ? \"tail\" : \"paused\");\n\t}\n\n\tjumpToTail(): void {\n\t\tthis.setMode(\"tail\");\n\t\tthis.snapToTail();\n\t}\n\n\tget isAtTail(): boolean {\n\t\tconst maxTop = Math.max(0, this.getWrapped().length - this.viewportHeight);\n\t\treturn this.topOffset >= maxTop;\n\t}\n\n\tget mode(): ScrollMode {\n\t\treturn this._mode;\n\t}\n\n\tunseenCount(): number {\n\t\treturn this._unseen;\n\t}\n\n\t/** Returns exactly viewportHeight display lines (pads with \"\" when short). */\n\trender(): string[] {\n\t\tconst wrapped = this.getWrapped();\n\t\tconst start = Math.max(0, Math.min(wrapped.length, this.topOffset));\n\t\tconst slice = wrapped.slice(start, start + this.viewportHeight);\n\t\twhile (slice.length < this.viewportHeight) slice.push(\"\");\n\t\treturn slice;\n\t}\n\n\t// ------------------- internals -------------------\n\n\tprivate setMode(next: ScrollMode): void {\n\t\tif (this._mode === next) return;\n\t\tthis._mode = next;\n\t\tif (next === \"tail\") {\n\t\t\tthis._unseen = 0;\n\t\t}\n\t}\n\n\tprivate snapToTail(): void {\n\t\tconst total = this.getWrapped().length;\n\t\tthis.topOffset = Math.max(0, total - this.viewportHeight);\n\t\tthis._unseen = 0;\n\t}\n\n\tprivate clampTopOffset(): void {\n\t\tconst maxTop = Math.max(0, this.getWrapped().length - this.viewportHeight);\n\t\tif (this.topOffset > maxTop) this.topOffset = maxTop;\n\t\tif (this.topOffset < 0) this.topOffset = 0;\n\t}\n\n\tprivate trimToMax(): void {\n\t\tif (!Number.isFinite(this.maxLines)) return;\n\t\tconst over = this.logical.length - this.maxLines;\n\t\tif (over > 0) this.logical.splice(0, over);\n\t}\n\n\tprivate invalidateCache(): void {\n\t\tthis.wrappedCache = null;\n\t}\n\n\tprivate getWrapped(): string[] {\n\t\tif (this.wrappedCache && this.cachedWidth === this.viewportWidth) {\n\t\t\treturn this.wrappedCache;\n\t\t}\n\t\tif (!this.wrapEnabled) {\n\t\t\tthis.wrappedCache = [...this.logical];\n\t\t} else {\n\t\t\tconst out: string[] = [];\n\t\t\tfor (const line of this.logical) {\n\t\t\t\tif (visibleWidth(line) <= this.viewportWidth) {\n\t\t\t\t\tout.push(line);\n\t\t\t\t} else {\n\t\t\t\t\tout.push(...wrapTextWithAnsi(line, this.viewportWidth));\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis.wrappedCache = out;\n\t\t}\n\t\tthis.cachedWidth = this.viewportWidth;\n\t\treturn this.wrappedCache;\n\t}\n\n\tprivate countDisplayLines(lines: string[]): number {\n\t\tif (!this.wrapEnabled) return lines.length;\n\t\tlet n = 0;\n\t\tfor (const line of lines) {\n\t\t\tif (visibleWidth(line) <= this.viewportWidth) n += 1;\n\t\t\telse n += wrapTextWithAnsi(line, this.viewportWidth).length;\n\t\t}\n\t\treturn n;\n\t}\n}\n"]}