| ... | ... | @@ -238,14 +238,14 @@ export interface TerminalStartInput extends TerminalOpenInput { |
| 238 | 238 | rows: number; |
| 239 | 239 | } |
| 240 | 240 | |
| 241 | | export interface TerminalSessionState { |
| 241 | interface TerminalSessionState { |
| 242 | 242 | threadId: string; |
| 243 | 243 | terminalId: string; |
| 244 | 244 | cwd: string; |
| 245 | 245 | worktreePath: string | null; |
| 246 | 246 | status: TerminalSessionStatus; |
| 247 | 247 | pid: number | null; |
| 248 | | history: string; |
| 248 | history: BoundedTerminalHistory; |
| 249 | 249 | pendingHistoryControlSequence: string; |
| 250 | 250 | pendingProcessEvents: Array<PendingProcessEvent>; |
| 251 | 251 | pendingProcessEventIndex: number; |
| ... | ... | @@ -266,7 +266,7 @@ export interface TerminalSessionState { |
| 266 | 266 | } |
| 267 | 267 | |
| 268 | 268 | interface PersistHistoryRequest { |
| 269 | | history: string; |
| 269 | history: BoundedTerminalHistory; |
| 270 | 270 | immediate: boolean; |
| 271 | 271 | } |
| 272 | 272 | |
| ... | ... | @@ -281,7 +281,7 @@ type DrainProcessEventAction = |
| 281 | 281 | threadId: string; |
| 282 | 282 | terminalId: string; |
| 283 | 283 | sequence: number; |
| 284 | | history: string | null; |
| 284 | history: BoundedTerminalHistory | null; |
| 285 | 285 | data: string; |
| 286 | 286 | } |
| 287 | 287 | | { |
| ... | ... | @@ -340,7 +340,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { |
| 340 | 340 | worktreePath: session.worktreePath, |
| 341 | 341 | status: session.status, |
| 342 | 342 | pid: session.pid, |
| 343 | | history: session.history, |
| 343 | history: session.history.value(), |
| 344 | 344 | exitCode: session.exitCode, |
| 345 | 345 | exitSignal: session.exitSignal, |
| 346 | 346 | label: terminalWireLabel(session), |
| ... | ... | @@ -796,6 +796,66 @@ function capHistory(history: string, maxLines: number): string { |
| 796 | 796 | return hasTrailingNewline ? `${capped}\n` : capped; |
| 797 | 797 | } |
| 798 | 798 | |
| 799 | export class BoundedTerminalHistory { |
| 800 | private readonly maxLines: number; |
| 801 | private lines: Array<string>; |
| 802 | private start = 0; |
| 803 | private trailingNewline: boolean; |
| 804 | private cachedValue: string | null = null; |
| 805 | |
| 806 | constructor(maxLines: number, initial: string) { |
| 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(); |
| 812 | } |
| 813 | |
| 814 | append(text: string): void { |
| 815 | if (text.length === 0) return; |
| 816 | |
| 817 | const trailingNewline = text.endsWith("\n"); |
| 818 | const appendedLines = text.split("\n"); |
| 819 | if (trailingNewline) appendedLines.pop(); |
| 820 | |
| 821 | const activeLines = this.lines.length - this.start; |
| 822 | if (activeLines === 0 || this.trailingNewline) { |
| 823 | for (const line of appendedLines) this.lines.push(line); |
| 824 | } 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] ?? ""); |
| 828 | } |
| 829 | } |
| 830 | this.trailingNewline = trailingNewline; |
| 831 | |
| 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; |
| 836 | } |
| 837 | if (this.start > 2_048 && this.start * 2 >= this.lines.length) { |
| 838 | this.lines = this.lines.slice(this.start); |
| 839 | this.start = 0; |
| 840 | } |
| 841 | this.cachedValue = null; |
| 842 | } |
| 843 | |
| 844 | clear(): void { |
| 845 | this.lines = []; |
| 846 | this.start = 0; |
| 847 | this.trailingNewline = false; |
| 848 | this.cachedValue = ""; |
| 849 | } |
| 850 | |
| 851 | value(): string { |
| 852 | 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; |
| 855 | return this.cachedValue; |
| 856 | } |
| 857 | } |
| 858 | |
| 799 | 859 | function isCsiFinalByte(codePoint: number): boolean { |
| 800 | 860 | return codePoint >= 0x40 && codePoint <= 0x7e; |
| 801 | 861 | } |
| ... | ... | @@ -1372,22 +1432,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1372 | 1432 | return; |
| 1373 | 1433 | } |
| 1374 | 1434 | |
| 1375 | | yield* fileSystem.writeFileString(historyPath(threadId, terminalId), request.history).pipe( |
| 1376 | | Effect.catch((error) => |
| 1377 | | Effect.logWarning("failed to persist terminal history", { |
| 1378 | | threadId, |
| 1379 | | terminalId, |
| 1380 | | error, |
| 1381 | | }), |
| 1382 | | ), |
| 1383 | | ); |
| 1435 | yield* fileSystem |
| 1436 | .writeFileString(historyPath(threadId, terminalId), request.history.value()) |
| 1437 | .pipe( |
| 1438 | Effect.catch((error) => |
| 1439 | Effect.logWarning("failed to persist terminal history", { |
| 1440 | threadId, |
| 1441 | terminalId, |
| 1442 | error, |
| 1443 | }), |
| 1444 | ), |
| 1445 | ); |
| 1384 | 1446 | }), |
| 1385 | 1447 | }); |
| 1386 | 1448 | |
| 1387 | 1449 | const queuePersist = Effect.fn("terminal.queuePersist")(function* ( |
| 1388 | 1450 | threadId: string, |
| 1389 | 1451 | terminalId: string, |
| 1390 | | history: string, |
| 1452 | history: BoundedTerminalHistory, |
| 1391 | 1453 | ) { |
| 1392 | 1454 | yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { |
| 1393 | 1455 | history, |
| ... | ... | @@ -1405,7 +1467,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1405 | 1467 | const persistHistory = Effect.fn("terminal.persistHistory")(function* ( |
| 1406 | 1468 | threadId: string, |
| 1407 | 1469 | terminalId: string, |
| 1408 | | history: string, |
| 1470 | history: BoundedTerminalHistory, |
| 1409 | 1471 | ) { |
| 1410 | 1472 | yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { |
| 1411 | 1473 | history, |
| ... | ... | @@ -1661,10 +1723,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1661 | 1723 | ); |
| 1662 | 1724 | session.pendingHistoryControlSequence = sanitized.pendingControlSequence; |
| 1663 | 1725 | if (sanitized.visibleText.length > 0) { |
| 1664 | | session.history = capHistory( |
| 1665 | | `${session.history}${sanitized.visibleText}`, |
| 1666 | | historyLineLimit, |
| 1667 | | ); |
| 1726 | session.history.append(sanitized.visibleText); |
| 1668 | 1727 | } |
| 1669 | 1728 | const eventStamp = advanceEventSequence(session); |
| 1670 | 1729 | |
| ... | ... | @@ -2160,7 +2219,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2160 | 2219 | worktreePath: input.worktreePath ?? null, |
| 2161 | 2220 | status: "starting", |
| 2162 | 2221 | pid: null, |
| 2163 | | history, |
| 2222 | history: new BoundedTerminalHistory(historyLineLimit, history), |
| 2164 | 2223 | pendingHistoryControlSequence: "", |
| 2165 | 2224 | pendingProcessEvents: [], |
| 2166 | 2225 | pendingProcessEventIndex: 0, |
| ... | ... | @@ -2221,7 +2280,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2221 | 2280 | liveSession.cwd = input.cwd; |
| 2222 | 2281 | liveSession.worktreePath = nextWorktreePath; |
| 2223 | 2282 | liveSession.runtimeEnv = nextRuntimeEnv; |
| 2224 | | liveSession.history = ""; |
| 2283 | liveSession.history.clear(); |
| 2225 | 2284 | liveSession.pendingHistoryControlSequence = ""; |
| 2226 | 2285 | liveSession.pendingProcessEvents = []; |
| 2227 | 2286 | liveSession.pendingProcessEventIndex = 0; |
| ... | ... | @@ -2230,7 +2289,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2230 | 2289 | } else if (liveSession.status === "exited" || liveSession.status === "error") { |
| 2231 | 2290 | liveSession.runtimeEnv = nextRuntimeEnv; |
| 2232 | 2291 | liveSession.worktreePath = nextWorktreePath; |
| 2233 | | liveSession.history = ""; |
| 2292 | liveSession.history.clear(); |
| 2234 | 2293 | liveSession.pendingHistoryControlSequence = ""; |
| 2235 | 2294 | liveSession.pendingProcessEvents = []; |
| 2236 | 2295 | liveSession.pendingProcessEventIndex = 0; |
| ... | ... | @@ -2535,7 +2594,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2535 | 2594 | Effect.gen(function* () { |
| 2536 | 2595 | const terminalId = input.terminalId; |
| 2537 | 2596 | const session = yield* requireSession(input.threadId, terminalId); |
| 2538 | | session.history = ""; |
| 2597 | session.history.clear(); |
| 2539 | 2598 | session.pendingHistoryControlSequence = ""; |
| 2540 | 2599 | session.pendingProcessEvents = []; |
| 2541 | 2600 | session.pendingProcessEventIndex = 0; |
| ... | ... | @@ -2572,7 +2631,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2572 | 2631 | worktreePath: input.worktreePath ?? null, |
| 2573 | 2632 | status: "starting", |
| 2574 | 2633 | pid: null, |
| 2575 | | history: "", |
| 2634 | history: new BoundedTerminalHistory(historyLineLimit, ""), |
| 2576 | 2635 | pendingHistoryControlSequence: "", |
| 2577 | 2636 | pendingProcessEvents: [], |
| 2578 | 2637 | pendingProcessEventIndex: 0, |
| ... | ... | @@ -2608,7 +2667,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2608 | 2667 | const cols = input.cols ?? session.cols; |
| 2609 | 2668 | const rows = input.rows ?? session.rows; |
| 2610 | 2669 | |
| 2611 | | session.history = ""; |
| 2670 | session.history.clear(); |
| 2612 | 2671 | session.pendingHistoryControlSequence = ""; |
| 2613 | 2672 | session.pendingProcessEvents = []; |
| 2614 | 2673 | session.pendingProcessEventIndex = 0; |