| ... | @@ -75,6 +75,8 @@ export { | ... | @@ -75,6 +75,8 @@ export { |
| 75 | }; | 75 | }; |
| 76 | | 76 | |
| 77 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | 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 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | 80 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; |
| 79 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | 81 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; |
| 80 | const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; | 82 | const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; |
| ... | @@ -784,74 +786,186 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps | ... | @@ -784,74 +786,186 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps |
| 784 | }, | 786 | }, |
| 785 | ); | 787 | ); |
| 786 | | 788 | |
| 787 | function capHistory(history: string, maxLines: number): string { | 789 | interface TerminalHistoryChunk { |
| 788 | if (history.length === 0) return history; | 790 | data: string; |
| 789 | const hasTrailingNewline = history.endsWith("\n"); | 791 | byteLength: number; |
| 790 | const lines = history.split("\n"); | 792 | lineBreaks: number; |
| 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; | | |
| 797 | } | 793 | } |
| 798 | | 794 | |
| 799 | export class BoundedTerminalHistory { | 795 | export class BoundedTerminalHistory { |
| 800 | private readonly maxLines: number; | 796 | private readonly maxLines: number; |
| 801 | private lines: Array<string>; | 797 | private readonly maxBytes: number; |
| | 798 | private chunks: Array<TerminalHistoryChunk | undefined> = []; |
| 802 | private start = 0; | 799 | private start = 0; |
| 803 | private trailingNewline: boolean; | 800 | private byteLength = 0; |
| 804 | private cachedValue: string | null = null; | 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 | this.maxLines = maxLines; | 807 | this.maxLines = maxLines; |
| 808 | const capped = capHistory(initial, maxLines); | 808 | this.maxBytes = maxBytes; |
| 809 | this.trailingNewline = capped.endsWith("\n"); | 809 | this.append(initial); |
| 810 | this.lines = capped.length === 0 ? [] : capped.split("\n"); | | |
| 811 | if (this.trailingNewline) this.lines.pop(); | | |
| 812 | } | 810 | } |
| 813 | | 811 | |
| 814 | append(text: string): void { | 812 | append(text: string): void { |
| 815 | if (text.length === 0) return; | 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"); | 843 | while (offset < text.length) { |
| 818 | const appendedLines = text.split("\n"); | 844 | let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length); |
| 819 | if (trailingNewline) appendedLines.pop(); | 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; | 862 | private appendChunk(data: string): void { |
| 822 | if (activeLines === 0 || this.trailingNewline) { | 863 | const byteLength = Buffer.byteLength(data); |
| 823 | for (const line of appendedLines) this.lines.push(line); | 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 | } else { | 873 | } else { |
| 825 | this.lines[this.lines.length - 1] += appendedLines[0] ?? ""; | 874 | this.chunks.push({ data, byteLength, lineBreaks }); |
| 826 | for (let index = 1; index < appendedLines.length; index += 1) { | 875 | } |
| 827 | this.lines.push(appendedLines[index] ?? ""); | 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; | 920 | while (this.byteLength > this.maxBytes) { |
| 833 | if (overflow > 0) { | 921 | const first = this.chunks[this.start]!; |
| 834 | this.lines.fill("", this.start, this.start + overflow); | 922 | const bytesToDrop = this.byteLength - this.maxBytes; |
| 835 | this.start += overflow; | 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) { | 944 | if ( |
| 838 | this.lines = this.lines.slice(this.start); | 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 | this.start = 0; | 949 | this.start = 0; |
| | 950 | if (this.chunks.length === 0) this.lastCodeUnit = undefined; |
| 840 | } | 951 | } |
| 841 | this.cachedValue = null; | | |
| 842 | } | 952 | } |
| 843 | | 953 | |
| 844 | clear(): void { | 954 | clear(): void { |
| 845 | this.lines = []; | 955 | this.chunks = []; |
| 846 | this.start = 0; | 956 | this.start = 0; |
| 847 | this.trailingNewline = false; | 957 | this.byteLength = 0; |
| | 958 | this.lineBreaks = 0; |
| | 959 | this.lastCodeUnit = undefined; |
| 848 | this.cachedValue = ""; | 960 | this.cachedValue = ""; |
| 849 | } | 961 | } |
| 850 | | 962 | |
| 851 | value(): string { | 963 | value(): string { |
| 852 | if (this.cachedValue !== null) return this.cachedValue; | 964 | if (this.cachedValue !== null) return this.cachedValue; |
| 853 | const joined = this.lines.slice(this.start).join("\n"); | 965 | this.cachedValue = this.chunks |
| 854 | this.cachedValue = this.trailingNewline ? `${joined}\n` : joined; | 966 | .slice(this.start) |
| | 967 | .map((chunk) => chunk!.data) |
| | 968 | .join(""); |
| 855 | return this.cachedValue; | 969 | return this.cachedValue; |
| 856 | } | 970 | } |
| 857 | } | 971 | } |
| ... | @@ -1171,6 +1285,7 @@ function normalizedRuntimeEnv( | ... | @@ -1171,6 +1285,7 @@ function normalizedRuntimeEnv( |
| 1171 | interface TerminalManagerOptions { | 1285 | interface TerminalManagerOptions { |
| 1172 | logsDir: string; | 1286 | logsDir: string; |
| 1173 | historyLineLimit?: number; | 1287 | historyLineLimit?: number; |
| | 1288 | historyByteLimit?: number; |
| 1174 | ptyAdapter: PtyAdapter.PtyAdapter["Service"]; | 1289 | ptyAdapter: PtyAdapter.PtyAdapter["Service"]; |
| 1175 | shellResolver?: () => string; | 1290 | shellResolver?: () => string; |
| 1176 | env?: NodeJS.ProcessEnv; | 1291 | env?: NodeJS.ProcessEnv; |
| ... | @@ -1211,6 +1326,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1211,6 +1326,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1211 | | 1326 | |
| 1212 | const logsDir = options.logsDir; | 1327 | const logsDir = options.logsDir; |
| 1213 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | 1328 | const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; |
| | 1329 | const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; |
| 1214 | const platform = yield* HostProcessPlatform; | 1330 | const platform = yield* HostProcessPlatform; |
| 1215 | // Terminals must inherit the user's full environment (minus the blocklist | 1331 | // Terminals must inherit the user's full environment (minus the blocklist |
| 1216 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips | 1332 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips |
| ... | @@ -1476,6 +1592,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1476,6 +1592,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1476 | yield* flushPersist(threadId, terminalId); | 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 | const readHistory = Effect.fn("terminal.readHistory")(function* ( | 1619 | const readHistory = Effect.fn("terminal.readHistory")(function* ( |
| 1480 | threadId: string, | 1620 | threadId: string, |
| 1481 | terminalId: string, | 1621 | terminalId: string, |
| ... | @@ -1490,15 +1630,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -1490,15 +1630,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1490 | ), | 1630 | ), |
| 1491 | ) | 1631 | ) |
| 1492 | ) { | 1632 | ) { |
| 1493 | const raw = yield* fileSystem | 1633 | const { history: raw, truncated } = yield* readHistoryTail(nextPath).pipe( |
| 1494 | .readFileString(nextPath) | 1634 | Effect.scoped, |
| 1495 | .pipe( | 1635 | Effect.mapError( |
| 1496 | Effect.mapError( | 1636 | (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), |
| 1497 | (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), | 1637 | ), |
| 1498 | ), | 1638 | ); |
| 1499 | ); | 1639 | const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); |
| 1500 | const capped = capHistory(raw, historyLineLimit); | 1640 | const capped = history.value(); |
| 1501 | if (capped !== raw) { | 1641 | if (truncated || capped !== raw) { |
| 1502 | yield* fileSystem | 1642 | yield* fileSystem |
| 1503 | .writeFileString(nextPath, capped) | 1643 | .writeFileString(nextPath, capped) |
| 1504 | .pipe( | 1644 | .pipe( |
| ... | @@ -1508,11 +1648,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -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 | if (terminalId !== DEFAULT_TERMINAL_ID) { | 1654 | if (terminalId !== DEFAULT_TERMINAL_ID) { |
| 1515 | return ""; | 1655 | return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); |
| 1516 | } | 1656 | } |
| 1517 | | 1657 | |
| 1518 | const legacyPath = legacyHistoryPath(threadId); | 1658 | const legacyPath = legacyHistoryPath(threadId); |
| ... | @@ -1526,18 +1666,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -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 | 1672 | const { history: raw } = yield* readHistoryTail(legacyPath).pipe( |
| 1533 | .readFileString(legacyPath) | 1673 | Effect.scoped, |
| 1534 | .pipe( | 1674 | Effect.mapError( |
| 1535 | Effect.mapError( | 1675 | (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), |
| 1536 | (cause) => | 1676 | ), |
| 1537 | new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), | 1677 | ); |
| 1538 | ), | 1678 | const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); |
| 1539 | ); | 1679 | const capped = history.value(); |
| 1540 | const capped = capHistory(raw, historyLineLimit); | | |
| 1541 | yield* fileSystem | 1680 | yield* fileSystem |
| 1542 | .writeFileString(nextPath, capped) | 1681 | .writeFileString(nextPath, capped) |
| 1543 | .pipe( | 1682 | .pipe( |
| ... | @@ -1554,7 +1693,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -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 | const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( | 1699 | const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( |
| ... | @@ -2219,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2219,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2219 | worktreePath: input.worktreePath ?? null, | 2358 | worktreePath: input.worktreePath ?? null, |
| 2220 | status: "starting", | 2359 | status: "starting", |
| 2221 | pid: null, | 2360 | pid: null, |
| 2222 | history: new BoundedTerminalHistory(historyLineLimit, history), | 2361 | history, |
| 2223 | pendingHistoryControlSequence: "", | 2362 | pendingHistoryControlSequence: "", |
| 2224 | pendingProcessEvents: [], | 2363 | pendingProcessEvents: [], |
| 2225 | pendingProcessEventIndex: 0, | 2364 | pendingProcessEventIndex: 0, |
| ... | @@ -2631,7 +2770,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func | ... | @@ -2631,7 +2770,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2631 | worktreePath: input.worktreePath ?? null, | 2770 | worktreePath: input.worktreePath ?? null, |
| 2632 | status: "starting", | 2771 | status: "starting", |
| 2633 | pid: null, | 2772 | pid: null, |
| 2634 | history: new BoundedTerminalHistory(historyLineLimit, ""), | 2773 | history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), |
| 2635 | pendingHistoryControlSequence: "", | 2774 | pendingHistoryControlSequence: "", |
| 2636 | pendingProcessEvents: [], | 2775 | pendingProcessEvents: [], |
| 2637 | pendingProcessEventIndex: 0, | 2776 | pendingProcessEventIndex: 0, |