@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
@@ -198,9 +198,14 @@ export function splitTextAtColumn(text: string, column: number): { before: strin
198
198
 
199
199
  // ─── Text wrapping ────────────────────────────────────────────────────────────
200
200
 
201
- export function wrapTextRows(text: string, maxWidth: number): WrappedTextRow[] {
201
+ export function wrapTextRows(
202
+ text: string,
203
+ maxWidth: number,
204
+ firstLineWidth: number = maxWidth,
205
+ ): WrappedTextRow[] {
202
206
  const normalized = normalizeLineBreaks(text);
203
207
  const safeWidth = Math.max(1, maxWidth);
208
+ const safeFirstWidth = Math.max(1, firstLineWidth);
204
209
  const rows: WrappedTextRow[] = [];
205
210
  let rowStart = 0;
206
211
  let rowText = "";
@@ -220,7 +225,9 @@ export function wrapTextRows(text: string, maxWidth: number): WrappedTextRow[] {
220
225
  continue;
221
226
  }
222
227
 
223
- if (rowText.length > 0 && rowWidth + unit.width > safeWidth) {
228
+ // Only the first emitted row honors firstLineWidth; later rows use maxWidth.
229
+ const limit = rows.length === 0 ? safeFirstWidth : safeWidth;
230
+ if (rowText.length > 0 && rowWidth + unit.width > limit) {
224
231
  rows.push({
225
232
  text: rowText,
226
233
  start: rowStart,
@@ -252,8 +259,12 @@ export function wrapTextRows(text: string, maxWidth: number): WrappedTextRow[] {
252
259
  }];
253
260
  }
254
261
 
255
- export function wrapPlainText(text: string, maxWidth: number): string[] {
256
- return wrapTextRows(text, maxWidth).map((row) => row.text);
262
+ export function wrapPlainText(
263
+ text: string,
264
+ maxWidth: number,
265
+ firstLineWidth: number = maxWidth,
266
+ ): string[] {
267
+ return wrapTextRows(text, maxWidth, firstLineWidth).map((row) => row.text);
257
268
  }
258
269
 
259
270
  export function wrapCommandText(text: string, maxWidth: number): string[] {
@@ -24,8 +24,8 @@ import {
24
24
  import { selectVisibleRunActivity } from "./runActivityView.js";
25
25
  import { getTextUnits, getTextWidth, wrapPlainText, wrapCommandText, splitTextAtColumn } from "./textLayout.js";
26
26
  import type { RenderTimelineItem } from "./Timeline.js";
27
- import { normalizePlanReviewMarkdown } from "../core/planStorage.js";
28
- import { selectLogoVariant, LOGO_LARGE_MIN_COLS } from "./logoVariants.js";
27
+ import { normalizePlanReviewMarkdown } from "../core/workspace/planStorage.js";
28
+ import { LOGO_COMPACT, LOGO_COMPACT_MIN_COLS, LOGO_LARGE_MIN_COLS, selectLogoVariant } from "./logoVariants.js";
29
29
 
30
30
  // ─── Exported types ───────────────────────────────────────────────────────────
31
31
 
@@ -172,6 +172,37 @@ function padSpansToWidth(spans: TimelineRowSpan[], width: number): TimelineRowSp
172
172
  return next;
173
173
  }
174
174
 
175
+ /**
176
+ * Truncates a span list to at most `width` display columns, cutting the
177
+ * overflowing span on a grapheme/display-width boundary. Used as a safety net so
178
+ * a too-wide content row can never push a card border past its declared width.
179
+ */
180
+ function clampSpansToWidth(spans: TimelineRowSpan[], width: number): TimelineRowSpan[] {
181
+ const safeWidth = Math.max(0, width);
182
+ const result: TimelineRowSpan[] = [];
183
+ let used = 0;
184
+ for (const span of spans) {
185
+ if (used >= safeWidth) break;
186
+ const spanWidth = getTextWidth(span.text);
187
+ if (used + spanWidth <= safeWidth) {
188
+ result.push({ ...span });
189
+ used += spanWidth;
190
+ continue;
191
+ }
192
+ const fitted = splitTextAtColumn(span.text, safeWidth - used).before;
193
+ if (fitted) {
194
+ result.push(cloneSpan(span, fitted));
195
+ }
196
+ break;
197
+ }
198
+ return result;
199
+ }
200
+
201
+ /** Clamp a row to `width` then pad it back out so it occupies exactly `width`. */
202
+ function fitSpansToWidth(spans: TimelineRowSpan[], width: number): TimelineRowSpan[] {
203
+ return padSpansToWidth(clampSpansToWidth(spans, width), width);
204
+ }
205
+
175
206
  const ROW_CONTENT_CACHE_LIMIT = 2500;
176
207
  const _rowContentCache = new Map<string, TimelineRow>();
177
208
 
@@ -432,14 +463,14 @@ function buildDashCardRows(params: {
432
463
  return { ...span, tone: borderTone };
433
464
  });
434
465
 
435
- const rows: TimelineRow[] = [createRow(`${params.keyPrefix}-top`, topRow, width)];
466
+ const rows: TimelineRow[] = [createRow(`${params.keyPrefix}-top`, fitSpansToWidth(topRow, width), width)];
436
467
 
437
468
  params.contentRows.forEach((row, index) => {
438
469
  rows.push(createRow(
439
470
  `${params.keyPrefix}-content-${index}`,
440
471
  [
441
472
  createSpan("│ ", borderTone),
442
- ...padSpansToWidth(row, contentWidth),
473
+ ...fitSpansToWidth(row, contentWidth),
443
474
  createSpan(" │", borderTone),
444
475
  ],
445
476
  width,
@@ -486,7 +517,7 @@ function buildPanelRows(params: {
486
517
  `${params.keyPrefix}-content-${index}`,
487
518
  [
488
519
  createSpan("│ ", "borderActive"),
489
- ...padSpansToWidth(row, contentWidth),
520
+ ...fitSpansToWidth(row, contentWidth),
490
521
  createSpan(" │", "borderActive"),
491
522
  ],
492
523
  width,
@@ -1537,18 +1568,19 @@ export function buildStandaloneEventRows(item: Extract<RenderTimelineItem, { typ
1537
1568
  return rows;
1538
1569
  }
1539
1570
 
1540
- export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro" }>, width: number): TimelineRow[] {
1571
+ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro" }>, width: number): TimelineRow[] {
1541
1572
  const rows: TimelineRow[] = [];
1542
1573
  const { intro } = item;
1543
1574
  const safeWidth = Math.max(10, width);
1544
1575
  const startupHeaderMode = intro.startupHeaderMode
1545
- ?? (intro.layoutMode === "full" ? "large" : "compact");
1546
- if (startupHeaderMode === "tiny") {
1547
- const messageRows = [
1548
- "Codexa",
1549
- "Terminal is too small for the startup view.",
1550
- "Resize the terminal to continue.",
1551
- ];
1576
+ ?? (intro.layoutMode === "expanded" ? "large" : "compact");
1577
+ const workspaceName = getWorkspaceDisplayName(intro.workspaceLabel);
1578
+ if (startupHeaderMode === "tiny") {
1579
+ const messageRows = [
1580
+ `Codexa v${intro.version}`,
1581
+ workspaceName ? `Workspace: ${workspaceName}` : null,
1582
+ intro.providerLabel ? `Provider: ${intro.providerLabel}` : `Auth: ${intro.authLabel}`,
1583
+ ].filter((line): line is string => Boolean(line));
1552
1584
  messageRows.forEach((line, index) => {
1553
1585
  rows.push(createRow(
1554
1586
  `${item.key}-resize-${index}`,
@@ -1559,16 +1591,20 @@ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
1559
1591
  return rows;
1560
1592
  }
1561
1593
 
1562
- const logoRows = startupHeaderMode === "large"
1563
- ? selectLogoVariant(safeWidth)
1564
- : selectLogoVariant(0); // text-only fallback for compact mode
1565
- const effectiveLogoRows = logoRows.length > 0 ? logoRows : ["CODEXA"];
1594
+ // Compact startup mode deliberately uses the one-line mark even when the
1595
+ // terminal is wide: its row budget is what made the full logo unsafe.
1596
+ const logoRows = startupHeaderMode === "large"
1597
+ ? selectLogoVariant(safeWidth)
1598
+ : safeWidth >= LOGO_COMPACT_MIN_COLS ? LOGO_COMPACT : [];
1599
+ const effectiveLogoRows = logoRows.length > 0 ? logoRows : ["CODEXA"];
1600
+ if (startupHeaderMode === "large") {
1601
+ rows.push(createBlankRow(`${item.key}-top-gap`, safeWidth));
1602
+ }
1566
1603
  const logoWidth = effectiveLogoRows.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
1567
- const workspaceName = getWorkspaceDisplayName(intro.workspaceLabel);
1568
- const metaLines = [
1604
+ const metaLines = [
1569
1605
  `Codexa v${intro.version}`,
1570
- `Auth: ${intro.authLabel}`,
1571
1606
  workspaceName ? `Workspace: ${workspaceName}` : null,
1607
+ intro.providerLabel ? `Provider: ${intro.providerLabel}` : `Auth: ${intro.authLabel}`,
1572
1608
  ].filter((line): line is string => Boolean(line));
1573
1609
  const gapWidth = 2;
1574
1610
  const widestMetaLine = metaLines.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
@@ -1591,6 +1627,8 @@ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
1591
1627
  if (effectiveLogoRows.length === 6) {
1592
1628
  if (rowIndex === 2 || rowIndex === 3) logoTone = "logoSecondary";
1593
1629
  else if (rowIndex === 4 || rowIndex === 5) logoTone = "logoShadow";
1630
+ } else if (effectiveLogoRows === LOGO_COMPACT) {
1631
+ logoTone = "accent";
1594
1632
  }
1595
1633
  // No bold on logo spans — bold on block/box-drawing chars causes spacing artifacts.
1596
1634
  const spans = [
@@ -1614,6 +1652,8 @@ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
1614
1652
  if (effectiveLogoRows.length === 6) {
1615
1653
  if (index === 2 || index === 3) logoTone = "logoSecondary";
1616
1654
  else if (index === 4 || index === 5) logoTone = "logoShadow";
1655
+ } else if (effectiveLogoRows === LOGO_COMPACT) {
1656
+ logoTone = "accent";
1617
1657
  }
1618
1658
  rows.push(createRow(
1619
1659
  `${item.key}-logo-${index}`,
@@ -1694,7 +1734,7 @@ function applyTurnOpacity(rows: TimelineRow[], opacity: "active" | "recent" | "d
1694
1734
  // ─── Stream event types ───────────────────────────────────────────────────────
1695
1735
 
1696
1736
  export type StreamEvent =
1697
- | { kind: "thinking"; streamSeq: number; block: RunProgressBlock; isActive: boolean }
1737
+ | { kind: "thinking"; streamSeq: number; block: RunProgressBlock }
1698
1738
  | { kind: "response"; streamSeq: number; segment: RunResponseSegment }
1699
1739
  | { kind: "action"; streamSeq: number; tool: RunToolActivity }
1700
1740
  | { kind: "actionSummary"; streamSeq: number; id: string; label: string; count: number }
@@ -1711,8 +1751,24 @@ function getCompactableActionLabel(event: StreamEvent): string | null {
1711
1751
  return label === "Read file" || label === "List files" ? label : null;
1712
1752
  }
1713
1753
 
1714
- function compactActionBursts(events: StreamEvent[], verbose: boolean): StreamEvent[] {
1715
- if (verbose) return events;
1754
+ /**
1755
+ * Collapse bursts of same-label completed action cards into a single summary
1756
+ * line. This is a *height-reducing* transform, so it must only run for a
1757
+ * FINISHED turn: applying it while the run is still live shrinks the turn's
1758
+ * total height mid-stream, and the bottom-anchored viewport then re-reveals
1759
+ * earlier (already-scrolled-off) content — the "old states come back" glitch.
1760
+ *
1761
+ * Callers pass `finalized = run.status !== "running"`. We deliberately key off
1762
+ * `run.status` rather than the render phase: `resolveTurnRunPhase` reports
1763
+ * "final" during the ANSWER_VISIBLE window while the run is still running, so a
1764
+ * phase-based gate would compact during that intermediate, still-active frame.
1765
+ */
1766
+ export function compactActionBursts(
1767
+ events: StreamEvent[],
1768
+ verbose: boolean,
1769
+ finalized: boolean,
1770
+ ): StreamEvent[] {
1771
+ if (verbose || !finalized) return events;
1716
1772
 
1717
1773
  const compacted: StreamEvent[] = [];
1718
1774
  for (let index = 0; index < events.length;) {
@@ -1774,13 +1830,11 @@ function buildCodexThinkingRows(params: {
1774
1830
  keyPrefix: string;
1775
1831
  width: number;
1776
1832
  event: Extract<StreamEvent, { kind: "thinking" }>;
1777
- isLive: boolean;
1778
1833
  verbose: boolean;
1779
1834
  }): TimelineRow[] {
1780
1835
  renderDebug.traceRender("ThinkingBlock", params.event.block.status, {
1781
1836
  keyPrefix: params.keyPrefix,
1782
1837
  streamSeq: params.event.streamSeq,
1783
- isLive: params.isLive,
1784
1838
  textLength: params.event.block.text.length,
1785
1839
  });
1786
1840
 
@@ -1794,8 +1848,6 @@ function buildCodexThinkingRows(params: {
1794
1848
  textCacheToken(block.text),
1795
1849
  params.width,
1796
1850
  params.verbose,
1797
- params.isLive,
1798
- params.event.isActive,
1799
1851
  ]);
1800
1852
 
1801
1853
  return getCachedStreamingBlockRows(cacheKey, () => {
@@ -1816,10 +1868,6 @@ function buildCodexThinkingRows(params: {
1816
1868
  ]);
1817
1869
  }
1818
1870
 
1819
- if (params.isLive && params.event.isActive) {
1820
- contentRows.push([createSpan("▌", "accent")]);
1821
- }
1822
-
1823
1871
  return buildCodexPlainRows(params.keyPrefix, params.width, contentRows);
1824
1872
  });
1825
1873
  }
@@ -1849,10 +1897,15 @@ function getActionDisplayDescriptor(params: {
1849
1897
  isLive: boolean;
1850
1898
  borderTone: TimelineTone;
1851
1899
  }): ActionDisplayDescriptor {
1852
- const command = normalizeCommand(params.tool.command);
1900
+ // Strip ANSI/control sequences before measuring or wrapping: string-width only
1901
+ // collapses *complete* escape sequences, so leftover bytes would otherwise be
1902
+ // counted (and wrapped) character-by-character and corrupt the card width.
1903
+ const command = normalizeCommand(sanitizeTerminalOutput(params.tool.command));
1853
1904
  const label = getFriendlyActionLabel(command);
1905
+ // Bare label (no leading gap) — the head-row builder right-aligns it and owns
1906
+ // the spacing, so the gap can never get baked into a width calculation.
1854
1907
  const duration = params.tool.completedAt != null
1855
- ? ` ${formatDuration(params.tool.completedAt - params.tool.startedAt)}`
1908
+ ? formatDuration(params.tool.completedAt - params.tool.startedAt)
1856
1909
  : "";
1857
1910
  const summary = params.verbose ? params.tool.summary ?? "" : "";
1858
1911
  const showLiveCursor = params.isLive && params.tool.status === "running";
@@ -1879,7 +1932,7 @@ function getActionDisplayDescriptor(params: {
1879
1932
  return descriptor;
1880
1933
  }
1881
1934
 
1882
- function buildPlainActionDebugRows(params: {
1935
+ function buildPlainActionDebugRows(params: {
1883
1936
  keyPrefix: string;
1884
1937
  width: number;
1885
1938
  descriptor: ActionDisplayDescriptor;
@@ -1887,7 +1940,7 @@ function buildPlainActionDebugRows(params: {
1887
1940
  const statusText = params.descriptor.label
1888
1941
  ? `${params.descriptor.label}: ${params.descriptor.command}`
1889
1942
  : params.descriptor.command;
1890
- const suffix = params.descriptor.duration ? params.descriptor.duration : "";
1943
+ const suffix = params.descriptor.duration ? ` ${params.descriptor.duration}` : "";
1891
1944
  const text = clampVisualText(`${params.descriptor.icon} ${statusText}${suffix}`, Math.max(1, params.width - 1));
1892
1945
  renderDebug.traceEvent("action", "plainActionRow", {
1893
1946
  actionId: params.descriptor.id,
@@ -1903,10 +1956,56 @@ function buildPlainActionDebugRows(params: {
1903
1956
  ],
1904
1957
  params.width,
1905
1958
  ),
1906
- ];
1907
- }
1908
-
1909
- export function buildActionEventRows(params: {
1959
+ ];
1960
+ }
1961
+
1962
+ function compactActionText(descriptor: ActionDisplayDescriptor): string {
1963
+ const command = descriptor.command.replace(/^([a-z][a-z0-9_]*):\s+/i, "$1 ");
1964
+ return descriptor.label && command === descriptor.command
1965
+ ? `${descriptor.label} ${command}`
1966
+ : command;
1967
+ }
1968
+
1969
+ function buildCompactActionRows(params: {
1970
+ keyPrefix: string;
1971
+ width: number;
1972
+ descriptor: ActionDisplayDescriptor;
1973
+ }): TimelineRow[] {
1974
+ const durationSuffix = params.descriptor.duration ? ` ${params.descriptor.duration}` : "";
1975
+ const liveSuffix = params.descriptor.showLiveCursor ? " ▌" : "";
1976
+ const availableWidth = Math.max(1, params.width - getTextWidth(params.descriptor.icon) - 1 - getTextWidth(durationSuffix) - getTextWidth(liveSuffix));
1977
+ const text = clampVisualText(compactActionText(params.descriptor), availableWidth);
1978
+ const rows: TimelineRow[] = [
1979
+ createRow(
1980
+ `${params.keyPrefix}-plain`,
1981
+ [
1982
+ createSpan(`${params.descriptor.icon} `, params.descriptor.iconTone),
1983
+ createSpan(text || " ", "text"),
1984
+ ...(durationSuffix ? [createSpan(durationSuffix, "dim")] : []),
1985
+ ...(liveSuffix ? [createSpan(liveSuffix, "accent")] : []),
1986
+ ],
1987
+ params.width,
1988
+ ),
1989
+ ];
1990
+
1991
+ if (params.descriptor.verbose) {
1992
+ const detail = params.descriptor.showLiveCursor
1993
+ ? "running"
1994
+ : params.descriptor.summary.trim() || "completed";
1995
+ rows.push(createRow(
1996
+ `${params.keyPrefix}-detail`,
1997
+ [
1998
+ createSpan(" "),
1999
+ createSpan(clampVisualText(detail, Math.max(1, params.width - 2)), "muted"),
2000
+ ],
2001
+ params.width,
2002
+ ));
2003
+ }
2004
+
2005
+ return rows;
2006
+ }
2007
+
2008
+ export function buildActionEventRows(params: {
1910
2009
  keyPrefix: string;
1911
2010
  width: number;
1912
2011
  event: Extract<StreamEvent, { kind: "action" }>;
@@ -1977,58 +2076,11 @@ export function buildActionEventRows(params: {
1977
2076
  });
1978
2077
  }
1979
2078
 
1980
- const buildActionRows = () => {
1981
- const contentWidth = Math.max(1, params.width - 4);
1982
- const commandWidth = Math.max(1, contentWidth - 2);
1983
- const detailText = descriptor.showLiveCursor
1984
- ? "▌"
1985
- : descriptor.summary.trim()
1986
- ? descriptor.summary
1987
- : " ";
1988
- const contentRows: TimelineRowSpan[][] = [];
1989
-
1990
- if (descriptor.label) {
1991
- contentRows.push([
1992
- createSpan(`${descriptor.icon} `, descriptor.iconTone),
1993
- createSpan(descriptor.label, "text"),
1994
- ...(descriptor.duration ? [createSpan(descriptor.duration, "dim")] : []),
1995
- ]);
1996
- wrapPlainText(descriptor.command, commandWidth).forEach((row) => {
1997
- contentRows.push([
1998
- createSpan(" "),
1999
- createSpan(row || " ", "muted"),
2000
- ]);
2001
- });
2002
- } else {
2003
- const headRows = wrapPlainText(descriptor.command, commandWidth);
2004
- headRows.forEach((row, rowIndex) => {
2005
- contentRows.push([
2006
- createSpan(rowIndex === 0 ? `${descriptor.icon} ` : " ", rowIndex === 0 ? descriptor.iconTone : undefined),
2007
- createSpan(row || " ", "text"),
2008
- ...(rowIndex === 0 && descriptor.duration ? [createSpan(descriptor.duration, "dim")] : []),
2009
- ]);
2010
- });
2011
- }
2012
-
2013
- if (!descriptor.label) {
2014
- contentRows.push([
2015
- createSpan(" "),
2016
- createSpan(" ", "muted"),
2017
- ]);
2018
- }
2019
- contentRows.push([
2020
- createSpan(" "),
2021
- createSpan(detailText, descriptor.showLiveCursor ? "accent" : "muted"),
2022
- ]);
2023
-
2024
- return buildDashCardRows({
2025
- keyPrefix: params.keyPrefix,
2026
- width: params.width,
2027
- title: "action",
2028
- borderTone: descriptor.borderTone,
2029
- contentRows: contentRows.length > 0 ? contentRows : [[createSpan(" ")]],
2030
- });
2031
- };
2079
+ const buildActionRows = () => buildCompactActionRows({
2080
+ keyPrefix: params.keyPrefix,
2081
+ width: params.width,
2082
+ descriptor,
2083
+ });
2032
2084
 
2033
2085
  if (isCompleted) {
2034
2086
  const rows = buildActionRows();
@@ -2178,13 +2230,11 @@ function buildApprovedPlanRows(params: {
2178
2230
 
2179
2231
  function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number, options: { verbose?: boolean; workspaceRoot?: string | null }): TimelineRow[] {
2180
2232
  const run = item.item.run!;
2181
- const assistant = item.item.assistant;
2182
2233
  const streaming = item.renderState.runPhase === "streaming";
2183
- const dim = item.renderState.opacity !== "active";
2184
- const borderTone = dim ? "borderSubtle" : streaming ? "borderActive" : "borderSubtle";
2185
2234
  const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
2186
2235
  const verbose = options.verbose ?? false;
2187
- const events = compactActionBursts(collectStreamEvents(item, streaming), verbose);
2236
+ const finalized = run.status !== "running";
2237
+ const events = compactActionBursts(collectStreamEvents(item), verbose, finalized);
2188
2238
 
2189
2239
  const rows: TimelineRow[] = [];
2190
2240
 
@@ -2193,7 +2243,9 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2193
2243
  const isLive = run.status === "running" && isLastEvent; // The cursor is on the last event
2194
2244
 
2195
2245
  if (index > 0) {
2196
- rows.push(createBlankRow(`${item.key}-stream-gap-${index}`, width));
2246
+ // Key the gap by the stable creation-order streamSeq, not the array
2247
+ // index, so gaps don't remount/reorder when the event set changes.
2248
+ rows.push(createBlankRow(`${item.key}-stream-gap-${event.streamSeq}`, width));
2197
2249
  }
2198
2250
 
2199
2251
  if (event.kind === "thinking") {
@@ -2201,7 +2253,6 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2201
2253
  keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2202
2254
  width,
2203
2255
  event,
2204
- isLive,
2205
2256
  verbose,
2206
2257
  }));
2207
2258
  } else if (event.kind === "action") {
@@ -2242,7 +2293,7 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2242
2293
  }
2243
2294
  });
2244
2295
 
2245
- if (!streaming && run.status !== "running") {
2296
+ if (!streaming && finalized) {
2246
2297
  if (run.status === "canceled") {
2247
2298
  rows.push(createBlankRow(`${item.key}-cancel-gap`, width));
2248
2299
  rows.push(...buildCodexPlainRows(
@@ -2281,12 +2332,10 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2281
2332
  return rows;
2282
2333
  }
2283
2334
 
2284
- function collectStreamEvents(
2285
- item: Extract<RenderTimelineItem, { type: "turn" }>,
2286
- streaming: boolean,
2287
- ): StreamEvent[] {
2335
+ function collectStreamEvents(item: Extract<RenderTimelineItem, { type: "turn" }>): StreamEvent[] {
2288
2336
  const run = item.item.run!;
2289
2337
  const assistant = item.item.assistant;
2338
+ const streaming = item.renderState.runPhase === "streaming";
2290
2339
  const blocksById = new Map<string, RunProgressBlock>();
2291
2340
  for (const entry of run.progressEntries ?? []) {
2292
2341
  for (const block of entry.blocks) blocksById.set(block.id, block);
@@ -2298,14 +2347,23 @@ function collectStreamEvents(
2298
2347
  const sortedItems = (run.streamItems ?? []).slice().sort((a, b) => a.streamSeq - b.streamSeq);
2299
2348
  for (const it of sortedItems) {
2300
2349
  if (it.kind === "thinking") {
2301
- const block = blocksById.get(it.refId);
2302
- if (block && block.text.trim().length > 0 && !(run.status === "running" && block.status === "active")) {
2303
- events.push({
2304
- kind: "thinking",
2305
- streamSeq: it.streamSeq,
2306
- block,
2307
- isActive: run.status === "running" && block.status === "active",
2308
- });
2350
+ // Active-turn topology stability: while the run is live we never surface
2351
+ // reasoning blocks. A thinking block is assigned its streamSeq early (when
2352
+ // its reasoning first streams) but only *completes* later — revealing it
2353
+ // mid-stream slots it in at that early streamSeq, ABOVE answer/action
2354
+ // blocks that have already streamed at higher streamSeqs. That late
2355
+ // insert-above is what reorders the live turn. Defer all reasoning to
2356
+ // finalize, where the full streamSeq order (reasoning included) reflows
2357
+ // atomically. (Height grows when it appears — never shrinks mid-stream.)
2358
+ if (run.status !== "running") {
2359
+ const block = blocksById.get(it.refId);
2360
+ if (block && block.text.trim().length > 0) {
2361
+ events.push({
2362
+ kind: "thinking",
2363
+ streamSeq: it.streamSeq,
2364
+ block,
2365
+ });
2366
+ }
2309
2367
  }
2310
2368
  } else if (it.kind === "action") {
2311
2369
  const tool = toolsById.get(it.refId);
@@ -2336,13 +2394,15 @@ function collectStreamEvents(
2336
2394
  if (!VISIBLE_THINKING_SOURCES.has(entry.source)) continue;
2337
2395
  for (const block of entry.blocks) {
2338
2396
  if (!block.text.trim()) continue;
2339
- if (run.status === "running" && block.status === "active") continue;
2397
+ // Same active-turn topology rule as the streamItems path: defer all
2398
+ // reasoning while the run is live so it cannot insert above already
2399
+ // streamed answer/action blocks. Reveal only once finalized.
2400
+ if (run.status === "running") continue;
2340
2401
  legacySeq += 1;
2341
2402
  events.push({
2342
2403
  kind: "thinking",
2343
2404
  streamSeq: legacySeq,
2344
2405
  block,
2345
- isActive: run.status === "running" && block.status === "active",
2346
2406
  });
2347
2407
  }
2348
2408
  }
@@ -2477,8 +2537,10 @@ function buildStableIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
2477
2537
  innerWidth,
2478
2538
  item.intro.version,
2479
2539
  item.intro.layoutMode,
2540
+ item.intro.startupHeaderMode ?? "",
2480
2541
  item.intro.authLabel,
2481
2542
  textCacheToken(item.intro.workspaceLabel),
2543
+ item.intro.providerLabel ?? "",
2482
2544
  ]);
2483
2545
  return getCachedFrozenRows(cacheKey, () => buildIntroRows(item, innerWidth));
2484
2546
  }
@@ -2552,10 +2614,9 @@ function buildStableActiveTurnGroups(
2552
2614
  }
2553
2615
 
2554
2616
  const streaming = item.renderState.runPhase === "streaming";
2555
- const dim = item.renderState.opacity !== "active";
2556
- const borderTone = dim ? "borderSubtle" : streaming ? "borderActive" : "borderSubtle";
2557
2617
  const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
2558
- const events = compactActionBursts(collectStreamEvents(item, streaming), verbose);
2618
+ const finalized = run.status !== "running";
2619
+ const events = compactActionBursts(collectStreamEvents(item), verbose, finalized);
2559
2620
  let orderedRows = [...getCachedFrozenRows(rowCacheKey([
2560
2621
  "stable-active-user",
2561
2622
  item.key,
@@ -2571,7 +2632,9 @@ function buildStableActiveTurnGroups(
2571
2632
  const isLastEvent = index === events.length - 1;
2572
2633
 
2573
2634
  if (index > 0) {
2574
- targetRows.push(createBlankRow(`${item.key}-stream-gap-${index}`, innerWidth));
2635
+ // Stable creation-order key (streamSeq), not array index — matches the
2636
+ // native path and avoids index-based remount when events change.
2637
+ targetRows.push(createBlankRow(`${item.key}-stream-gap-${event.streamSeq}`, innerWidth));
2575
2638
  }
2576
2639
 
2577
2640
  if (event.kind === "thinking") {
@@ -2579,7 +2642,6 @@ function buildStableActiveTurnGroups(
2579
2642
  keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2580
2643
  width: innerWidth,
2581
2644
  event,
2582
- isLive: liveEvent,
2583
2645
  verbose,
2584
2646
  });
2585
2647
  targetRows.push(...(liveEvent ? build() : getCachedFrozenRows(rowCacheKey([
@@ -2708,7 +2770,6 @@ function buildNativeStreamEventRows(params: {
2708
2770
  keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2709
2771
  width: innerWidth,
2710
2772
  event,
2711
- isLive: !params.forceStable && isNativeLiveStreamEvent(event, run),
2712
2773
  verbose,
2713
2774
  }));
2714
2775
  } else if (event.kind === "action") {
@@ -2795,8 +2856,9 @@ function appendNativeTurnParts(
2795
2856
  if (!run) return;
2796
2857
 
2797
2858
  const events = compactActionBursts(
2798
- collectStreamEvents(item, item.renderState.runPhase === "streaming"),
2859
+ collectStreamEvents(item),
2799
2860
  verbose,
2861
+ run.status !== "running",
2800
2862
  );
2801
2863
 
2802
2864
  events.forEach((event, eventIndex) => {
@@ -2975,7 +3037,7 @@ export function buildTimelineSnapshot(
2975
3037
  let builtRows: TimelineRow[];
2976
3038
 
2977
3039
  if (item.type === "intro") {
2978
- const cacheKey = `i:${item.key}:${innerWidth}:${item.intro.version}:${item.intro.layoutMode}:${item.intro.startupHeaderMode ?? ""}:${item.intro.authLabel}:${item.intro.workspaceLabel}:v${LOGO_LARGE_MIN_COLS}`;
3040
+ const cacheKey = `i:${item.key}:${innerWidth}:${item.intro.version}:${item.intro.layoutMode}:${item.intro.startupHeaderMode ?? ""}:${item.intro.authLabel}:${item.intro.workspaceLabel}:${item.intro.providerLabel ?? ""}:v${LOGO_LARGE_MIN_COLS}`;
2979
3041
  const cached = _staticRowCache.get(cacheKey);
2980
3042
  if (cached) {
2981
3043
  renderDebug.traceEvent("timeline", "rowGeneration", {
@@ -2999,8 +3061,18 @@ export function buildTimelineSnapshot(
2999
3061
  builtRows = r;
3000
3062
  }
3001
3063
  } else if (item.type === "event") {
3002
- // Standalone events (system, error, etc.) are immutable cache by key+width.
3003
- const cacheKey = `e:${item.key}:${innerWidth}`;
3064
+ // Standalone events are immutable for a given event payload, not just id.
3065
+ const cacheKey = rowCacheKey([
3066
+ "event",
3067
+ item.key,
3068
+ item.event.type,
3069
+ item.event.id,
3070
+ innerWidth,
3071
+ textCacheToken("title" in item.event ? item.event.title : item.event.command),
3072
+ textCacheToken("content" in item.event ? item.event.content : item.event.summary ?? ""),
3073
+ "status" in item.event ? item.event.status : "",
3074
+ "durationMs" in item.event ? item.event.durationMs : "",
3075
+ ]);
3004
3076
  const cached = _staticRowCache.get(cacheKey);
3005
3077
  if (cached) {
3006
3078
  renderDebug.traceEvent("timeline", "rowGeneration", {