@golba98/codexa 1.0.2 → 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 (206) hide show
  1. package/README.md +396 -100
  2. package/bin/codexa.js +62 -144
  3. package/package.json +14 -8
  4. package/src/app.tsx +596 -306
  5. package/src/commands/handler.ts +6 -6
  6. package/src/config/buildInfo.ts +2 -2
  7. package/src/config/layeredConfig.ts +1 -1
  8. package/src/config/persistence.ts +10 -0
  9. package/src/config/runtimeConfig.ts +1 -1
  10. package/src/config/settings.ts +8 -16
  11. package/src/config/trustStore.ts +1 -1
  12. package/src/config/updateCheckCache.ts +19 -1
  13. package/src/core/README.md +52 -0
  14. package/src/core/agent/loop.ts +282 -0
  15. package/src/core/agent/protocol.ts +211 -0
  16. package/src/core/agent/tools.ts +414 -0
  17. package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
  18. package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
  19. package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
  20. package/src/core/debug/modelStateDebug.ts +34 -0
  21. package/src/core/executables/antigravityExecutable.ts +48 -0
  22. package/src/core/executables/codexExecutable.ts +1 -0
  23. package/src/core/executables/executableResolver.ts +65 -43
  24. package/src/core/perf/renderDebug.ts +10 -6
  25. package/src/core/process/processValidation.ts +9 -5
  26. package/src/core/providerLauncher/launcher.ts +59 -42
  27. package/src/core/providerLauncher/registry.ts +30 -14
  28. package/src/core/providerLauncher/types.ts +11 -9
  29. package/src/core/providerLauncher/workspaceConfig.ts +41 -26
  30. package/src/core/providerRuntime/anthropic.ts +7 -1
  31. package/src/core/providerRuntime/antigravity.ts +305 -0
  32. package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
  33. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  34. package/src/core/providerRuntime/contextMetadata.ts +12 -24
  35. package/src/core/providerRuntime/local.ts +129 -51
  36. package/src/core/providerRuntime/models.ts +22 -11
  37. package/src/core/providerRuntime/registry.ts +58 -31
  38. package/src/core/providerRuntime/types.ts +19 -14
  39. package/src/core/providers/codexSubprocess.ts +2 -2
  40. package/src/core/providers/types.ts +1 -1
  41. package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
  42. package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
  43. package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
  44. package/src/core/terminal/clearFrameBoundary.ts +814 -0
  45. package/src/core/terminal/frameLock.ts +109 -0
  46. package/src/core/terminal/inkRenderReset.ts +123 -0
  47. package/src/core/terminal/terminalControl.ts +22 -0
  48. package/src/core/terminal/terminalTitle.ts +16 -102
  49. package/src/core/version/channel.ts +23 -0
  50. package/src/core/version/updateCheck.ts +193 -0
  51. package/src/core/{launchContext.ts → workspace/launchContext.ts} +32 -39
  52. package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
  53. package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
  54. package/src/headless/execRunner.ts +2 -2
  55. package/src/index.tsx +43 -89
  56. package/src/session/appSession.ts +10 -7
  57. package/src/session/chatLifecycle.ts +1 -1
  58. package/src/session/liveRenderScheduler.ts +1 -1
  59. package/src/session/types.ts +3 -2
  60. package/src/ui/ActionRequiredBlock.tsx +5 -5
  61. package/src/ui/ActivityBars.tsx +3 -3
  62. package/src/ui/ActivityIndicator.tsx +6 -6
  63. package/src/ui/AgentBlock.tsx +6 -6
  64. package/src/ui/AnimatedStatusText.tsx +1 -1
  65. package/src/ui/AppShell.tsx +670 -719
  66. package/src/ui/AttachmentImportPanel.tsx +8 -8
  67. package/src/ui/AuthPanel.tsx +20 -20
  68. package/src/ui/BottomComposer.tsx +158 -118
  69. package/src/ui/DashCard.tsx +3 -3
  70. package/src/ui/Markdown.tsx +17 -17
  71. package/src/ui/ModelPickerScreen.tsx +222 -42
  72. package/src/ui/ModelReasoningPicker.tsx +15 -15
  73. package/src/ui/Panel.tsx +3 -3
  74. package/src/ui/PlanActionPicker.tsx +6 -6
  75. package/src/ui/PlanReviewPanel.tsx +9 -9
  76. package/src/ui/ProviderPicker.tsx +735 -321
  77. package/src/ui/RunFooter.tsx +3 -3
  78. package/src/ui/RuntimeStatusBar.tsx +108 -0
  79. package/src/ui/SelectionPanel.tsx +8 -4
  80. package/src/ui/SettingsPanel.tsx +9 -9
  81. package/src/ui/Spinner.tsx +1 -1
  82. package/src/ui/TextEntryPanel.tsx +11 -11
  83. package/src/ui/ThinkingBlock.tsx +8 -8
  84. package/src/ui/Timeline.tsx +1625 -1472
  85. package/src/ui/TopHeader.tsx +437 -293
  86. package/src/ui/TranscriptShell.tsx +322 -0
  87. package/src/ui/TurnGroup.tsx +33 -33
  88. package/src/ui/UpdateAvailableCard.tsx +41 -0
  89. package/src/ui/UpdatePromptPanel.tsx +197 -0
  90. package/src/ui/focus.ts +3 -0
  91. package/src/ui/layout.ts +299 -25
  92. package/src/ui/layoutListWindow.ts +145 -0
  93. package/src/ui/logoVariants.ts +103 -0
  94. package/src/ui/modeDisplay.ts +12 -12
  95. package/src/ui/runtimeDisplay.ts +112 -0
  96. package/src/ui/textLayout.ts +15 -4
  97. package/src/ui/theme.tsx +274 -395
  98. package/src/ui/timelineMeasure.ts +218 -136
  99. package/scripts/audit-codexa-capabilities.mjs +0 -466
  100. package/scripts/gen-build-info.mjs +0 -33
  101. package/scripts/smoke-terminal-bench.mjs +0 -35
  102. package/src/appRenderStability.test.ts +0 -131
  103. package/src/commands/handler.test.ts +0 -655
  104. package/src/config/launchArgs.test.ts +0 -189
  105. package/src/config/layeredConfig.test.ts +0 -143
  106. package/src/config/persistence.test.ts +0 -114
  107. package/src/config/runtimeConfig.test.ts +0 -218
  108. package/src/config/settings.test.ts +0 -155
  109. package/src/config/trustStore.test.ts +0 -29
  110. package/src/core/attachments.test.ts +0 -155
  111. package/src/core/auth/codexAuth.test.ts +0 -68
  112. package/src/core/cleanupFastFail.test.ts +0 -76
  113. package/src/core/codex.ts +0 -124
  114. package/src/core/codexExecArgs.test.ts +0 -195
  115. package/src/core/codexLaunch.test.ts +0 -205
  116. package/src/core/codexPrompt.test.ts +0 -252
  117. package/src/core/executables/codexExecutable.test.ts +0 -212
  118. package/src/core/executables/executableResolver.test.ts +0 -129
  119. package/src/core/executables/geminiExecutable.test.ts +0 -116
  120. package/src/core/executables/pathSanityScan.test.ts +0 -47
  121. package/src/core/githubDiagnostics.test.ts +0 -92
  122. package/src/core/hollowResponseFormat.test.ts +0 -58
  123. package/src/core/launchContext.test.ts +0 -157
  124. package/src/core/models/codexCapabilities.test.ts +0 -45
  125. package/src/core/models/codexModelCapabilities.test.ts +0 -246
  126. package/src/core/models/modelSpecs.test.ts +0 -283
  127. package/src/core/perf/renderDebug.test.ts +0 -230
  128. package/src/core/planStorage.test.ts +0 -143
  129. package/src/core/process/CommandRunner.test.ts +0 -105
  130. package/src/core/projectInstructions.test.ts +0 -50
  131. package/src/core/providerLauncher/launcher.test.ts +0 -238
  132. package/src/core/providerLauncher/registry.test.ts +0 -324
  133. package/src/core/providerLauncher/workspaceConfig.test.ts +0 -638
  134. package/src/core/providerRuntime/anthropic.test.ts +0 -1120
  135. package/src/core/providerRuntime/capabilityProfile.test.ts +0 -311
  136. package/src/core/providerRuntime/contextMetadata.test.ts +0 -468
  137. package/src/core/providerRuntime/gemini.test.ts +0 -437
  138. package/src/core/providerRuntime/lmstudio.test.ts +0 -168
  139. package/src/core/providerRuntime/local.test.ts +0 -787
  140. package/src/core/providerRuntime/registry.test.ts +0 -233
  141. package/src/core/providers/codexJsonStream.test.ts +0 -148
  142. package/src/core/providers/codexSubprocess.test.ts +0 -68
  143. package/src/core/providers/codexTranscript.test.ts +0 -284
  144. package/src/core/terminal/startupClear.test.ts +0 -55
  145. package/src/core/terminal/terminalCapabilities.test.ts +0 -93
  146. package/src/core/terminal/terminalControl.test.ts +0 -75
  147. package/src/core/terminal/terminalSanitize.test.ts +0 -22
  148. package/src/core/terminal/terminalSelection.test.ts +0 -42
  149. package/src/core/terminal/terminalTitle.test.ts +0 -328
  150. package/src/core/updateCheck.test.ts +0 -194
  151. package/src/core/updateCheck.ts +0 -172
  152. package/src/core/workspaceActivity.test.ts +0 -163
  153. package/src/core/workspaceGuard.test.ts +0 -151
  154. package/src/core/workspaceRoot.test.ts +0 -23
  155. package/src/exec.test.ts +0 -13
  156. package/src/headless/execArgs.test.ts +0 -147
  157. package/src/headless/execRunner.test.ts +0 -436
  158. package/src/index.test.tsx +0 -620
  159. package/src/session/appSession.test.ts +0 -897
  160. package/src/session/chatLifecycle.test.ts +0 -64
  161. package/src/session/liveRenderScheduler.test.ts +0 -201
  162. package/src/session/planFlow.test.ts +0 -103
  163. package/src/session/planTranscript.test.ts +0 -65
  164. package/src/session/promptRunSchedule.test.ts +0 -36
  165. package/src/ui/ActivityIndicator.test.tsx +0 -58
  166. package/src/ui/AgentBlock.test.ts +0 -6
  167. package/src/ui/AnimatedStatusText.test.ts +0 -16
  168. package/src/ui/AppShell.test.tsx +0 -1776
  169. package/src/ui/AttachmentImportPanel.test.tsx +0 -204
  170. package/src/ui/BottomComposer.test.ts +0 -674
  171. package/src/ui/CodexLogo.tsx +0 -55
  172. package/src/ui/Markdown.test.ts +0 -157
  173. package/src/ui/ModelPickerProviderScope.test.tsx +0 -411
  174. package/src/ui/ModelPickerScreen.test.tsx +0 -99
  175. package/src/ui/ModelPickerState.test.tsx +0 -151
  176. package/src/ui/ModelReasoningPicker.test.tsx +0 -447
  177. package/src/ui/PlanReviewPanel.test.tsx +0 -267
  178. package/src/ui/PromptCardBorder.test.tsx +0 -161
  179. package/src/ui/ProviderPicker.test.tsx +0 -289
  180. package/src/ui/ProviderShortcut.test.tsx +0 -143
  181. package/src/ui/SettingsPanel.test.tsx +0 -233
  182. package/src/ui/StaticTranscriptItem.tsx +0 -56
  183. package/src/ui/Timeline.test.ts +0 -2067
  184. package/src/ui/TimelineNavigation.test.tsx +0 -201
  185. package/src/ui/TopHeader.test.tsx +0 -254
  186. package/src/ui/TurnGroup.test.tsx +0 -365
  187. package/src/ui/busyStatusAnimation.test.ts +0 -30
  188. package/src/ui/commandNormalize.test.ts +0 -142
  189. package/src/ui/diffRenderer.test.ts +0 -102
  190. package/src/ui/focusFlow.test.tsx +0 -1098
  191. package/src/ui/inputBuffer.test.ts +0 -151
  192. package/src/ui/layout.test.ts +0 -146
  193. package/src/ui/modeDisplay.test.ts +0 -42
  194. package/src/ui/runActivityView.test.ts +0 -89
  195. package/src/ui/runLifecycleView.test.tsx +0 -237
  196. package/src/ui/statusRenderIsolation.test.tsx +0 -654
  197. package/src/ui/terminalAnswerFormat.test.ts +0 -19
  198. package/src/ui/textLayout.test.ts +0 -18
  199. package/src/ui/themeFlow.test.ts +0 -53
  200. package/src/ui/timelineMeasureCache.test.ts +0 -986
  201. /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
  202. /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
  203. /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
  204. /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
  205. /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
  206. /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
@@ -24,7 +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";
27
+ import { normalizePlanReviewMarkdown } from "../core/workspace/planStorage.js";
28
+ import { LOGO_COMPACT, LOGO_COMPACT_MIN_COLS, LOGO_LARGE_MIN_COLS, selectLogoVariant } from "./logoVariants.js";
28
29
 
29
30
  // ─── Exported types ───────────────────────────────────────────────────────────
30
31
 
@@ -40,7 +41,10 @@ export type TimelineTone =
40
41
  | "borderSubtle"
41
42
  | "borderActive"
42
43
  | "panel"
43
- | "star";
44
+ | "star"
45
+ | "logoPrimary"
46
+ | "logoSecondary"
47
+ | "logoShadow";
44
48
 
45
49
  export interface TimelineRowSpan {
46
50
  text: string;
@@ -95,14 +99,8 @@ const MAX_VISIBLE_PROGRESS_ENTRIES = 3;
95
99
  const COMPACT_PROCESSING_BODY_LINE_CAP = 4;
96
100
  const COMPACT_STREAMING_TAIL_CAP = 6;
97
101
  const VISIBLE_THINKING_SOURCES = new Set(["reasoning", "todo"]);
98
- const INTRO_WORDMARK = [
99
- " ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ",
100
- "██╔════╝██╔═══██╗██╔══██╗██╔════╝╚██╗██╔╝██╔══██╗",
101
- "██║ ██║ ██║██║ ██║█████╗ ╚███╔╝ ███████║",
102
- "██║ ██║ ██║██║ ██║██╔══╝ ██╔██╗ ██╔══██║",
103
- "╚██████╗╚██████╔╝██████╔╝███████╗██╔╝ ██╗██║ ██║",
104
- " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
105
- ];
102
+ // Logo rows for the intro item — selected dynamically from logoVariants.ts so
103
+ // the dead-code intro path stays consistent with the live TopHeader rendering.
106
104
 
107
105
  // Matches sentence-ending punctuation followed (optionally after whitespace) by
108
106
  // a capital letter starting a new word. Requires [A-Z] to be followed by [a-z]
@@ -174,6 +172,37 @@ function padSpansToWidth(spans: TimelineRowSpan[], width: number): TimelineRowSp
174
172
  return next;
175
173
  }
176
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
+
177
206
  const ROW_CONTENT_CACHE_LIMIT = 2500;
178
207
  const _rowContentCache = new Map<string, TimelineRow>();
179
208
 
@@ -434,14 +463,14 @@ function buildDashCardRows(params: {
434
463
  return { ...span, tone: borderTone };
435
464
  });
436
465
 
437
- const rows: TimelineRow[] = [createRow(`${params.keyPrefix}-top`, topRow, width)];
466
+ const rows: TimelineRow[] = [createRow(`${params.keyPrefix}-top`, fitSpansToWidth(topRow, width), width)];
438
467
 
439
468
  params.contentRows.forEach((row, index) => {
440
469
  rows.push(createRow(
441
470
  `${params.keyPrefix}-content-${index}`,
442
471
  [
443
472
  createSpan("│ ", borderTone),
444
- ...padSpansToWidth(row, contentWidth),
473
+ ...fitSpansToWidth(row, contentWidth),
445
474
  createSpan(" │", borderTone),
446
475
  ],
447
476
  width,
@@ -488,7 +517,7 @@ function buildPanelRows(params: {
488
517
  `${params.keyPrefix}-content-${index}`,
489
518
  [
490
519
  createSpan("│ ", "borderActive"),
491
- ...padSpansToWidth(row, contentWidth),
520
+ ...fitSpansToWidth(row, contentWidth),
492
521
  createSpan(" │", "borderActive"),
493
522
  ],
494
523
  width,
@@ -1539,18 +1568,19 @@ export function buildStandaloneEventRows(item: Extract<RenderTimelineItem, { typ
1539
1568
  return rows;
1540
1569
  }
1541
1570
 
1542
- export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro" }>, width: number): TimelineRow[] {
1571
+ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro" }>, width: number): TimelineRow[] {
1543
1572
  const rows: TimelineRow[] = [];
1544
1573
  const { intro } = item;
1545
1574
  const safeWidth = Math.max(10, width);
1546
1575
  const startupHeaderMode = intro.startupHeaderMode
1547
- ?? (intro.layoutMode === "full" ? "large" : "compact");
1548
- if (startupHeaderMode === "tiny") {
1549
- const messageRows = [
1550
- "Codexa",
1551
- "Terminal is too small for the startup view.",
1552
- "Resize the terminal to continue.",
1553
- ];
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));
1554
1584
  messageRows.forEach((line, index) => {
1555
1585
  rows.push(createRow(
1556
1586
  `${item.key}-resize-${index}`,
@@ -1561,15 +1591,20 @@ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
1561
1591
  return rows;
1562
1592
  }
1563
1593
 
1564
- const logoRows = startupHeaderMode === "large"
1565
- ? INTRO_WORDMARK
1566
- : ["CODEXA"];
1567
- const logoWidth = logoRows.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
1568
- const workspaceName = getWorkspaceDisplayName(intro.workspaceLabel);
1569
- const metaLines = [
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
+ }
1603
+ const logoWidth = effectiveLogoRows.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
1604
+ const metaLines = [
1570
1605
  `Codexa v${intro.version}`,
1571
- `Auth: ${intro.authLabel}`,
1572
1606
  workspaceName ? `Workspace: ${workspaceName}` : null,
1607
+ intro.providerLabel ? `Provider: ${intro.providerLabel}` : `Auth: ${intro.authLabel}`,
1573
1608
  ].filter((line): line is string => Boolean(line));
1574
1609
  const gapWidth = 2;
1575
1610
  const widestMetaLine = metaLines.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
@@ -1577,19 +1612,27 @@ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
1577
1612
  && safeWidth >= logoWidth + gapWidth + widestMetaLine;
1578
1613
 
1579
1614
  if (canRenderSideBySide) {
1580
- const metaStartRow = Math.max(0, Math.floor((logoRows.length - metaLines.length) / 2));
1581
- const rowCount = Math.max(logoRows.length, metaStartRow + metaLines.length);
1615
+ const metaStartRow = Math.max(0, Math.floor((effectiveLogoRows.length - metaLines.length) / 2));
1616
+ const rowCount = Math.max(effectiveLogoRows.length, metaStartRow + metaLines.length);
1582
1617
  const metaWidth = Math.max(1, safeWidth - logoWidth - gapWidth);
1583
1618
 
1584
1619
  for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
1585
- const logoLine = logoRows[rowIndex] ?? "";
1620
+ const logoLine = effectiveLogoRows[rowIndex] ?? "";
1586
1621
  const logoPadding = Math.max(0, logoWidth - getTextWidth(logoLine));
1587
1622
  const metaIndex = rowIndex - metaStartRow;
1588
1623
  const metaLine = metaIndex >= 0 && metaIndex < metaLines.length
1589
1624
  ? sanitizeTerminalOutput(metaLines[metaIndex]!)
1590
1625
  : "";
1626
+ let logoTone: TimelineTone = "logoPrimary";
1627
+ if (effectiveLogoRows.length === 6) {
1628
+ if (rowIndex === 2 || rowIndex === 3) logoTone = "logoSecondary";
1629
+ else if (rowIndex === 4 || rowIndex === 5) logoTone = "logoShadow";
1630
+ } else if (effectiveLogoRows === LOGO_COMPACT) {
1631
+ logoTone = "accent";
1632
+ }
1633
+ // No bold on logo spans — bold on block/box-drawing chars causes spacing artifacts.
1591
1634
  const spans = [
1592
- createSpan(`${logoLine}${" ".repeat(logoPadding)}`, "accent", { bold: true }),
1635
+ createSpan(`${logoLine}${" ".repeat(logoPadding)}`, logoTone),
1593
1636
  createSpan(" ".repeat(gapWidth)),
1594
1637
  ];
1595
1638
 
@@ -1604,10 +1647,17 @@ export function buildIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
1604
1647
  ));
1605
1648
  }
1606
1649
  } else {
1607
- logoRows.forEach((line, index) => {
1650
+ effectiveLogoRows.forEach((line, index) => {
1651
+ let logoTone: TimelineTone = "logoPrimary";
1652
+ if (effectiveLogoRows.length === 6) {
1653
+ if (index === 2 || index === 3) logoTone = "logoSecondary";
1654
+ else if (index === 4 || index === 5) logoTone = "logoShadow";
1655
+ } else if (effectiveLogoRows === LOGO_COMPACT) {
1656
+ logoTone = "accent";
1657
+ }
1608
1658
  rows.push(createRow(
1609
1659
  `${item.key}-logo-${index}`,
1610
- [createSpan(clampVisualText(line, safeWidth), "accent", { bold: true })],
1660
+ [createSpan(clampVisualText(line, safeWidth), logoTone)],
1611
1661
  safeWidth,
1612
1662
  ));
1613
1663
  });
@@ -1684,7 +1734,7 @@ function applyTurnOpacity(rows: TimelineRow[], opacity: "active" | "recent" | "d
1684
1734
  // ─── Stream event types ───────────────────────────────────────────────────────
1685
1735
 
1686
1736
  export type StreamEvent =
1687
- | { kind: "thinking"; streamSeq: number; block: RunProgressBlock; isActive: boolean }
1737
+ | { kind: "thinking"; streamSeq: number; block: RunProgressBlock }
1688
1738
  | { kind: "response"; streamSeq: number; segment: RunResponseSegment }
1689
1739
  | { kind: "action"; streamSeq: number; tool: RunToolActivity }
1690
1740
  | { kind: "actionSummary"; streamSeq: number; id: string; label: string; count: number }
@@ -1701,8 +1751,24 @@ function getCompactableActionLabel(event: StreamEvent): string | null {
1701
1751
  return label === "Read file" || label === "List files" ? label : null;
1702
1752
  }
1703
1753
 
1704
- function compactActionBursts(events: StreamEvent[], verbose: boolean): StreamEvent[] {
1705
- 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;
1706
1772
 
1707
1773
  const compacted: StreamEvent[] = [];
1708
1774
  for (let index = 0; index < events.length;) {
@@ -1764,13 +1830,11 @@ function buildCodexThinkingRows(params: {
1764
1830
  keyPrefix: string;
1765
1831
  width: number;
1766
1832
  event: Extract<StreamEvent, { kind: "thinking" }>;
1767
- isLive: boolean;
1768
1833
  verbose: boolean;
1769
1834
  }): TimelineRow[] {
1770
1835
  renderDebug.traceRender("ThinkingBlock", params.event.block.status, {
1771
1836
  keyPrefix: params.keyPrefix,
1772
1837
  streamSeq: params.event.streamSeq,
1773
- isLive: params.isLive,
1774
1838
  textLength: params.event.block.text.length,
1775
1839
  });
1776
1840
 
@@ -1784,8 +1848,6 @@ function buildCodexThinkingRows(params: {
1784
1848
  textCacheToken(block.text),
1785
1849
  params.width,
1786
1850
  params.verbose,
1787
- params.isLive,
1788
- params.event.isActive,
1789
1851
  ]);
1790
1852
 
1791
1853
  return getCachedStreamingBlockRows(cacheKey, () => {
@@ -1806,10 +1868,6 @@ function buildCodexThinkingRows(params: {
1806
1868
  ]);
1807
1869
  }
1808
1870
 
1809
- if (params.isLive && params.event.isActive) {
1810
- contentRows.push([createSpan("▌", "accent")]);
1811
- }
1812
-
1813
1871
  return buildCodexPlainRows(params.keyPrefix, params.width, contentRows);
1814
1872
  });
1815
1873
  }
@@ -1839,10 +1897,15 @@ function getActionDisplayDescriptor(params: {
1839
1897
  isLive: boolean;
1840
1898
  borderTone: TimelineTone;
1841
1899
  }): ActionDisplayDescriptor {
1842
- 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));
1843
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.
1844
1907
  const duration = params.tool.completedAt != null
1845
- ? ` ${formatDuration(params.tool.completedAt - params.tool.startedAt)}`
1908
+ ? formatDuration(params.tool.completedAt - params.tool.startedAt)
1846
1909
  : "";
1847
1910
  const summary = params.verbose ? params.tool.summary ?? "" : "";
1848
1911
  const showLiveCursor = params.isLive && params.tool.status === "running";
@@ -1869,7 +1932,7 @@ function getActionDisplayDescriptor(params: {
1869
1932
  return descriptor;
1870
1933
  }
1871
1934
 
1872
- function buildPlainActionDebugRows(params: {
1935
+ function buildPlainActionDebugRows(params: {
1873
1936
  keyPrefix: string;
1874
1937
  width: number;
1875
1938
  descriptor: ActionDisplayDescriptor;
@@ -1877,7 +1940,7 @@ function buildPlainActionDebugRows(params: {
1877
1940
  const statusText = params.descriptor.label
1878
1941
  ? `${params.descriptor.label}: ${params.descriptor.command}`
1879
1942
  : params.descriptor.command;
1880
- const suffix = params.descriptor.duration ? params.descriptor.duration : "";
1943
+ const suffix = params.descriptor.duration ? ` ${params.descriptor.duration}` : "";
1881
1944
  const text = clampVisualText(`${params.descriptor.icon} ${statusText}${suffix}`, Math.max(1, params.width - 1));
1882
1945
  renderDebug.traceEvent("action", "plainActionRow", {
1883
1946
  actionId: params.descriptor.id,
@@ -1893,10 +1956,56 @@ function buildPlainActionDebugRows(params: {
1893
1956
  ],
1894
1957
  params.width,
1895
1958
  ),
1896
- ];
1897
- }
1898
-
1899
- 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: {
1900
2009
  keyPrefix: string;
1901
2010
  width: number;
1902
2011
  event: Extract<StreamEvent, { kind: "action" }>;
@@ -1967,58 +2076,11 @@ export function buildActionEventRows(params: {
1967
2076
  });
1968
2077
  }
1969
2078
 
1970
- const buildActionRows = () => {
1971
- const contentWidth = Math.max(1, params.width - 4);
1972
- const commandWidth = Math.max(1, contentWidth - 2);
1973
- const detailText = descriptor.showLiveCursor
1974
- ? "▌"
1975
- : descriptor.summary.trim()
1976
- ? descriptor.summary
1977
- : " ";
1978
- const contentRows: TimelineRowSpan[][] = [];
1979
-
1980
- if (descriptor.label) {
1981
- contentRows.push([
1982
- createSpan(`${descriptor.icon} `, descriptor.iconTone),
1983
- createSpan(descriptor.label, "text"),
1984
- ...(descriptor.duration ? [createSpan(descriptor.duration, "dim")] : []),
1985
- ]);
1986
- wrapPlainText(descriptor.command, commandWidth).forEach((row) => {
1987
- contentRows.push([
1988
- createSpan(" "),
1989
- createSpan(row || " ", "muted"),
1990
- ]);
1991
- });
1992
- } else {
1993
- const headRows = wrapPlainText(descriptor.command, commandWidth);
1994
- headRows.forEach((row, rowIndex) => {
1995
- contentRows.push([
1996
- createSpan(rowIndex === 0 ? `${descriptor.icon} ` : " ", rowIndex === 0 ? descriptor.iconTone : undefined),
1997
- createSpan(row || " ", "text"),
1998
- ...(rowIndex === 0 && descriptor.duration ? [createSpan(descriptor.duration, "dim")] : []),
1999
- ]);
2000
- });
2001
- }
2002
-
2003
- if (!descriptor.label) {
2004
- contentRows.push([
2005
- createSpan(" "),
2006
- createSpan(" ", "muted"),
2007
- ]);
2008
- }
2009
- contentRows.push([
2010
- createSpan(" "),
2011
- createSpan(detailText, descriptor.showLiveCursor ? "accent" : "muted"),
2012
- ]);
2013
-
2014
- return buildDashCardRows({
2015
- keyPrefix: params.keyPrefix,
2016
- width: params.width,
2017
- title: "action",
2018
- borderTone: descriptor.borderTone,
2019
- contentRows: contentRows.length > 0 ? contentRows : [[createSpan(" ")]],
2020
- });
2021
- };
2079
+ const buildActionRows = () => buildCompactActionRows({
2080
+ keyPrefix: params.keyPrefix,
2081
+ width: params.width,
2082
+ descriptor,
2083
+ });
2022
2084
 
2023
2085
  if (isCompleted) {
2024
2086
  const rows = buildActionRows();
@@ -2168,13 +2230,11 @@ function buildApprovedPlanRows(params: {
2168
2230
 
2169
2231
  function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn" }>, width: number, options: { verbose?: boolean; workspaceRoot?: string | null }): TimelineRow[] {
2170
2232
  const run = item.item.run!;
2171
- const assistant = item.item.assistant;
2172
2233
  const streaming = item.renderState.runPhase === "streaming";
2173
- const dim = item.renderState.opacity !== "active";
2174
- const borderTone = dim ? "borderSubtle" : streaming ? "borderActive" : "borderSubtle";
2175
2234
  const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
2176
2235
  const verbose = options.verbose ?? false;
2177
- const events = compactActionBursts(collectStreamEvents(item, streaming), verbose);
2236
+ const finalized = run.status !== "running";
2237
+ const events = compactActionBursts(collectStreamEvents(item), verbose, finalized);
2178
2238
 
2179
2239
  const rows: TimelineRow[] = [];
2180
2240
 
@@ -2183,7 +2243,9 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2183
2243
  const isLive = run.status === "running" && isLastEvent; // The cursor is on the last event
2184
2244
 
2185
2245
  if (index > 0) {
2186
- 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));
2187
2249
  }
2188
2250
 
2189
2251
  if (event.kind === "thinking") {
@@ -2191,7 +2253,6 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2191
2253
  keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2192
2254
  width,
2193
2255
  event,
2194
- isLive,
2195
2256
  verbose,
2196
2257
  }));
2197
2258
  } else if (event.kind === "action") {
@@ -2232,7 +2293,7 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2232
2293
  }
2233
2294
  });
2234
2295
 
2235
- if (!streaming && run.status !== "running") {
2296
+ if (!streaming && finalized) {
2236
2297
  if (run.status === "canceled") {
2237
2298
  rows.push(createBlankRow(`${item.key}-cancel-gap`, width));
2238
2299
  rows.push(...buildCodexPlainRows(
@@ -2271,12 +2332,10 @@ function buildUnifiedStreamRows(item: Extract<RenderTimelineItem, { type: "turn"
2271
2332
  return rows;
2272
2333
  }
2273
2334
 
2274
- function collectStreamEvents(
2275
- item: Extract<RenderTimelineItem, { type: "turn" }>,
2276
- streaming: boolean,
2277
- ): StreamEvent[] {
2335
+ function collectStreamEvents(item: Extract<RenderTimelineItem, { type: "turn" }>): StreamEvent[] {
2278
2336
  const run = item.item.run!;
2279
2337
  const assistant = item.item.assistant;
2338
+ const streaming = item.renderState.runPhase === "streaming";
2280
2339
  const blocksById = new Map<string, RunProgressBlock>();
2281
2340
  for (const entry of run.progressEntries ?? []) {
2282
2341
  for (const block of entry.blocks) blocksById.set(block.id, block);
@@ -2288,14 +2347,23 @@ function collectStreamEvents(
2288
2347
  const sortedItems = (run.streamItems ?? []).slice().sort((a, b) => a.streamSeq - b.streamSeq);
2289
2348
  for (const it of sortedItems) {
2290
2349
  if (it.kind === "thinking") {
2291
- const block = blocksById.get(it.refId);
2292
- if (block && block.text.trim().length > 0 && !(run.status === "running" && block.status === "active")) {
2293
- events.push({
2294
- kind: "thinking",
2295
- streamSeq: it.streamSeq,
2296
- block,
2297
- isActive: run.status === "running" && block.status === "active",
2298
- });
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
+ }
2299
2367
  }
2300
2368
  } else if (it.kind === "action") {
2301
2369
  const tool = toolsById.get(it.refId);
@@ -2326,13 +2394,15 @@ function collectStreamEvents(
2326
2394
  if (!VISIBLE_THINKING_SOURCES.has(entry.source)) continue;
2327
2395
  for (const block of entry.blocks) {
2328
2396
  if (!block.text.trim()) continue;
2329
- 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;
2330
2401
  legacySeq += 1;
2331
2402
  events.push({
2332
2403
  kind: "thinking",
2333
2404
  streamSeq: legacySeq,
2334
2405
  block,
2335
- isActive: run.status === "running" && block.status === "active",
2336
2406
  });
2337
2407
  }
2338
2408
  }
@@ -2467,8 +2537,10 @@ function buildStableIntroRows(item: Extract<RenderTimelineItem, { type: "intro"
2467
2537
  innerWidth,
2468
2538
  item.intro.version,
2469
2539
  item.intro.layoutMode,
2540
+ item.intro.startupHeaderMode ?? "",
2470
2541
  item.intro.authLabel,
2471
2542
  textCacheToken(item.intro.workspaceLabel),
2543
+ item.intro.providerLabel ?? "",
2472
2544
  ]);
2473
2545
  return getCachedFrozenRows(cacheKey, () => buildIntroRows(item, innerWidth));
2474
2546
  }
@@ -2542,10 +2614,9 @@ function buildStableActiveTurnGroups(
2542
2614
  }
2543
2615
 
2544
2616
  const streaming = item.renderState.runPhase === "streaming";
2545
- const dim = item.renderState.opacity !== "active";
2546
- const borderTone = dim ? "borderSubtle" : streaming ? "borderActive" : "borderSubtle";
2547
2617
  const actionBorderTone = item.renderState.opacity === "dim" ? "borderSubtle" : "borderActive";
2548
- const events = compactActionBursts(collectStreamEvents(item, streaming), verbose);
2618
+ const finalized = run.status !== "running";
2619
+ const events = compactActionBursts(collectStreamEvents(item), verbose, finalized);
2549
2620
  let orderedRows = [...getCachedFrozenRows(rowCacheKey([
2550
2621
  "stable-active-user",
2551
2622
  item.key,
@@ -2561,7 +2632,9 @@ function buildStableActiveTurnGroups(
2561
2632
  const isLastEvent = index === events.length - 1;
2562
2633
 
2563
2634
  if (index > 0) {
2564
- 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));
2565
2638
  }
2566
2639
 
2567
2640
  if (event.kind === "thinking") {
@@ -2569,7 +2642,6 @@ function buildStableActiveTurnGroups(
2569
2642
  keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2570
2643
  width: innerWidth,
2571
2644
  event,
2572
- isLive: liveEvent,
2573
2645
  verbose,
2574
2646
  });
2575
2647
  targetRows.push(...(liveEvent ? build() : getCachedFrozenRows(rowCacheKey([
@@ -2698,7 +2770,6 @@ function buildNativeStreamEventRows(params: {
2698
2770
  keyPrefix: `${item.key}-codex-thinking-${event.streamSeq}`,
2699
2771
  width: innerWidth,
2700
2772
  event,
2701
- isLive: !params.forceStable && isNativeLiveStreamEvent(event, run),
2702
2773
  verbose,
2703
2774
  }));
2704
2775
  } else if (event.kind === "action") {
@@ -2785,8 +2856,9 @@ function appendNativeTurnParts(
2785
2856
  if (!run) return;
2786
2857
 
2787
2858
  const events = compactActionBursts(
2788
- collectStreamEvents(item, item.renderState.runPhase === "streaming"),
2859
+ collectStreamEvents(item),
2789
2860
  verbose,
2861
+ run.status !== "running",
2790
2862
  );
2791
2863
 
2792
2864
  events.forEach((event, eventIndex) => {
@@ -2965,7 +3037,7 @@ export function buildTimelineSnapshot(
2965
3037
  let builtRows: TimelineRow[];
2966
3038
 
2967
3039
  if (item.type === "intro") {
2968
- const cacheKey = `i:${item.key}:${innerWidth}:${item.intro.version}:${item.intro.layoutMode}:${item.intro.startupHeaderMode ?? ""}:${item.intro.authLabel}:${item.intro.workspaceLabel}`;
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}`;
2969
3041
  const cached = _staticRowCache.get(cacheKey);
2970
3042
  if (cached) {
2971
3043
  renderDebug.traceEvent("timeline", "rowGeneration", {
@@ -2989,8 +3061,18 @@ export function buildTimelineSnapshot(
2989
3061
  builtRows = r;
2990
3062
  }
2991
3063
  } else if (item.type === "event") {
2992
- // Standalone events (system, error, etc.) are immutable cache by key+width.
2993
- 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
+ ]);
2994
3076
  const cached = _staticRowCache.get(cacheKey);
2995
3077
  if (cached) {
2996
3078
  renderDebug.traceEvent("timeline", "rowGeneration", {