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