| ... | ... | @@ -75,6 +75,8 @@ export { |
| 75 | 75 | }; |
| 76 | 76 | |
| 77 | 77 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| 78 | const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024; |
| 79 | const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; |
| 78 | 80 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; |
| 79 | 81 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; |
| 80 | 82 | const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; |
| ... | ... | @@ -784,74 +786,186 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps |
| 784 | 786 | }, |
| 785 | 787 | ); |
| 786 | 788 | |
| 787 | | function capHistory(history: string, maxLines: number): string { |
| 788 | | if (history.length === 0) return history; |
| 789 | | const hasTrailingNewline = history.endsWith("\n"); |
| 790 | | const lines = history.split("\n"); |
| 791 | | if (hasTrailingNewline) { |
| 792 | | lines.pop(); |
| 793 | | } |
| 794 | | if (lines.length <= maxLines) return history; |
| 795 | | const capped = lines.slice(lines.length - maxLines).join("\n"); |
| 796 | | return hasTrailingNewline ? `${capped}\n` : capped; |
| 789 | interface TerminalHistoryChunk { |
| 790 | data: string; |
| 791 | byteLength: number; |
| 792 | lineBreaks: number; |
| 797 | 793 | } |
| 798 | 794 | |
| 799 | 795 | export class BoundedTerminalHistory { |
| 800 | 796 | private readonly maxLines: number; |
| 801 | | private lines: Array<string>; |
| 797 | private readonly maxBytes: number; |
| 798 | private chunks: Array<TerminalHistoryChunk | undefined> = []; |
| 802 | 799 | private start = 0; |
| 803 | | private trailingNewline: boolean; |
| 804 | | private cachedValue: string | null = null; |
| 800 | private byteLength = 0; |
| 801 | private lineBreaks = 0; |
| 802 | // Reading the old string's tail on each append can force chunk concatenation. |
| 803 | private lastCodeUnit: number | undefined; |
| 804 | private cachedValue: string | null = ""; |
| 805 | 805 | |
| 806 | | constructor(maxLines: number, initial: string) { |
| 806 | constructor(maxLines: number, initial: string, maxBytes = DEFAULT_HISTORY_BYTE_LIMIT) { |
| 807 | 807 | this.maxLines = maxLines; |
| 808 | | const capped = capHistory(initial, maxLines); |
| 809 | | this.trailingNewline = capped.endsWith("\n"); |
| 810 | | this.lines = capped.length === 0 ? [] : capped.split("\n"); |
| 811 | | if (this.trailingNewline) this.lines.pop(); |
| 808 | this.maxBytes = maxBytes; |
| 809 | this.append(initial); |
| 812 | 810 | } |
| 813 | 811 | |
| 814 | 812 | append(text: string): void { |
| 815 | 813 | if (text.length === 0) return; |
| 814 | this.cachedValue = null; |
| 815 | if (this.maxBytes <= 0 || this.maxLines <= 0) { |
| 816 | this.clear(); |
| 817 | // Preserve the existing zero-line limit's trailing newline behavior. |
| 818 | if (this.maxBytes > 0 && text.endsWith("\n")) this.appendChunk("\n"); |
| 819 | return; |
| 820 | } |
| 821 | |
| 822 | let offset = 0; |
| 823 | const previous = this.chunks.at(-1); |
| 824 | const lastCode = this.lastCodeUnit; |
| 825 | const firstCode = text.charCodeAt(0); |
| 826 | if ( |
| 827 | previous && |
| 828 | lastCode !== undefined && |
| 829 | lastCode >= 0xd800 && |
| 830 | lastCode <= 0xdbff && |
| 831 | firstCode >= 0xdc00 && |
| 832 | firstCode <= 0xdfff |
| 833 | ) { |
| 834 | // Joining a split surrogate changes its UTF-8 size from 3 to 4 bytes. |
| 835 | previous.data += text[0]; |
| 836 | previous.byteLength += 1; |
| 837 | this.byteLength += 1; |
| 838 | this.lastCodeUnit = firstCode; |
| 839 | offset = 1; |
| 840 | this.trim(); |
| 841 | } |
| 816 | 842 | |
| 817 | | const trailingNewline = text.endsWith("\n"); |
| 818 | | const appendedLines = text.split("\n"); |
| 819 | | if (trailingNewline) appendedLines.pop(); |
| 843 | while (offset < text.length) { |
| 844 | let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length); |
| 845 | const before = text.charCodeAt(end - 1); |
| 846 | const after = text.charCodeAt(end); |
| 847 | if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) { |
| 848 | end -= 1; |
| 849 | } |
| 850 | const data = text.slice(offset, end); |
| 851 | // Detach small chunks from large input strings so evicted prefixes can be collected. |
| 852 | this.appendChunk( |
| 853 | text.length > MAX_HISTORY_CHUNK_LENGTH |
| 854 | ? Buffer.from(data, "utf16le").toString("utf16le") |
| 855 | : data, |
| 856 | ); |
| 857 | this.trim(); |
| 858 | offset = end; |
| 859 | } |
| 860 | } |
| 820 | 861 | |
| 821 | | const activeLines = this.lines.length - this.start; |
| 822 | | if (activeLines === 0 || this.trailingNewline) { |
| 823 | | for (const line of appendedLines) this.lines.push(line); |
| 862 | private appendChunk(data: string): void { |
| 863 | const byteLength = Buffer.byteLength(data); |
| 864 | let lineBreaks = 0; |
| 865 | for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) { |
| 866 | lineBreaks += 1; |
| 867 | } |
| 868 | const previous = this.chunks.at(-1); |
| 869 | if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) { |
| 870 | previous.data += data; |
| 871 | previous.byteLength += byteLength; |
| 872 | previous.lineBreaks += lineBreaks; |
| 824 | 873 | } else { |
| 825 | | this.lines[this.lines.length - 1] += appendedLines[0] ?? ""; |
| 826 | | for (let index = 1; index < appendedLines.length; index += 1) { |
| 827 | | this.lines.push(appendedLines[index] ?? ""); |
| 874 | this.chunks.push({ data, byteLength, lineBreaks }); |
| 875 | } |
| 876 | this.byteLength += byteLength; |
| 877 | this.lineBreaks += lineBreaks; |
| 878 | this.lastCodeUnit = data.charCodeAt(data.length - 1); |
| 879 | this.cachedValue = null; |
| 880 | } |
| 881 | |
| 882 | private discardChunk(): void { |
| 883 | const first = this.chunks[this.start]!; |
| 884 | this.byteLength -= first.byteLength; |
| 885 | this.lineBreaks -= first.lineBreaks; |
| 886 | this.chunks[this.start++] = undefined; |
| 887 | } |
| 888 | |
| 889 | private trimChunk(offset: number, byteLength: number, lineBreaks: number): void { |
| 890 | const first = this.chunks[this.start]!; |
| 891 | if (offset === first.data.length) { |
| 892 | this.discardChunk(); |
| 893 | return; |
| 894 | } |
| 895 | first.data = first.data.slice(offset); |
| 896 | first.byteLength -= byteLength; |
| 897 | first.lineBreaks -= lineBreaks; |
| 898 | this.byteLength -= byteLength; |
| 899 | this.lineBreaks -= lineBreaks; |
| 900 | } |
| 901 | |
| 902 | private trim(): void { |
| 903 | const trailingNewline = this.lastCodeUnit === 10; |
| 904 | let linesToDrop = this.lineBreaks + (trailingNewline ? 0 : 1) - this.maxLines; |
| 905 | while (linesToDrop > 0) { |
| 906 | const first = this.chunks[this.start]!; |
| 907 | if (first.lineBreaks < linesToDrop) { |
| 908 | linesToDrop -= first.lineBreaks; |
| 909 | this.discardChunk(); |
| 910 | continue; |
| 911 | } |
| 912 | let offset = 0; |
| 913 | for (let line = 0; line < linesToDrop; line += 1) { |
| 914 | offset = first.data.indexOf("\n", offset) + 1; |
| 828 | 915 | } |
| 916 | this.trimChunk(offset, Buffer.byteLength(first.data.slice(0, offset)), linesToDrop); |
| 917 | linesToDrop = 0; |
| 829 | 918 | } |
| 830 | | this.trailingNewline = trailingNewline; |
| 831 | 919 | |
| 832 | | const overflow = this.lines.length - this.start - this.maxLines; |
| 833 | | if (overflow > 0) { |
| 834 | | this.lines.fill("", this.start, this.start + overflow); |
| 835 | | this.start += overflow; |
| 920 | while (this.byteLength > this.maxBytes) { |
| 921 | const first = this.chunks[this.start]!; |
| 922 | const bytesToDrop = this.byteLength - this.maxBytes; |
| 923 | if (first.byteLength <= bytesToDrop) { |
| 924 | this.discardChunk(); |
| 925 | continue; |
| 926 | } |
| 927 | if (first.byteLength === first.data.length && first.lineBreaks === 0) { |
| 928 | // ASCII without newlines needs no scan to find the byte cutoff. |
| 929 | this.trimChunk(bytesToDrop, bytesToDrop, 0); |
| 930 | continue; |
| 931 | } |
| 932 | let offset = 0; |
| 933 | let bytes = 0; |
| 934 | let lineBreaks = 0; |
| 935 | // Scan only the discarded prefix of one small chunk, never all history. |
| 936 | while (bytes < bytesToDrop) { |
| 937 | const codePoint = first.data.codePointAt(offset)!; |
| 938 | bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; |
| 939 | offset += codePoint <= 0xffff ? 1 : 2; |
| 940 | if (codePoint === 10) lineBreaks += 1; |
| 941 | } |
| 942 | this.trimChunk(offset, bytes, lineBreaks); |
| 836 | 943 | } |
| 837 | | if (this.start > 2_048 && this.start * 2 >= this.lines.length) { |
| 838 | | this.lines = this.lines.slice(this.start); |
| 944 | if ( |
| 945 | this.start === this.chunks.length || |
| 946 | (this.start > 2_048 && this.start * 2 >= this.chunks.length) |
| 947 | ) { |
| 948 | this.chunks = this.chunks.slice(this.start); |
| 839 | 949 | this.start = 0; |
| 950 | if (this.chunks.length === 0) this.lastCodeUnit = undefined; |
| 840 | 951 | } |
| 841 | | this.cachedValue = null; |
| 842 | 952 | } |
| 843 | 953 | |
| 844 | 954 | clear(): void { |
| 845 | | this.lines = []; |
| 955 | this.chunks = []; |
| 846 | 956 | this.start = 0; |
| 847 | | this.trailingNewline = false; |
| 957 | this.byteLength = 0; |
| 958 | this.lineBreaks = 0; |
| 959 | this.lastCodeUnit = undefined; |
| 848 | 960 | this.cachedValue = ""; |
| 849 | 961 | } |
| 850 | 962 | |
| 851 | 963 | value(): string { |
| 852 | 964 | if (this.cachedValue !== null) return this.cachedValue; |
| 853 | | const joined = this.lines.slice(this.start).join("\n"); |
| 854 | | this.cachedValue = this.trailingNewline ? `${joined}\n` : joined; |
| 965 | this.cachedValue = this.chunks |
| 966 | .slice(this.start) |
| 967 | .map((chunk) => chunk!.data) |
| 968 | .join(""); |
| 855 | 969 | return this.cachedValue; |
| 856 | 970 | } |
| 857 | 971 | } |
| ... | ... | @@ -1171,6 +1285,7 @@ function normalizedRuntimeEnv( |
| 1171 | 1285 | interface TerminalManagerOptions { |
| 1172 | 1286 | logsDir: string; |
| 1173 | 1287 | historyLineLimit?: number; |
| 1288 | historyByteLimit?: number; |
| 1174 | 1289 | ptyAdapter: PtyAdapter.PtyAdapter["Service"]; |
| 1175 | 1290 | shellResolver?: () => string; |
| 1176 | 1291 | env?: NodeJS.ProcessEnv; |
| ... | ... | @@ -1211,6 +1326,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1211 | 1326 | |
| 1212 | 1327 | const logsDir = options.logsDir; |
| 1213 | 1328 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; |
| 1329 | const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; |
| 1214 | 1330 | const platform = yield* HostProcessPlatform; |
| 1215 | 1331 | // Terminals must inherit the user's full environment (minus the blocklist |
| 1216 | 1332 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips |
| ... | ... | @@ -1476,6 +1592,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1476 | 1592 | yield* flushPersist(threadId, terminalId); |
| 1477 | 1593 | }); |
| 1478 | 1594 | |
| 1595 | const readHistoryTail = Effect.fn("terminal.readHistoryTail")(function* (filePath: string) { |
| 1596 | const file = yield* fileSystem.open(filePath, { flag: "r" }); |
| 1597 | const info = yield* file.stat; |
| 1598 | const limit = BigInt(historyByteLimit); |
| 1599 | const offset = info.size > limit ? info.size - limit : 0n; |
| 1600 | yield* file.seek(offset, "start"); |
| 1601 | const bytes = new Uint8Array(Number(info.size - offset)); |
| 1602 | let length = 0; |
| 1603 | while (length < bytes.length) { |
| 1604 | const read = Number(yield* file.read(bytes.subarray(length))); |
| 1605 | if (read === 0) break; |
| 1606 | length += read; |
| 1607 | } |
| 1608 | let start = 0; |
| 1609 | if (offset > 0n) { |
| 1610 | // A tail read can start inside a UTF-8 code point. Skip its remaining bytes. |
| 1611 | while (start < length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1; |
| 1612 | } |
| 1613 | return { |
| 1614 | history: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(start, length)), |
| 1615 | truncated: offset > 0n, |
| 1616 | }; |
| 1617 | }); |
| 1618 | |
| 1479 | 1619 | const readHistory = Effect.fn("terminal.readHistory")(function* ( |
| 1480 | 1620 | threadId: string, |
| 1481 | 1621 | terminalId: string, |
| ... | ... | @@ -1490,15 +1630,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1490 | 1630 | ), |
| 1491 | 1631 | ) |
| 1492 | 1632 | ) { |
| 1493 | | const raw = yield* fileSystem |
| 1494 | | .readFileString(nextPath) |
| 1495 | | .pipe( |
| 1496 | | Effect.mapError( |
| 1497 | | (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), |
| 1498 | | ), |
| 1499 | | ); |
| 1500 | | const capped = capHistory(raw, historyLineLimit); |
| 1501 | | if (capped !== raw) { |
| 1633 | const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe( |
| 1634 | Effect.scoped, |
| 1635 | Effect.mapError( |
| 1636 | (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), |
| 1637 | ), |
| 1638 | ); |
| 1639 | const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); |
| 1640 | const capped = history.value(); |
| 1641 | if (truncated || capped !== raw) { |
| 1502 | 1642 | yield* fileSystem |
| 1503 | 1643 | .writeFileString(nextPath, capped) |
| 1504 | 1644 | .pipe( |
| ... | ... | @@ -1508,11 +1648,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1508 | 1648 | ), |
| 1509 | 1649 | ); |
| 1510 | 1650 | } |
| 1511 | | return capped; |
| 1651 | return history; |
| 1512 | 1652 | } |
| 1513 | 1653 | |
| 1514 | 1654 | if (terminalId !== DEFAULT_TERMINAL_ID) { |
| 1515 | | return ""; |
| 1655 | return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); |
| 1516 | 1656 | } |
| 1517 | 1657 | |
| 1518 | 1658 | const legacyPath = legacyHistoryPath(threadId); |
| ... | ... | @@ -1526,18 +1666,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1526 | 1666 | ), |
| 1527 | 1667 | )) |
| 1528 | 1668 | ) { |
| 1529 | | return ""; |
| 1669 | return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); |
| 1530 | 1670 | } |
| 1531 | 1671 | |
| 1532 | | const raw = yield* fileSystem |
| 1533 | | .readFileString(legacyPath) |
| 1534 | | .pipe( |
| 1535 | | Effect.mapError( |
| 1536 | | (cause) => |
| 1537 | | new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), |
| 1538 | | ), |
| 1539 | | ); |
| 1540 | | const capped = capHistory(raw, historyLineLimit); |
| 1672 | const { history: raw } = yield* readHistoryTail(legacyPath).pipe( |
| 1673 | Effect.scoped, |
| 1674 | Effect.mapError( |
| 1675 | (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), |
| 1676 | ), |
| 1677 | ); |
| 1678 | const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); |
| 1679 | const capped = history.value(); |
| 1541 | 1680 | yield* fileSystem |
| 1542 | 1681 | .writeFileString(nextPath, capped) |
| 1543 | 1682 | .pipe( |
| ... | ... | @@ -1554,7 +1693,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1554 | 1693 | }), |
| 1555 | 1694 | ), |
| 1556 | 1695 | ); |
| 1557 | | return capped; |
| 1696 | return history; |
| 1558 | 1697 | }); |
| 1559 | 1698 | |
| 1560 | 1699 | const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( |
| ... | ... | @@ -2219,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2219 | 2358 | worktreePath: input.worktreePath ?? null, |
| 2220 | 2359 | status: "starting", |
| 2221 | 2360 | pid: null, |
| 2222 | | history: new BoundedTerminalHistory(historyLineLimit, history), |
| 2361 | history, |
| 2223 | 2362 | pendingHistoryControlSequence: "", |
| 2224 | 2363 | pendingProcessEvents: [], |
| 2225 | 2364 | pendingProcessEventIndex: 0, |
| ... | ... | @@ -2631,7 +2770,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2631 | 2770 | worktreePath: input.worktreePath ?? null, |
| 2632 | 2771 | status: "starting", |
| 2633 | 2772 | pid: null, |
| 2634 | | history: new BoundedTerminalHistory(historyLineLimit, ""), |
| 2773 | history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), |
| 2635 | 2774 | pendingHistoryControlSequence: "", |
| 2636 | 2775 | pendingProcessEvents: [], |
| 2637 | 2776 | pendingProcessEventIndex: 0, |