@golba98/codexa 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +17 -18
  2. package/bin/codexa.js +62 -144
  3. package/package.json +4 -3
  4. package/src/app.tsx +533 -275
  5. package/src/commands/handler.ts +2 -2
  6. package/src/config/buildInfo.ts +2 -2
  7. package/src/config/layeredConfig.ts +1 -1
  8. package/src/config/runtimeConfig.ts +1 -1
  9. package/src/config/settings.ts +1 -1
  10. package/src/config/trustStore.ts +1 -1
  11. package/src/core/README.md +52 -0
  12. package/src/core/agent/loop.ts +282 -0
  13. package/src/core/agent/protocol.ts +211 -0
  14. package/src/core/agent/tools.ts +414 -0
  15. package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
  16. package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
  17. package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
  18. package/src/core/debug/modelStateDebug.ts +34 -0
  19. package/src/core/executables/antigravityExecutable.ts +48 -0
  20. package/src/core/executables/executableResolver.ts +5 -1
  21. package/src/core/perf/renderDebug.ts +10 -6
  22. package/src/core/providerLauncher/registry.ts +30 -14
  23. package/src/core/providerLauncher/types.ts +11 -9
  24. package/src/core/providerLauncher/workspaceConfig.ts +41 -26
  25. package/src/core/providerRuntime/anthropic.ts +7 -1
  26. package/src/core/providerRuntime/antigravity.ts +305 -0
  27. package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
  28. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  29. package/src/core/providerRuntime/contextMetadata.ts +12 -24
  30. package/src/core/providerRuntime/local.ts +129 -51
  31. package/src/core/providerRuntime/models.ts +22 -11
  32. package/src/core/providerRuntime/registry.ts +58 -31
  33. package/src/core/providerRuntime/types.ts +19 -14
  34. package/src/core/providers/codexSubprocess.ts +2 -2
  35. package/src/core/providers/types.ts +1 -1
  36. package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
  37. package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
  38. package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
  39. package/src/core/terminal/clearFrameBoundary.ts +814 -0
  40. package/src/core/terminal/frameLock.ts +109 -0
  41. package/src/core/terminal/inkRenderReset.ts +123 -0
  42. package/src/core/terminal/terminalControl.ts +22 -0
  43. package/src/core/terminal/terminalTitle.ts +16 -102
  44. package/src/core/{channel.ts → version/channel.ts} +1 -1
  45. package/src/core/{updateCheck.ts → version/updateCheck.ts} +10 -5
  46. package/src/core/{launchContext.ts → workspace/launchContext.ts} +9 -16
  47. package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
  48. package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
  49. package/src/headless/execRunner.ts +2 -2
  50. package/src/index.tsx +43 -89
  51. package/src/session/appSession.ts +10 -7
  52. package/src/session/chatLifecycle.ts +1 -1
  53. package/src/session/liveRenderScheduler.ts +1 -1
  54. package/src/session/types.ts +1 -1
  55. package/src/ui/AppShell.tsx +672 -706
  56. package/src/ui/AttachmentImportPanel.tsx +2 -2
  57. package/src/ui/BottomComposer.tsx +145 -144
  58. package/src/ui/ModelPickerScreen.tsx +216 -36
  59. package/src/ui/ModelReasoningPicker.tsx +1 -1
  60. package/src/ui/PlanReviewPanel.tsx +1 -1
  61. package/src/ui/ProviderPicker.tsx +735 -321
  62. package/src/ui/RuntimeStatusBar.tsx +108 -0
  63. package/src/ui/SelectionPanel.tsx +4 -0
  64. package/src/ui/SettingsPanel.tsx +1 -1
  65. package/src/ui/TextEntryPanel.tsx +1 -1
  66. package/src/ui/Timeline.tsx +1619 -1470
  67. package/src/ui/TopHeader.tsx +46 -30
  68. package/src/ui/TranscriptShell.tsx +322 -0
  69. package/src/ui/TurnGroup.tsx +2 -2
  70. package/src/ui/UpdateAvailableCard.tsx +3 -3
  71. package/src/ui/UpdatePromptPanel.tsx +16 -22
  72. package/src/ui/layout.ts +298 -24
  73. package/src/ui/layoutListWindow.ts +145 -0
  74. package/src/ui/logoVariants.ts +4 -8
  75. package/src/ui/runtimeDisplay.ts +15 -3
  76. package/src/ui/textLayout.ts +15 -4
  77. package/src/ui/timelineMeasure.ts +194 -122
  78. package/src/core/codex.ts +0 -124
  79. package/src/ui/StaticTranscriptItem.tsx +0 -56
  80. /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
  81. /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
  82. /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
  83. /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
  84. /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
  85. /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
@@ -0,0 +1,814 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as renderDebug from "../perf/renderDebug.js";
3
+ import { resetInkOutputForFreshFrame, type InkRenderInstance } from "./inkRenderReset.js";
4
+ import { traceTerminalClear } from "./terminalControl.js";
5
+
6
+ interface ClearBoundaryStdoutLike {
7
+ columns?: number;
8
+ rows?: number;
9
+ }
10
+
11
+ interface TerminalClearLike {
12
+ clearTranscript: (source: string) => void;
13
+ clearViewport: (source: string) => void;
14
+ setAlternateScreen?: (enabled: boolean, source: string) => void;
15
+ }
16
+
17
+ interface LogShadowState {
18
+ previousOutputLength: number;
19
+ previousLineCount: number;
20
+ }
21
+
22
+ interface TerminalControlSequenceStats {
23
+ cursorUpCount: number;
24
+ cursorHomeCount: number;
25
+ eraseLineCount: number;
26
+ eraseDisplayCount: number;
27
+ carriageReturnCount: number;
28
+ }
29
+
30
+ type InkLogLike = {
31
+ (output: string): unknown;
32
+ clear?: () => unknown;
33
+ sync?: (output: string) => unknown;
34
+ reset?: () => unknown;
35
+ willRender?: (output: string) => unknown;
36
+ isCursorDirty?: () => unknown;
37
+ };
38
+
39
+ interface SavedNormalBufferFrame {
40
+ lastOutput: string;
41
+ lastOutputToRender: string;
42
+ lastOutputHeight: number;
43
+ fullStaticOutput: string;
44
+ cols: number | undefined;
45
+ }
46
+
47
+ export interface ClearFrameBoundaryRenderState {
48
+ generation: number;
49
+ staticEventsLength: number;
50
+ activeEventsLength: number;
51
+ transcriptCleared: boolean;
52
+ clearGenerationReady?: boolean;
53
+ uiStateKind: string;
54
+ }
55
+
56
+ export interface ClearFrameBoundaryController {
57
+ beginClearGeneration: (generation: number) => boolean;
58
+ /**
59
+ * Mirror the React render state into the boundary's gate. Returns `true` when
60
+ * a post-clear authoritative frame is now ready to commit but hasn't been
61
+ * written yet — the caller must force one more render so Ink's
62
+ * renderInteractiveFrame runs again and flushes it (see app.tsx).
63
+ */
64
+ syncRenderState: (state: ClearFrameBoundaryRenderState) => boolean;
65
+ getState: () => {
66
+ clearPending: boolean;
67
+ pendingGeneration: number | null;
68
+ committedGeneration: number | null;
69
+ renderGeneration: number;
70
+ transcriptCleared: boolean;
71
+ lastFrameWasAuthoritative: boolean;
72
+ widthRepaintPending: boolean;
73
+ overlayActive: boolean;
74
+ logShadow: LogShadowState;
75
+ };
76
+ dispose: () => void;
77
+ }
78
+
79
+ interface CreateClearFrameBoundaryOptions {
80
+ instance: InkRenderInstance | null;
81
+ terminalControl: TerminalClearLike;
82
+ stdout: ClearBoundaryStdoutLike;
83
+ source?: string;
84
+ /**
85
+ * Reports whether the committing render tree is an overlay screen (any
86
+ * screen other than "main"). Read at frame-write time so alternate-screen
87
+ * enter/exit happens atomically with the first frame of the new screen —
88
+ * never from an effect that runs after the frame already hit the wrong
89
+ * buffer. The callback must reflect the render that produced the frame
90
+ * (app.tsx passes a ref assigned during render).
91
+ */
92
+ isOverlayActive?: () => boolean;
93
+ /**
94
+ * Called synchronously whenever a width change requires the home/transcript
95
+ * content to be rebuilt. The physical clear is DEFERRED until the caller has
96
+ * re-rendered with a fresh <Static> instance: Ink's <Static> never re-emits
97
+ * items once flushed, so the boundary suppresses frames until a frame
98
+ * arrives that carries the re-flushed static content, then clears and writes
99
+ * that frame atomically. The caller must use this to force a fresh render
100
+ * with a new <Static> key so its content gets reflushed at the new width.
101
+ */
102
+ onWidthResizeRefresh?: () => void;
103
+ /**
104
+ * Reports the repaint generation of the committing render tree (the counter
105
+ * onWidthResizeRefresh bumps), read at frame-write time like isOverlayActive.
106
+ * Lets the boundary tell the genuine post-bump <Static> re-flush apart from
107
+ * an incremental pre-resize static chunk that happens to land while the
108
+ * repaint is pending — committing the latter would clear the scrollback and
109
+ * then write only that chunk, losing the rest of the transcript.
110
+ */
111
+ getRenderedRepaintGeneration?: () => number;
112
+ /**
113
+ * Reports the raw terminal column count the committing render tree was laid
114
+ * out against, read at frame-write time. The viewport hook commits new
115
+ * dimensions on a trailing settle (~100ms after the resize event), so the
116
+ * <Static> re-flush must not be requested until a commit whose layout
117
+ * already matches stdout.columns — remounting earlier would re-flush the
118
+ * transcript at the pre-resize width.
119
+ */
120
+ getRenderedLayoutCols?: () => number | undefined;
121
+ }
122
+
123
+ interface FrameMarkerCounts {
124
+ codexaLogoCount: number;
125
+ providerMigratedCount: number;
126
+ launchModeCount: number;
127
+ composerCount: number;
128
+ footerCount: number;
129
+ }
130
+
131
+ const LOGO_LINE = /██╔════╝██╔═══██╗/g;
132
+ const PROVIDER_MIGRATED = /Provider migrated/g;
133
+ const LAUNCH_MODE = /Launch mode/g;
134
+ const COMPOSER = /│ ❯/g;
135
+ const FOOTER_CONTEXT = /Context:/g;
136
+ const ANSI_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
137
+
138
+ /**
139
+ * Safety valve for the deferred width repaint: if the re-flushed static frame
140
+ * never arrives (unexpected render stall), stop suppressing and commit the
141
+ * next frame with whatever static content Ink has accumulated, so the UI can
142
+ * never freeze behind the gate.
143
+ */
144
+ const WIDTH_REPAINT_MAX_SUPPRESSED_FRAMES = 8;
145
+
146
+ function stableFrameHash(text: string): string {
147
+ return createHash("sha1").update(text, "utf8").digest("hex").slice(0, 12);
148
+ }
149
+
150
+ function countMatches(text: string, pattern: RegExp): number {
151
+ pattern.lastIndex = 0;
152
+ let count = 0;
153
+ while (pattern.exec(text)) count += 1;
154
+ pattern.lastIndex = 0;
155
+ return count;
156
+ }
157
+
158
+ function countFrameMarkers(text: string): FrameMarkerCounts {
159
+ const plainText = text.replace(ANSI_SEQUENCE, "");
160
+ return {
161
+ codexaLogoCount: countMatches(plainText, LOGO_LINE),
162
+ providerMigratedCount: countMatches(plainText, PROVIDER_MIGRATED),
163
+ launchModeCount: countMatches(plainText, LAUNCH_MODE),
164
+ composerCount: countMatches(plainText, COMPOSER),
165
+ footerCount: countMatches(plainText, FOOTER_CONTEXT),
166
+ };
167
+ }
168
+
169
+ function buildFrameText(instance: InkRenderInstance, output: string, staticOutput = ""): string {
170
+ const fullStaticOutput = instance.fullStaticOutput ?? "";
171
+ if (!staticOutput) return `${fullStaticOutput}${output}`;
172
+ if (!fullStaticOutput || staticOutput.includes(fullStaticOutput)) {
173
+ return `${staticOutput}${output}`;
174
+ }
175
+ return `${fullStaticOutput}${staticOutput}${output}`;
176
+ }
177
+
178
+ function snapshotFrameState(instance: InkRenderInstance, logShadow: LogShadowState) {
179
+ return {
180
+ lastOutputLength: (instance.lastOutput ?? "").length,
181
+ lastOutputToRenderLength: (instance.lastOutputToRender ?? "").length,
182
+ lastOutputHeight: instance.lastOutputHeight ?? 0,
183
+ fullStaticOutputLength: (instance.fullStaticOutput ?? "").length,
184
+ logPreviousOutputLength: logShadow.previousOutputLength,
185
+ logPreviousLineCount: logShadow.previousLineCount,
186
+ };
187
+ }
188
+
189
+ function lineCountForOutput(output: string): number {
190
+ if (output.length === 0) return 0;
191
+ return output.split("\n").length;
192
+ }
193
+
194
+ function countAnsiMatches(text: string, pattern: RegExp): number {
195
+ pattern.lastIndex = 0;
196
+ let count = 0;
197
+ while (pattern.exec(text)) count += 1;
198
+ pattern.lastIndex = 0;
199
+ return count;
200
+ }
201
+
202
+ function inspectControlSequences(output: string): TerminalControlSequenceStats {
203
+ return {
204
+ cursorUpCount: countAnsiMatches(output, /\x1b\[[0-9]*A/g),
205
+ cursorHomeCount: countAnsiMatches(output, /\x1b\[[0-9;]*H/g),
206
+ eraseLineCount: countAnsiMatches(output, /\x1b\[[0-9]*K/g),
207
+ eraseDisplayCount: countAnsiMatches(output, /\x1b\[[0-9]*J/g),
208
+ carriageReturnCount: countAnsiMatches(output, /\r/g),
209
+ };
210
+ }
211
+
212
+ function wrapInkLogForTrace(
213
+ instance: InkRenderInstance,
214
+ source: string,
215
+ shadow: LogShadowState,
216
+ ): () => void {
217
+ const currentLog = instance.log;
218
+ if (typeof currentLog !== "function") {
219
+ return () => {};
220
+ }
221
+
222
+ const original = currentLog as InkLogLike;
223
+
224
+ const wrapped: InkLogLike = ((output: string) => {
225
+ const before = { ...shadow };
226
+ const controlSequences = inspectControlSequences(output);
227
+ const result = original(output);
228
+ shadow.previousOutputLength = output.length;
229
+ shadow.previousLineCount = lineCountForOutput(output);
230
+ renderDebug.traceEvent("terminal", "inkLogWrite", {
231
+ source,
232
+ outputLength: output.length,
233
+ outputHash: stableFrameHash(output),
234
+ previousOutputLengthBefore: before.previousOutputLength,
235
+ previousLineCountBefore: before.previousLineCount,
236
+ previousOutputLengthAfter: shadow.previousOutputLength,
237
+ previousLineCountAfter: shadow.previousLineCount,
238
+ ...controlSequences,
239
+ });
240
+ return result;
241
+ }) as InkLogLike;
242
+
243
+ wrapped.clear = () => {
244
+ const before = { ...shadow };
245
+ const result = original.clear?.();
246
+ shadow.previousOutputLength = 0;
247
+ shadow.previousLineCount = 0;
248
+ renderDebug.traceEvent("terminal", "inkLogClear", {
249
+ source,
250
+ previousOutputLengthBefore: before.previousOutputLength,
251
+ previousLineCountBefore: before.previousLineCount,
252
+ previousOutputLengthAfter: shadow.previousOutputLength,
253
+ previousLineCountAfter: shadow.previousLineCount,
254
+ });
255
+ return result;
256
+ };
257
+
258
+ wrapped.sync = (output: string) => {
259
+ const before = { ...shadow };
260
+ const controlSequences = inspectControlSequences(output);
261
+ const result = original.sync?.(output);
262
+ shadow.previousOutputLength = output.length;
263
+ shadow.previousLineCount = lineCountForOutput(output);
264
+ renderDebug.traceEvent("terminal", "inkLogSync", {
265
+ source,
266
+ outputLength: output.length,
267
+ previousOutputLengthBefore: before.previousOutputLength,
268
+ previousLineCountBefore: before.previousLineCount,
269
+ previousOutputLengthAfter: shadow.previousOutputLength,
270
+ previousLineCountAfter: shadow.previousLineCount,
271
+ ...controlSequences,
272
+ });
273
+ return result;
274
+ };
275
+
276
+ wrapped.reset = () => {
277
+ const before = { ...shadow };
278
+ const result = original.reset?.();
279
+ shadow.previousOutputLength = 0;
280
+ shadow.previousLineCount = 0;
281
+ renderDebug.traceEvent("terminal", "inkLogReset", {
282
+ source,
283
+ previousOutputLengthBefore: before.previousOutputLength,
284
+ previousLineCountBefore: before.previousLineCount,
285
+ previousOutputLengthAfter: shadow.previousOutputLength,
286
+ previousLineCountAfter: shadow.previousLineCount,
287
+ });
288
+ return result;
289
+ };
290
+
291
+ wrapped.willRender = original.willRender ? (output: string) => original.willRender?.(output) : undefined;
292
+ wrapped.isCursorDirty = original.isCursorDirty ? () => original.isCursorDirty?.() : undefined;
293
+
294
+ instance.log = wrapped as unknown as { reset?: () => void };
295
+
296
+ return () => {
297
+ instance.log = original as unknown as { reset?: () => void };
298
+ };
299
+ }
300
+
301
+ export function createClearFrameBoundaryController({
302
+ instance,
303
+ terminalControl,
304
+ stdout,
305
+ source = "src/core/terminal/clearFrameBoundary.ts",
306
+ isOverlayActive,
307
+ onWidthResizeRefresh,
308
+ getRenderedRepaintGeneration,
309
+ getRenderedLayoutCols,
310
+ }: CreateClearFrameBoundaryOptions): ClearFrameBoundaryController | null {
311
+ const originalRenderInteractiveFrame = instance?.renderInteractiveFrame;
312
+ if (!instance || typeof originalRenderInteractiveFrame !== "function") {
313
+ renderDebug.traceEvent("terminal", "clearBoundaryInit", {
314
+ source,
315
+ liveInkInstanceResolved: Boolean(instance),
316
+ renderInteractiveFrameResolved: false,
317
+ });
318
+ return null;
319
+ }
320
+
321
+ let clearPending = false;
322
+ let pendingGeneration: number | null = null;
323
+ let committedGeneration: number | null = null;
324
+ let renderGeneration = 0;
325
+ let transcriptCleared = false;
326
+ let clearGenerationReady = false;
327
+ let staticEventsLength = 0;
328
+ let activeEventsLength = 0;
329
+ let uiStateKind = "IDLE";
330
+ let lastFrameWasAuthoritative = false;
331
+ let suppressedPostClearStaticOutput = "";
332
+ // Deferred width repaint: armed when the terminal width changes under a
333
+ // main-screen frame, resolved when the re-flushed static frame arrives.
334
+ let widthRepaintPending = false;
335
+ let widthRepaintSuppressedFrames = 0;
336
+ let widthRepaintTargetGeneration: number | null = null;
337
+ let widthRepaintReflushRequested = false;
338
+ // Alternate-screen overlay state. While an overlay is active, the normal
339
+ // buffer's Ink caches are parked here so diffing can resume against the
340
+ // buffer's true contents when the overlay exits (DECSET 1049 restores the
341
+ // normal buffer byte-for-byte).
342
+ let overlayActive = false;
343
+ let savedNormalFrame: SavedNormalBufferFrame | null = null;
344
+ let overlayCapturedStaticOutput = "";
345
+ let previousCols = stdout.columns;
346
+ let previousRows = stdout.rows;
347
+ const logShadow: LogShadowState = { previousOutputLength: 0, previousLineCount: 0 };
348
+
349
+ const restoreLog = wrapInkLogForTrace(instance, source, logShadow);
350
+ const boundOriginal = originalRenderInteractiveFrame.bind(instance);
351
+
352
+ const isPostClearFrameReady = (): boolean => {
353
+ if (!clearPending || pendingGeneration === null) return false;
354
+ return renderGeneration >= pendingGeneration && (transcriptCleared || clearGenerationReady);
355
+ };
356
+
357
+ const setAlternateScreen = (enabled: boolean, reason: string): void => {
358
+ terminalControl.setAlternateScreen?.(enabled, `${source}:${reason}`);
359
+ };
360
+
361
+ /**
362
+ * Ink's onRender appends this frame's static chunk to fullStaticOutput
363
+ * BEFORE calling renderInteractiveFrame. When the chunk must not land in the
364
+ * currently active buffer (overlay frames), peel it back off so the cache
365
+ * keeps describing the normal buffer only.
366
+ */
367
+ const detachStaticChunkFromFullStatic = (staticOutput: string): void => {
368
+ if (!staticOutput) return;
369
+ const fullStatic = instance.fullStaticOutput ?? "";
370
+ if (fullStatic.endsWith(staticOutput)) {
371
+ instance.fullStaticOutput = fullStatic.slice(0, fullStatic.length - staticOutput.length);
372
+ }
373
+ };
374
+
375
+ const armWidthRepaint = (reason: string): void => {
376
+ widthRepaintPending = true;
377
+ widthRepaintSuppressedFrames = 0;
378
+ widthRepaintTargetGeneration = null;
379
+ // The <Static> re-flush is requested later, from the first suppressed
380
+ // frame whose committed layout already matches the new terminal width —
381
+ // requesting it here would remount <Static> against the pre-resize layout
382
+ // (the viewport hook commits dimensions on a trailing settle).
383
+ widthRepaintReflushRequested = false;
384
+ renderDebug.traceEvent("terminal", "widthRepaintArmed", {
385
+ source,
386
+ reason,
387
+ currentCols: stdout.columns,
388
+ });
389
+ };
390
+
391
+ const commitAuthoritativeFrame = (
392
+ output: string,
393
+ outputHeight: number,
394
+ staticOutput: string,
395
+ clearMode: "transcript" | "viewport",
396
+ reason: string,
397
+ ): void => {
398
+ traceTerminalClear(`${source}:${reason}`, {
399
+ mode: clearMode,
400
+ clearGeneration: pendingGeneration ?? committedGeneration,
401
+ });
402
+ if (clearMode === "transcript") {
403
+ terminalControl.clearTranscript(`${source}:${reason}`);
404
+ } else {
405
+ terminalControl.clearViewport(`${source}:${reason}`);
406
+ }
407
+ const resetRan = resetInkOutputForFreshFrame({ instance, columns: stdout.columns });
408
+ renderDebug.traceEvent("terminal", "authoritativeFrameCommit", {
409
+ source,
410
+ reason,
411
+ clearMode,
412
+ inkCacheResetEmitted: resetRan,
413
+ staticLength: staticOutput.length,
414
+ outputLength: output.length,
415
+ });
416
+ lastFrameWasAuthoritative = true;
417
+ boundOriginal(output, outputHeight, staticOutput);
418
+ // Keep fullStaticOutput describing what is actually above the live frame in
419
+ // this buffer; onRender's accumulation was wiped by the cache reset above.
420
+ instance.fullStaticOutput = staticOutput;
421
+ };
422
+
423
+ instance.renderInteractiveFrame = (output: string, outputHeight: number, staticOutput: string) => {
424
+ const currentCols = stdout.columns;
425
+ const currentRows = stdout.rows;
426
+ const widthChanged = previousCols !== currentCols;
427
+ const resizeDetected = widthChanged || previousRows !== currentRows;
428
+ const overlayNow = Boolean(isOverlayActive?.());
429
+ previousCols = currentCols;
430
+ previousRows = currentRows;
431
+
432
+ if (resizeDetected) {
433
+ renderDebug.traceEvent("terminal", "resizeAfterClear", {
434
+ currentCols,
435
+ currentRows,
436
+ widthChanged,
437
+ clearPending,
438
+ widthRepaintPending,
439
+ overlayActive,
440
+ overlayNow,
441
+ });
442
+ }
443
+
444
+ // ── Overlay enter: main → overlay ─────────────────────────────────────
445
+ // Switch buffers BEFORE the overlay frame is written so it lands in the
446
+ // alternate screen, never in the normal buffer/scrollback.
447
+ if (overlayNow && !overlayActive) {
448
+ detachStaticChunkFromFullStatic(staticOutput);
449
+ savedNormalFrame = {
450
+ lastOutput: instance.lastOutput ?? "",
451
+ lastOutputToRender: instance.lastOutputToRender ?? "",
452
+ lastOutputHeight: instance.lastOutputHeight ?? 0,
453
+ fullStaticOutput: instance.fullStaticOutput ?? "",
454
+ cols: currentCols,
455
+ };
456
+ // Static content that arrives from this frame on belongs to the normal
457
+ // buffer's transcript — hold it until the overlay exits.
458
+ overlayCapturedStaticOutput = staticOutput;
459
+ overlayActive = true;
460
+ setAlternateScreen(true, "overlayEnter");
461
+ // DECSET 1049 keeps the cursor where the normal buffer left it; home it
462
+ // so the overlay frame starts at the top of the blank alternate buffer.
463
+ terminalControl.clearViewport(`${source}:overlayEnter`);
464
+ resetInkOutputForFreshFrame({ instance, columns: currentCols });
465
+ renderDebug.traceEvent("terminal", "overlayEnter", {
466
+ source,
467
+ currentCols,
468
+ currentRows,
469
+ savedLastOutputHeight: savedNormalFrame.lastOutputHeight,
470
+ capturedStaticLength: overlayCapturedStaticOutput.length,
471
+ });
472
+ boundOriginal(output, outputHeight, "");
473
+ return;
474
+ }
475
+
476
+ // ── Overlay exit: overlay → main ──────────────────────────────────────
477
+ if (!overlayNow && overlayActive) {
478
+ overlayActive = false;
479
+ detachStaticChunkFromFullStatic(staticOutput);
480
+ setAlternateScreen(false, "overlayExit");
481
+
482
+ const saved = savedNormalFrame;
483
+ savedNormalFrame = null;
484
+ const pendingStatic = `${overlayCapturedStaticOutput}${staticOutput}`;
485
+ overlayCapturedStaticOutput = "";
486
+
487
+ if (clearPending) {
488
+ // A /clear was issued while the overlay was open; the post-clear gate
489
+ // owns the next authoritative frame. Fall through to the clear
490
+ // machinery below with the caches untouched.
491
+ if (pendingStatic) {
492
+ suppressedPostClearStaticOutput = pendingStatic;
493
+ }
494
+ } else {
495
+ // DECSET 1049l restored the normal buffer exactly as saved; restore the
496
+ // caches so Ink diffs against what is really on screen.
497
+ if (saved) {
498
+ instance.lastOutput = saved.lastOutput;
499
+ instance.lastOutputToRender = saved.lastOutputToRender;
500
+ instance.lastOutputHeight = saved.lastOutputHeight;
501
+ instance.fullStaticOutput = saved.fullStaticOutput;
502
+ (instance.log as InkLogLike | undefined)?.sync?.(saved.lastOutputToRender);
503
+ }
504
+ renderDebug.traceEvent("terminal", "overlayExit", {
505
+ source,
506
+ currentCols,
507
+ currentRows,
508
+ widthChangedWhileAway: saved ? saved.cols !== currentCols : false,
509
+ pendingStaticLength: pendingStatic.length,
510
+ });
511
+
512
+ if (saved && saved.cols !== currentCols) {
513
+ // The terminal was resized while the overlay was open; the restored
514
+ // normal buffer reflowed and is stale. Repaint it from scratch. The
515
+ // remounted <Static> re-emits everything held in React state, so the
516
+ // pending chunks are covered by the re-flush.
517
+ armWidthRepaint("overlayExitWidthChanged");
518
+ return;
519
+ }
520
+
521
+ instance.fullStaticOutput = `${instance.fullStaticOutput ?? ""}${pendingStatic}`;
522
+ boundOriginal(output, outputHeight, pendingStatic);
523
+ return;
524
+ }
525
+ }
526
+
527
+ // ── Overlay steady state ──────────────────────────────────────────────
528
+ if (overlayNow) {
529
+ if (staticOutput) {
530
+ // Transcript rows flushed while the overlay is open would be written
531
+ // into the alternate buffer and lost on exit; hold them instead.
532
+ detachStaticChunkFromFullStatic(staticOutput);
533
+ overlayCapturedStaticOutput += staticOutput;
534
+ }
535
+ if (widthChanged) {
536
+ // The alternate buffer has no scrollback; a viewport clear plus a full
537
+ // rewrite is a complete repaint.
538
+ terminalControl.clearViewport(`${source}:overlayResize`);
539
+ resetInkOutputForFreshFrame({ instance, columns: currentCols });
540
+ lastFrameWasAuthoritative = true;
541
+ }
542
+ boundOriginal(output, outputHeight, "");
543
+ return;
544
+ }
545
+
546
+ // ── Main screen: /clear generation gate ───────────────────────────────
547
+ const postClearReady = isPostClearFrameReady();
548
+ const isFirstPostClearCommit = clearPending && postClearReady;
549
+ const staleFrameSuppressed = clearPending && !postClearReady;
550
+
551
+ // ── Main screen: deferred width repaint ───────────────────────────────
552
+ // A width change reflows the previously-rendered frame; a diffed write
553
+ // would leave that reflowed frame stacked behind the new one — GNOME
554
+ // Terminal keeps it in scrollback on a grow and re-exposes it. But this
555
+ // frame was still built from React state that may predate the resize, and
556
+ // <Static> never re-emits flushed content on its own. So: suppress frames
557
+ // until the caller has re-rendered with a fresh <Static> (its re-flushed
558
+ // content arrives as staticOutput), then clear scrollback and write that
559
+ // frame atomically. Skip while a clear is pending: that path owns the next
560
+ // authoritative frame and already repaints from a clean baseline.
561
+ if (widthChanged && !clearPending && !widthRepaintPending) {
562
+ armWidthRepaint("widthChanged");
563
+ }
564
+
565
+ if (widthRepaintPending && !clearPending) {
566
+ widthRepaintSuppressedFrames += 1;
567
+
568
+ // Phase 1: wait for a commit whose layout matches the new terminal
569
+ // width (the viewport hook commits dimensions on a trailing settle),
570
+ // then request the <Static> re-flush exactly once.
571
+ const renderedLayoutCols = getRenderedLayoutCols?.();
572
+ const layoutReady = getRenderedLayoutCols === undefined
573
+ || renderedLayoutCols === currentCols;
574
+ if (!widthRepaintReflushRequested) {
575
+ if (layoutReady && widthRepaintSuppressedFrames <= WIDTH_REPAINT_MAX_SUPPRESSED_FRAMES) {
576
+ widthRepaintReflushRequested = true;
577
+ widthRepaintTargetGeneration = getRenderedRepaintGeneration
578
+ ? getRenderedRepaintGeneration() + 1
579
+ : null;
580
+ renderDebug.traceEvent("terminal", "widthRepaintReflushRequested", {
581
+ source,
582
+ currentCols,
583
+ suppressedFrames: widthRepaintSuppressedFrames,
584
+ targetGeneration: widthRepaintTargetGeneration,
585
+ });
586
+ onWidthResizeRefresh?.();
587
+ // This frame still carries the pre-remount static state; suppress it
588
+ // and commit the re-flushed frame the bump produces.
589
+ if (getRenderedRepaintGeneration !== undefined) {
590
+ return;
591
+ }
592
+ }
593
+ }
594
+
595
+ // Phase 2: commit the first frame that carries the re-flushed static
596
+ // content from the post-bump render.
597
+ const repaintGenerationRendered = widthRepaintTargetGeneration === null
598
+ || (getRenderedRepaintGeneration?.() ?? 0) >= widthRepaintTargetGeneration;
599
+ const repaintFrameReady = staticOutput !== ""
600
+ && repaintGenerationRendered
601
+ && widthRepaintReflushRequested;
602
+ if (repaintFrameReady) {
603
+ widthRepaintPending = false;
604
+ widthRepaintTargetGeneration = null;
605
+ widthRepaintReflushRequested = false;
606
+ commitAuthoritativeFrame(output, outputHeight, staticOutput, "transcript", "resizeRefresh");
607
+ renderDebug.traceEvent("terminal", "resizeRefreshCommitted", {
608
+ committedGeneration,
609
+ currentCols,
610
+ resizeRefreshConsumed: true,
611
+ ...countFrameMarkers(`${staticOutput}${output}`),
612
+ });
613
+ return;
614
+ }
615
+ if (widthRepaintSuppressedFrames <= WIDTH_REPAINT_MAX_SUPPRESSED_FRAMES) {
616
+ renderDebug.traceEvent("terminal", "widthRepaintFrameSuppressed", {
617
+ source,
618
+ suppressedFrames: widthRepaintSuppressedFrames,
619
+ currentCols,
620
+ layoutReady,
621
+ reflushRequested: widthRepaintReflushRequested,
622
+ });
623
+ return;
624
+ }
625
+ // Safety valve: never freeze the UI behind the gate. Repaint with the
626
+ // static content Ink has accumulated, even if it predates the resize.
627
+ widthRepaintPending = false;
628
+ widthRepaintTargetGeneration = null;
629
+ widthRepaintReflushRequested = false;
630
+ commitAuthoritativeFrame(
631
+ output,
632
+ outputHeight,
633
+ staticOutput || (instance.fullStaticOutput ?? ""),
634
+ "transcript",
635
+ "resizeRefreshFallback",
636
+ );
637
+ return;
638
+ }
639
+
640
+ const frameClassification = isFirstPostClearCommit
641
+ ? "post-clear"
642
+ : (clearPending ? "pre-clear" : "normal");
643
+ const effectiveStaticOutput = isFirstPostClearCommit && !staticOutput && suppressedPostClearStaticOutput
644
+ ? suppressedPostClearStaticOutput
645
+ : staticOutput;
646
+ const frameText = isFirstPostClearCommit
647
+ ? `${effectiveStaticOutput}${output}`
648
+ : buildFrameText(instance, output, staticOutput);
649
+ const markerCounts = countFrameMarkers(frameText);
650
+ const frameHash = stableFrameHash(frameText);
651
+ const clearGenerationUsed = isFirstPostClearCommit ? pendingGeneration : committedGeneration;
652
+ const before = snapshotFrameState(instance, logShadow);
653
+
654
+ renderDebug.traceEvent("terminal", "clearBoundaryFrame", {
655
+ clearPending,
656
+ pendingGeneration,
657
+ committedGeneration,
658
+ renderGeneration,
659
+ transcriptCleared,
660
+ staticEventsLength,
661
+ activeEventsLength,
662
+ uiStateKind,
663
+ frameClassification,
664
+ isPostClearFrame: postClearReady,
665
+ staleFrameSuppressed,
666
+ frameWriteAllowed: !staleFrameSuppressed,
667
+ widthChanged,
668
+ currentCols,
669
+ currentRows,
670
+ clearGenerationUsed,
671
+ frameLength: frameText.length,
672
+ frameHash,
673
+ ...markerCounts,
674
+ ...before,
675
+ previousLineCountBefore: before.logPreviousLineCount,
676
+ lastOutputHeightBefore: before.lastOutputHeight,
677
+ physicalClearImmediatelyBeforeFrame: isFirstPostClearCommit,
678
+ });
679
+
680
+ if (staleFrameSuppressed) {
681
+ if (staticOutput) {
682
+ suppressedPostClearStaticOutput = staticOutput;
683
+ }
684
+ return;
685
+ }
686
+
687
+ if (isFirstPostClearCommit) {
688
+ commitAuthoritativeFrame(output, outputHeight, effectiveStaticOutput, "transcript", "firstPostClearFrame");
689
+ renderDebug.traceEvent("terminal", "clearBoundaryCommit", {
690
+ clearGeneration: pendingGeneration,
691
+ physicalTerminalClearEmitted: true,
692
+ before,
693
+ afterReset: snapshotFrameState(instance, logShadow),
694
+ });
695
+ } else {
696
+ lastFrameWasAuthoritative = false;
697
+ boundOriginal(output, outputHeight, effectiveStaticOutput);
698
+ }
699
+
700
+ const after = snapshotFrameState(instance, logShadow);
701
+ renderDebug.traceEvent("terminal", "clearBoundaryFrameCommitted", {
702
+ clearPending,
703
+ pendingGeneration,
704
+ committedGeneration,
705
+ renderGeneration,
706
+ transcriptCleared,
707
+ frameClassification: isFirstPostClearCommit ? "post-clear-authoritative" : frameClassification,
708
+ frameLength: frameText.length,
709
+ frameHash,
710
+ ...markerCounts,
711
+ ...after,
712
+ diffedFrame: !isFirstPostClearCommit,
713
+ fullAuthoritativeFrame: isFirstPostClearCommit,
714
+ widthChanged,
715
+ currentCols,
716
+ currentRows,
717
+ clearGenerationUsed,
718
+ previousLineCountAfter: after.logPreviousLineCount,
719
+ lastOutputHeightAfter: after.lastOutputHeight,
720
+ physicalClearImmediatelyBeforeFrame: isFirstPostClearCommit,
721
+ });
722
+
723
+ if (isFirstPostClearCommit) {
724
+ committedGeneration = pendingGeneration;
725
+ clearPending = false;
726
+ pendingGeneration = null;
727
+ suppressedPostClearStaticOutput = "";
728
+ renderDebug.traceEvent("terminal", "firstCommittedPostClearFrame", {
729
+ committedGeneration,
730
+ frameHash,
731
+ firstFrameAuthoritative: true,
732
+ ...markerCounts,
733
+ });
734
+ }
735
+ };
736
+
737
+ renderDebug.traceEvent("terminal", "clearBoundaryInit", {
738
+ source,
739
+ liveInkInstanceResolved: true,
740
+ renderInteractiveFrameResolved: true,
741
+ });
742
+
743
+ return {
744
+ beginClearGeneration(generation) {
745
+ if (!Number.isFinite(generation)) return false;
746
+ clearPending = true;
747
+ pendingGeneration = generation;
748
+ suppressedPostClearStaticOutput = "";
749
+ // The post-clear frame repaints from a physically cleared baseline, which
750
+ // supersedes any in-flight width repaint.
751
+ widthRepaintPending = false;
752
+ renderDebug.traceEvent("terminal", "clearBoundaryBegin", {
753
+ clearGeneration: generation,
754
+ clearPending,
755
+ renderGeneration,
756
+ transcriptCleared,
757
+ staticEventsLength,
758
+ activeEventsLength,
759
+ uiStateKind,
760
+ before: snapshotFrameState(instance, logShadow),
761
+ });
762
+ return true;
763
+ },
764
+ syncRenderState(state) {
765
+ if (!Number.isFinite(state.generation)) return false;
766
+ renderGeneration = state.generation;
767
+ transcriptCleared = state.transcriptCleared;
768
+ clearGenerationReady = state.clearGenerationReady === true;
769
+ staticEventsLength = state.staticEventsLength;
770
+ activeEventsLength = state.activeEventsLength;
771
+ uiStateKind = state.uiStateKind;
772
+ // Ink writes a frame during the React commit (resetAfterCommit), which runs
773
+ // BEFORE the passive effect that calls this. So the cleared frame's
774
+ // renderInteractiveFrame already ran (and was suppressed) against the stale
775
+ // gate. When the gate is now satisfiable, signal the host to force exactly
776
+ // one more render so the authoritative post-clear frame is flushed.
777
+ const postClearRepaintPending = clearPending && isPostClearFrameReady();
778
+ renderDebug.traceEvent("terminal", "clearBoundaryRenderState", {
779
+ renderGeneration,
780
+ transcriptCleared,
781
+ clearGenerationReady,
782
+ staticEventsLength,
783
+ activeEventsLength,
784
+ uiStateKind,
785
+ clearPending,
786
+ pendingGeneration,
787
+ committedGeneration,
788
+ postClearRepaintPending,
789
+ });
790
+ return postClearRepaintPending;
791
+ },
792
+ getState() {
793
+ return {
794
+ clearPending,
795
+ pendingGeneration,
796
+ committedGeneration,
797
+ renderGeneration,
798
+ transcriptCleared,
799
+ lastFrameWasAuthoritative,
800
+ widthRepaintPending,
801
+ overlayActive,
802
+ logShadow: { ...logShadow },
803
+ };
804
+ },
805
+ dispose() {
806
+ if (overlayActive) {
807
+ setAlternateScreen(false, "dispose");
808
+ overlayActive = false;
809
+ }
810
+ instance.renderInteractiveFrame = originalRenderInteractiveFrame;
811
+ restoreLog();
812
+ },
813
+ };
814
+ }