ubume 0.1.0

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 (182) hide show
  1. package/README.md +88 -0
  2. package/bin/codexa-local-harness-bridge.js +3 -0
  3. package/bin/codexa.js +28 -0
  4. package/bin/ubume-local-harness-bridge.js +325 -0
  5. package/bin/ubume.js +398 -0
  6. package/package.json +66 -0
  7. package/src/app.tsx +5759 -0
  8. package/src/commands/handler.ts +889 -0
  9. package/src/config/appVersion.ts +69 -0
  10. package/src/config/buildInfo.ts +3 -0
  11. package/src/config/launchArgs.ts +196 -0
  12. package/src/config/layeredConfig.ts +853 -0
  13. package/src/config/legacyEnv.ts +16 -0
  14. package/src/config/persistence.ts +377 -0
  15. package/src/config/runtimeConfig.ts +558 -0
  16. package/src/config/settings.ts +405 -0
  17. package/src/config/toml-serialize.ts +109 -0
  18. package/src/config/trustStore.ts +84 -0
  19. package/src/config/updateCheckCache.ts +85 -0
  20. package/src/core/README.md +55 -0
  21. package/src/core/agent/loop.ts +464 -0
  22. package/src/core/agent/protocol.ts +345 -0
  23. package/src/core/agent/tools.ts +423 -0
  24. package/src/core/auth/codexAuth.ts +359 -0
  25. package/src/core/codex/codexExecArgs.ts +166 -0
  26. package/src/core/codex/codexLaunch.ts +163 -0
  27. package/src/core/codex/codexPrompt.ts +429 -0
  28. package/src/core/debug/inputDebug.ts +51 -0
  29. package/src/core/debug/localStreamDebug.ts +50 -0
  30. package/src/core/debug/modelStateDebug.ts +35 -0
  31. package/src/core/executables/antigravityExecutable.ts +48 -0
  32. package/src/core/executables/claudeExecutable.ts +63 -0
  33. package/src/core/executables/codexExecutable.ts +160 -0
  34. package/src/core/executables/executableResolver.ts +164 -0
  35. package/src/core/executables/geminiExecutable.ts +78 -0
  36. package/src/core/models/codexCapabilities.ts +97 -0
  37. package/src/core/models/codexModelCapabilities.ts +624 -0
  38. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  39. package/src/core/models/modelSpecs.ts +303 -0
  40. package/src/core/models/providerModelCache.ts +94 -0
  41. package/src/core/perf/profiler.ts +125 -0
  42. package/src/core/perf/renderDebug.ts +398 -0
  43. package/src/core/process/CommandRunner.ts +280 -0
  44. package/src/core/process/processValidation.ts +111 -0
  45. package/src/core/providerLauncher/launcher.ts +220 -0
  46. package/src/core/providerLauncher/registry.ts +354 -0
  47. package/src/core/providerLauncher/types.ts +95 -0
  48. package/src/core/providerLauncher/workspaceConfig.ts +487 -0
  49. package/src/core/providerRuntime/anthropic.ts +580 -0
  50. package/src/core/providerRuntime/antigravity.ts +500 -0
  51. package/src/core/providerRuntime/capabilityProfile.ts +383 -0
  52. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
  53. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  54. package/src/core/providerRuntime/codexaCupy.ts +97 -0
  55. package/src/core/providerRuntime/codexaNative.ts +425 -0
  56. package/src/core/providerRuntime/contextMetadata.ts +397 -0
  57. package/src/core/providerRuntime/gemini.ts +789 -0
  58. package/src/core/providerRuntime/lmstudio.ts +118 -0
  59. package/src/core/providerRuntime/local.ts +770 -0
  60. package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
  61. package/src/core/providerRuntime/localOutputBudget.ts +17 -0
  62. package/src/core/providerRuntime/mistralVibe.ts +667 -0
  63. package/src/core/providerRuntime/models.ts +175 -0
  64. package/src/core/providerRuntime/reasoning.ts +20 -0
  65. package/src/core/providerRuntime/registry.ts +286 -0
  66. package/src/core/providerRuntime/types.ts +142 -0
  67. package/src/core/providerRuntime/unsloth.ts +216 -0
  68. package/src/core/providers/codexJsonStream.ts +305 -0
  69. package/src/core/providers/codexSubprocess.ts +378 -0
  70. package/src/core/providers/codexTranscript.ts +695 -0
  71. package/src/core/providers/openaiNative.ts +13 -0
  72. package/src/core/providers/registry.ts +21 -0
  73. package/src/core/providers/types.ts +91 -0
  74. package/src/core/shared/attachments.ts +101 -0
  75. package/src/core/shared/cleanupFastFail.ts +67 -0
  76. package/src/core/shared/clipboard.ts +24 -0
  77. package/src/core/shared/clipboardImage.ts +111 -0
  78. package/src/core/shared/githubDiagnostics.ts +222 -0
  79. package/src/core/shared/hollowResponseFormat.ts +39 -0
  80. package/src/core/terminal/clearFrameBoundary.ts +852 -0
  81. package/src/core/terminal/frameLock.ts +110 -0
  82. package/src/core/terminal/inkRenderReset.ts +123 -0
  83. package/src/core/terminal/startupClear.ts +20 -0
  84. package/src/core/terminal/terminalCapabilities.ts +100 -0
  85. package/src/core/terminal/terminalControl.ts +169 -0
  86. package/src/core/terminal/terminalSanitize.ts +147 -0
  87. package/src/core/terminal/terminalTitle.ts +400 -0
  88. package/src/core/version/channel.ts +27 -0
  89. package/src/core/version/packageManager.ts +119 -0
  90. package/src/core/version/updateCheck.ts +203 -0
  91. package/src/core/workspace/appData.ts +107 -0
  92. package/src/core/workspace/conversationStore.ts +335 -0
  93. package/src/core/workspace/launchContext.ts +259 -0
  94. package/src/core/workspace/planStorage.ts +135 -0
  95. package/src/core/workspace/projectInstructions.ts +54 -0
  96. package/src/core/workspace/scratchDir.ts +64 -0
  97. package/src/core/workspace/workspaceActivity.ts +384 -0
  98. package/src/core/workspace/workspaceGuard.ts +377 -0
  99. package/src/core/workspace/workspaceRoot.ts +47 -0
  100. package/src/exec.ts +73 -0
  101. package/src/headless/execArgs.ts +296 -0
  102. package/src/headless/execRunner.ts +304 -0
  103. package/src/index.tsx +270 -0
  104. package/src/legacyEnvBootstrap.ts +5 -0
  105. package/src/session/appSession.ts +771 -0
  106. package/src/session/chatLifecycle.ts +994 -0
  107. package/src/session/conversation.ts +107 -0
  108. package/src/session/liveRenderScheduler.ts +214 -0
  109. package/src/session/persistedResponse.ts +93 -0
  110. package/src/session/planFlow.ts +159 -0
  111. package/src/session/planTranscript.ts +19 -0
  112. package/src/session/promptRunSchedule.ts +26 -0
  113. package/src/session/types.ts +234 -0
  114. package/src/test/runtimeTestUtils.ts +14 -0
  115. package/src/types/react-dom.d.ts +3 -0
  116. package/src/ui/chrome/ActivityBars.tsx +68 -0
  117. package/src/ui/chrome/ActivityIndicator.tsx +58 -0
  118. package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
  119. package/src/ui/chrome/AppShell.tsx +474 -0
  120. package/src/ui/chrome/BottomComposer.tsx +1135 -0
  121. package/src/ui/chrome/DashCard.tsx +82 -0
  122. package/src/ui/chrome/RunFooter.tsx +65 -0
  123. package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
  124. package/src/ui/chrome/Spinner.tsx +25 -0
  125. package/src/ui/chrome/TopHeader.tsx +439 -0
  126. package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
  127. package/src/ui/chrome/busyStatusAnimation.ts +11 -0
  128. package/src/ui/input/commandNormalize.ts +66 -0
  129. package/src/ui/input/focus.ts +73 -0
  130. package/src/ui/input/imageAttachments.ts +18 -0
  131. package/src/ui/input/inputBuffer.ts +203 -0
  132. package/src/ui/input/pastedContent.ts +75 -0
  133. package/src/ui/input/rawArrowKeys.ts +28 -0
  134. package/src/ui/input/slashCommands.ts +43 -0
  135. package/src/ui/input/useStdinRawModeLease.ts +23 -0
  136. package/src/ui/layout.ts +560 -0
  137. package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
  138. package/src/ui/panels/AuthPanel.tsx +149 -0
  139. package/src/ui/panels/BackendPicker.tsx +28 -0
  140. package/src/ui/panels/ModePicker.tsx +31 -0
  141. package/src/ui/panels/ModelPicker.tsx +31 -0
  142. package/src/ui/panels/ModelPickerScreen.tsx +761 -0
  143. package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
  144. package/src/ui/panels/Panel.tsx +51 -0
  145. package/src/ui/panels/PermissionsPanel.tsx +78 -0
  146. package/src/ui/panels/PlanActionPicker.tsx +187 -0
  147. package/src/ui/panels/ProviderPicker.tsx +753 -0
  148. package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
  149. package/src/ui/panels/ReasoningPicker.tsx +46 -0
  150. package/src/ui/panels/ResumePicker.tsx +90 -0
  151. package/src/ui/panels/SelectionPanel.tsx +138 -0
  152. package/src/ui/panels/SettingsPanel.tsx +156 -0
  153. package/src/ui/panels/TextEntryPanel.tsx +139 -0
  154. package/src/ui/panels/ThemePicker.tsx +32 -0
  155. package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
  156. package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
  157. package/src/ui/panels/responsivePickerViewport.ts +64 -0
  158. package/src/ui/render/Markdown.tsx +331 -0
  159. package/src/ui/render/diffRenderer.ts +116 -0
  160. package/src/ui/render/logoVariants.ts +113 -0
  161. package/src/ui/render/modeDisplay.ts +52 -0
  162. package/src/ui/render/outputPipeline.ts +64 -0
  163. package/src/ui/render/runtimeDisplay.ts +128 -0
  164. package/src/ui/render/terminalAnswerFormat.ts +128 -0
  165. package/src/ui/render/textLayout.ts +392 -0
  166. package/src/ui/theme.tsx +274 -0
  167. package/src/ui/themeFlow.ts +41 -0
  168. package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
  169. package/src/ui/timeline/AgentBlock.tsx +130 -0
  170. package/src/ui/timeline/StaticIntroItem.tsx +54 -0
  171. package/src/ui/timeline/ThinkingBlock.tsx +100 -0
  172. package/src/ui/timeline/Timeline.tsx +1410 -0
  173. package/src/ui/timeline/TranscriptShell.tsx +302 -0
  174. package/src/ui/timeline/TurnGroup.tsx +673 -0
  175. package/src/ui/timeline/layoutListWindow.ts +145 -0
  176. package/src/ui/timeline/liveViewportWindow.ts +68 -0
  177. package/src/ui/timeline/progressEntries.ts +156 -0
  178. package/src/ui/timeline/runActivityView.ts +37 -0
  179. package/src/ui/timeline/staticTranscriptCache.ts +174 -0
  180. package/src/ui/timeline/streamCoalesce.ts +53 -0
  181. package/src/ui/timeline/timelineMeasure.ts +3273 -0
  182. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,673 @@
1
+ import React, { memo, useEffect, useState, useMemo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import type {
4
+ AssistantEvent,
5
+ RunEvent,
6
+ RunProgressBlock,
7
+ RunResponseSegment,
8
+ RunStreamItem,
9
+ RunToolActivity,
10
+ UIState,
11
+ UserPromptEvent,
12
+ } from "../../session/types.js";
13
+ import { getAssistantContent, getResponseSegmentText, getRunPlanText } from "../../session/types.js";
14
+ import { formatTerminalAnswerInline } from "../render/terminalAnswerFormat.js";
15
+ import { ActionRequiredBlock } from "./ActionRequiredBlock.js";
16
+ import { DashCard } from "../chrome/DashCard.js";
17
+ import { useTheme } from "../theme.js";
18
+ import { sanitizeTerminalOutput } from "../../core/terminal/terminalSanitize.js";
19
+ import { wrapPlainText, wrapCommandText } from "../render/textLayout.js";
20
+ import { selectVisibleRunActivity } from "./runActivityView.js";
21
+ import type { RunFileActivity } from "../../core/workspace/workspaceActivity.js";
22
+ import { RUN_OUTPUT_TRUNCATION_NOTICE } from "../../session/chatLifecycle.js";
23
+ import { formatProgressBlockBodyLines } from "./progressEntries.js";
24
+ import { getUsableShellWidth, transcriptContentIndent } from "../layout.js";
25
+ import { MemoizedRenderMessage } from "../render/Markdown.js";
26
+ import {
27
+ sanitizeOutput,
28
+ sanitizeStreamChunk,
29
+ normalizeOutput,
30
+ classifyOutput,
31
+ formatForBox,
32
+ } from "../render/outputPipeline.js";
33
+ import { normalizeCommand, getFriendlyActionLabel } from "../input/commandNormalize.js";
34
+ import * as renderDebug from "../../core/perf/renderDebug.js";
35
+ import { normalizePlanReviewMarkdown } from "../../core/workspace/planStorage.js";
36
+ import { AgentBlock } from "./AgentBlock.js";
37
+ import { coalesceConsecutiveThinking } from "./streamCoalesce.js";
38
+
39
+ export type TurnOpacity = "active" | "recent" | "dim";
40
+
41
+ interface TurnGroupProps {
42
+ cols: number;
43
+ turnIndex: number;
44
+ user: UserPromptEvent;
45
+ run: RunEvent | null;
46
+ assistant: AssistantEvent | null;
47
+ opacity: TurnOpacity;
48
+ question: string | null;
49
+ runPhase: TurnRunPhase;
50
+ streamPreviewRows: number;
51
+ streamMode: "assistant-first";
52
+ verboseMode?: boolean;
53
+ workspaceRoot?: string | null;
54
+ }
55
+
56
+ function formatDuration(ms: number): string {
57
+ if (ms < 1000) return `${ms}ms`;
58
+ return `${(ms / 1000).toFixed(1)}s`;
59
+ }
60
+
61
+ // ─── User Input Card ─────────────────────────────────────────────────────────
62
+ // User prompt wrapped in a rounded DashCard border.
63
+
64
+ function UserInputCard({
65
+ prompt,
66
+ cols,
67
+ dim,
68
+ }: {
69
+ prompt: string;
70
+ cols: number;
71
+ dim: boolean;
72
+ }) {
73
+ const theme = useTheme();
74
+ const borderColor = theme.border;
75
+ const contentWidth = Math.max(1, cols - 7);
76
+ const lines = wrapPlainText(sanitizeTerminalOutput(prompt), contentWidth);
77
+
78
+ return (
79
+ <DashCard cols={cols} title="PROMPT" borderColor={borderColor}>
80
+ {lines.map((line, i) => (
81
+ <Text key={i} color={dim ? theme.textDim : theme.text}>
82
+ {i === 0 ? "❯ " : " "}{line}
83
+ </Text>
84
+ ))}
85
+ </DashCard>
86
+ );
87
+ }
88
+
89
+ const MemoizedUserInputCard = memo(UserInputCard, (prev, next) => (
90
+ prev.prompt === next.prompt
91
+ && prev.cols === next.cols
92
+ && prev.dim === next.dim
93
+ ));
94
+
95
+ // ─── Impact Summary ──────────────────────────────────────────────────────────
96
+ // Compact file-change summary replacing FileScanCard + ActivityCard
97
+
98
+ function ImpactSummary({
99
+ run,
100
+ cols,
101
+ }: {
102
+ run: RunEvent;
103
+ cols: number;
104
+ }) {
105
+ const theme = useTheme();
106
+ const summary = run.activitySummary;
107
+ const hasFiles = run.touchedFileCount > 0;
108
+ const hasTools = run.toolActivities.length > 0;
109
+
110
+ if (!hasFiles && !hasTools) return null;
111
+
112
+ const contentWidth = Math.max(1, cols - 6);
113
+ const recentFiles = summary?.recent ?? run.activity.slice(-6);
114
+ const hasDeletes = (summary?.deleted ?? 0) > 0;
115
+
116
+ const opLabel = (op: string) => {
117
+ switch (op) {
118
+ case "created": return "CREATED ";
119
+ case "modified": return "MODIFIED";
120
+ case "deleted": return "DELETED ";
121
+ default: return op.toUpperCase().padEnd(8);
122
+ }
123
+ };
124
+
125
+ const opColor = (op: string) => {
126
+ switch (op) {
127
+ case "created": return theme.success;
128
+ case "deleted": return theme.error;
129
+ default: return theme.info;
130
+ }
131
+ };
132
+
133
+ return (
134
+ <Box flexDirection="column" width="100%" paddingX={1} marginTop={0}>
135
+ {hasDeletes && (
136
+ <Text color={theme.warning}>{"⚠ Destructive changes detected:"}</Text>
137
+ )}
138
+ {hasFiles && (
139
+ <>
140
+ <Text color={theme.textDim}>{" Changes:"}</Text>
141
+ {recentFiles.map((file: RunFileActivity, i: number) => {
142
+ const diffInfo = file.addedLines != null || file.removedLines != null
143
+ ? ` (+${file.addedLines ?? 0} -${file.removedLines ?? 0})`
144
+ : "";
145
+ return (
146
+ <Text key={i}>
147
+ <Text color={theme.textDim}>{" "}</Text>
148
+ <Text color={opColor(file.operation)}>{opLabel(file.operation)}</Text>
149
+ <Text color={theme.text}>{" "}{file.path}</Text>
150
+ <Text color={theme.textDim}>{diffInfo}</Text>
151
+ </Text>
152
+ );
153
+ })}
154
+ </>
155
+ )}
156
+ <Text color={theme.textDim}>
157
+ {" "}
158
+ <Text color={theme.success}>{"✔ "}</Text>
159
+ {run.touchedFileCount > 0 && `${run.touchedFileCount} file${run.touchedFileCount === 1 ? "" : "s"}`}
160
+ {hasTools && `${hasFiles ? " • " : ""}${run.toolActivities.length} action${run.toolActivities.length === 1 ? "" : "s"}`}
161
+ {run.durationMs != null && ` • ${formatDuration(run.durationMs)}`}
162
+ </Text>
163
+ </Box>
164
+ );
165
+ }
166
+
167
+ // ─── Verbose Cards (only shown in verbose mode) ──────────────────────────────
168
+
169
+ function FileScanCard({ run, cols }: { run: RunEvent; cols: number }) {
170
+ const theme = useTheme();
171
+ const { visible, hiddenCount } = selectVisibleRunActivity(run);
172
+ const badge = `${run.touchedFileCount} file${run.touchedFileCount === 1 ? "" : "s"}`;
173
+
174
+ return (
175
+ <DashCard cols={cols} title="Scanning workspace ..." rightBadge={badge}>
176
+ {hiddenCount > 0 && (
177
+ <Text color={theme.textDim}>{`... ${hiddenCount} more`}</Text>
178
+ )}
179
+ {visible.map((file, i) => (
180
+ <Text key={i} color={theme.success}>
181
+ {"● "}<Text color={theme.text}>{file.path}</Text>
182
+ </Text>
183
+ ))}
184
+ </DashCard>
185
+ );
186
+ }
187
+
188
+ const COMPACT_PROCESSING_BODY_LINE_CAP = 4;
189
+ const COMPACT_STREAMING_TAIL_CAP = 6;
190
+ const VISIBLE_THINKING_SOURCES = new Set(["reasoning", "todo"]);
191
+
192
+ // ─── Unified Event Stream Card ───────────────────────────────────────────────
193
+
194
+ type ResolvedStreamEvent =
195
+ | { kind: "thinking"; streamSeq: number; block: RunProgressBlock }
196
+ | { kind: "action"; streamSeq: number; tool: RunToolActivity }
197
+ | { kind: "response"; streamSeq: number; segment: RunResponseSegment }
198
+ | { kind: "plan"; streamSeq: number; planText: string; approved: boolean };
199
+
200
+ function resolveStreamEvents(
201
+ run: RunEvent,
202
+ assistant: AssistantEvent | null,
203
+ streaming: boolean,
204
+ ): ResolvedStreamEvent[] {
205
+ const blocksById = new Map<string, RunProgressBlock>();
206
+ for (const entry of run.progressEntries ?? []) {
207
+ for (const block of entry.blocks) blocksById.set(block.id, block);
208
+ }
209
+ const toolsById = new Map(run.toolActivities.map((tool) => [tool.id, tool] as const));
210
+ const segmentsById = new Map((run.responseSegments ?? []).map((seg) => [seg.id, seg] as const));
211
+
212
+ const items = (run.streamItems ?? []).slice().sort((a, b) => a.streamSeq - b.streamSeq);
213
+ const resolved: ResolvedStreamEvent[] = [];
214
+ for (const item of items) {
215
+ if (item.kind === "thinking") {
216
+ const block = blocksById.get(item.refId);
217
+ if (block && block.text.trim().length > 0 && !(run.status === "running" && block.status === "active")) {
218
+ resolved.push({ kind: "thinking", streamSeq: item.streamSeq, block });
219
+ }
220
+ } else if (item.kind === "action") {
221
+ const tool = toolsById.get(item.refId);
222
+ if (tool) resolved.push({ kind: "action", streamSeq: item.streamSeq, tool });
223
+ } else if (item.kind === "response") {
224
+ const segment = segmentsById.get(item.refId);
225
+ if (segment) resolved.push({ kind: "response", streamSeq: item.streamSeq, segment });
226
+ } else if (item.kind === "plan") {
227
+ const planText = run.plan?.id === item.refId
228
+ ? getRunPlanText(run.plan)
229
+ : run.approvedPlan ?? "";
230
+ if (planText.trim()) {
231
+ resolved.push({
232
+ kind: "plan",
233
+ streamSeq: item.streamSeq,
234
+ planText,
235
+ approved: Boolean(run.approvedPlan),
236
+ });
237
+ }
238
+ }
239
+ }
240
+
241
+ // Backward-compat fallback for older session data that predates streamItems.
242
+ // New runs always use the streamItems path above.
243
+ if (resolved.length === 0 && items.length === 0) {
244
+ let legacySeq = 0;
245
+ for (const entry of run.progressEntries ?? []) {
246
+ if (!VISIBLE_THINKING_SOURCES.has(entry.source)) continue;
247
+ for (const block of entry.blocks) {
248
+ if (!block.text.trim()) continue;
249
+ if (run.status === "running" && block.status === "active") continue;
250
+ legacySeq += 1;
251
+ resolved.push({ kind: "thinking", streamSeq: legacySeq, block });
252
+ }
253
+ }
254
+
255
+ for (const tool of run.toolActivities ?? []) {
256
+ legacySeq += 1;
257
+ resolved.push({ kind: "action", streamSeq: legacySeq, tool });
258
+ }
259
+
260
+ for (const segment of run.responseSegments ?? []) {
261
+ if (!getResponseSegmentText(segment).trim() && !streaming) continue;
262
+ legacySeq += 1;
263
+ resolved.push({ kind: "response", streamSeq: legacySeq, segment });
264
+ }
265
+ }
266
+
267
+ // First-render fallback: the assistant may have produced text before the
268
+ // run has received a stream item, especially with older persisted data.
269
+ if (resolved.length === 0 && (getAssistantContent(assistant).length > 0 || streaming)) {
270
+ const content = getAssistantContent(assistant);
271
+ resolved.push({
272
+ kind: "response",
273
+ streamSeq: 1,
274
+ segment: {
275
+ id: `synthetic-${run.id}`,
276
+ streamSeq: 1,
277
+ chunks: [content],
278
+ status: streaming ? "active" : "completed",
279
+ startedAt: run.startedAt,
280
+ },
281
+ });
282
+ }
283
+
284
+ return coalesceConsecutiveThinking(resolved);
285
+ }
286
+
287
+ function PlanPanel({
288
+ planText,
289
+ cols,
290
+ approved,
291
+ workspaceRoot,
292
+ }: {
293
+ planText: string;
294
+ cols: number;
295
+ approved: boolean;
296
+ workspaceRoot?: string | null;
297
+ }) {
298
+ const theme = useTheme();
299
+ const contentWidth = Math.max(1, getUsableShellWidth(cols, 4));
300
+
301
+ const formatted = useMemo(() => {
302
+ const normalized = normalizePlanReviewMarkdown(planText, workspaceRoot);
303
+ const classified = classifyOutput(normalized);
304
+ return formatForBox(classified, contentWidth);
305
+ }, [planText, contentWidth, workspaceRoot]);
306
+
307
+ return (
308
+ <DashCard
309
+ cols={cols}
310
+ title="Plan"
311
+ rightBadge={approved ? "approved" : undefined}
312
+ borderColor={theme.accent}
313
+ titleColor={theme.text}
314
+ badgeColor={theme.success}
315
+ >
316
+ <MemoizedRenderMessage segments={formatted} width={contentWidth} brightHeadings />
317
+ </DashCard>
318
+ );
319
+ }
320
+
321
+ const MemoizedPlanPanel = memo(PlanPanel, (prev, next) => (
322
+ prev.planText === next.planText && prev.cols === next.cols && prev.approved === next.approved && prev.workspaceRoot === next.workspaceRoot
323
+ ));
324
+
325
+ function ActionEventCard({
326
+ cols,
327
+ tool,
328
+ opacity,
329
+ isLiveCursorTarget,
330
+ }: {
331
+ cols: number;
332
+ tool: RunToolActivity;
333
+ opacity: TurnOpacity;
334
+ isLiveCursorTarget: boolean;
335
+ }) {
336
+ const theme = useTheme();
337
+ const dim = opacity !== "active";
338
+ const actionNormalized = normalizeCommand(tool.command);
339
+ const actionLabel = getFriendlyActionLabel(actionNormalized);
340
+
341
+ const statusIcon = tool.status === "failed" ? "✕" : tool.status === "completed" ? "✔" : "▸";
342
+ const statusColor = tool.status === "failed" ? theme.error : tool.status === "completed" ? theme.success : theme.info;
343
+ const borderColor = dim ? theme.border : tool.status === "running" ? theme.borderFocused : theme.border;
344
+ const detailText = isLiveCursorTarget && tool.status === "running"
345
+ ? "▌"
346
+ : tool.summary?.trim() ? tool.summary : " ";
347
+ const detailColor = isLiveCursorTarget && tool.status === "running" ? theme.accent : theme.textMuted;
348
+ const duration = tool.completedAt != null
349
+ ? formatDuration(tool.completedAt - tool.startedAt)
350
+ : null;
351
+
352
+ const commandBodyWidth = Math.max(1, cols - 6);
353
+ const commandLines = wrapCommandText(actionNormalized, commandBodyWidth);
354
+
355
+ return (
356
+ <DashCard cols={cols} title="action" rightBadge={duration || undefined} borderColor={borderColor}>
357
+ {actionLabel ? (
358
+ <>
359
+ <Box>
360
+ <Text color={statusColor}>{statusIcon + " "}</Text>
361
+ <Text color={dim ? theme.textDim : theme.text}>{actionLabel}</Text>
362
+ </Box>
363
+ {commandLines.map((line, i) => (
364
+ <Text key={i} color={theme.textMuted}>{" "}{line || " "}</Text>
365
+ ))}
366
+ </>
367
+ ) : (
368
+ <>
369
+ {commandLines.map((line, i) => (
370
+ <Box key={i}>
371
+ <Text color={i === 0 ? statusColor : undefined}>{i === 0 ? statusIcon + " " : " "}</Text>
372
+ <Text color={dim ? theme.textDim : theme.text}>{line || " "}</Text>
373
+ </Box>
374
+ ))}
375
+ <Text color={theme.textMuted}>{" "}</Text>
376
+ </>
377
+ )}
378
+ <Text color={detailColor}>{" "}{detailText}</Text>
379
+ </DashCard>
380
+ );
381
+ }
382
+
383
+ const MemoizedActionEventCard = memo(ActionEventCard, (prev, next) =>
384
+ prev.tool.id === next.tool.id &&
385
+ prev.tool.status === next.tool.status &&
386
+ prev.tool.command === next.tool.command &&
387
+ prev.tool.completedAt === next.tool.completedAt &&
388
+ prev.tool.summary === next.tool.summary &&
389
+ prev.cols === next.cols &&
390
+ prev.opacity === next.opacity &&
391
+ prev.isLiveCursorTarget === next.isLiveCursorTarget
392
+ );
393
+
394
+ function CodexThinkingBlock({
395
+ block,
396
+ cols,
397
+ isLiveCursorTarget,
398
+ verboseMode,
399
+ }: {
400
+ block: RunProgressBlock;
401
+ cols: number;
402
+ isLiveCursorTarget: boolean;
403
+ verboseMode: boolean;
404
+ }) {
405
+ const theme = useTheme();
406
+ const contentWidth = Math.max(1, getUsableShellWidth(cols, transcriptContentIndent + 1));
407
+
408
+ return (
409
+ <Box flexDirection="column" width="100%" paddingLeft={transcriptContentIndent} paddingRight={1}>
410
+ <Text color={theme.textMuted} bold>Reasoning</Text>
411
+ {formatProgressBlockBodyLines(block.text, contentWidth)
412
+ .slice(0, verboseMode ? undefined : COMPACT_PROCESSING_BODY_LINE_CAP)
413
+ .map((line, i) => (
414
+ <Text key={i} color={theme.textDim}>{line || " "}</Text>
415
+ ))}
416
+ {isLiveCursorTarget && block.status === "active" && (
417
+ <Text color={theme.accent}>▌</Text>
418
+ )}
419
+ </Box>
420
+ );
421
+ }
422
+
423
+ function CodexResponseBlock({
424
+ run,
425
+ segment,
426
+ cols,
427
+ streaming,
428
+ isLast,
429
+ isLiveCursorTarget,
430
+ verboseMode,
431
+ }: {
432
+ run: RunEvent;
433
+ segment: RunResponseSegment;
434
+ cols: number;
435
+ streaming: boolean;
436
+ isLast: boolean;
437
+ isLiveCursorTarget: boolean;
438
+ verboseMode: boolean;
439
+ }) {
440
+ const theme = useTheme();
441
+ const contentWidth = Math.max(1, getUsableShellWidth(cols, transcriptContentIndent + 1));
442
+
443
+ const formatted = useMemo(() => {
444
+ const raw = formatTerminalAnswerInline(getResponseSegmentText(segment));
445
+ const sanitized = segment.status === "active"
446
+ ? sanitizeStreamChunk(raw)
447
+ : sanitizeOutput(raw);
448
+ const normalized = normalizeOutput(sanitized);
449
+ const classified = classifyOutput(normalized);
450
+ return formatForBox(classified, contentWidth);
451
+ }, [contentWidth, segment]);
452
+
453
+ const segmentStreaming = segment.status === "active";
454
+ const showTail = !segmentStreaming && !verboseMode && formatted.length > COMPACT_STREAMING_TAIL_CAP;
455
+
456
+ return (
457
+ <Box flexDirection="column" width="100%" paddingLeft={transcriptContentIndent} paddingRight={1}>
458
+ <Text color={theme.textMuted} bold>Ubume</Text>
459
+ {run.status === "failed" && !streaming && isLast && (
460
+ <Box flexDirection="column">
461
+ {wrapPlainText(sanitizeTerminalOutput(run.errorMessage ?? run.summary), contentWidth).map((row, i) => (
462
+ <Text key={i} color={theme.error}>{i === 0 ? `✕ ${row}` : ` ${row}`}</Text>
463
+ ))}
464
+ </Box>
465
+ )}
466
+ <MemoizedRenderMessage
467
+ segments={showTail ? formatted.slice(-COMPACT_STREAMING_TAIL_CAP) : formatted}
468
+ width={contentWidth}
469
+ />
470
+ {isLiveCursorTarget && segmentStreaming && (
471
+ <Text color={theme.accent}>▌</Text>
472
+ )}
473
+ </Box>
474
+ );
475
+ }
476
+
477
+ const StreamEventList = memo(function StreamEventList({
478
+ cols,
479
+ run,
480
+ assistant,
481
+ runPhase,
482
+ opacity,
483
+ verboseMode,
484
+ workspaceRoot,
485
+ }: {
486
+ cols: number;
487
+ run: RunEvent;
488
+ assistant: AssistantEvent | null;
489
+ runPhase: TurnRunPhase;
490
+ opacity: TurnOpacity;
491
+ verboseMode: boolean;
492
+ workspaceRoot?: string | null;
493
+ }) {
494
+ const streaming = runPhase === "streaming";
495
+ const events = useMemo(
496
+ () => resolveStreamEvents(run, assistant, streaming),
497
+ [run, assistant, streaming],
498
+ );
499
+
500
+ return (
501
+ <Box flexDirection="column" width="100%">
502
+ {events.map((event, index) => {
503
+ const isLast = index === events.length - 1;
504
+ const isLiveCursorTarget = run.status === "running" && isLast;
505
+
506
+ return (
507
+ <Box key={`${event.kind}-${event.streamSeq}`} flexDirection="column" marginTop={index > 0 ? 1 : 0}>
508
+ {event.kind === "thinking" && (
509
+ <CodexThinkingBlock
510
+ block={event.block}
511
+ cols={cols}
512
+ isLiveCursorTarget={isLiveCursorTarget}
513
+ verboseMode={verboseMode}
514
+ />
515
+ )}
516
+ {event.kind === "action" && (
517
+ <MemoizedActionEventCard
518
+ cols={cols}
519
+ tool={event.tool}
520
+ opacity={opacity}
521
+ isLiveCursorTarget={isLiveCursorTarget}
522
+ />
523
+ )}
524
+ {event.kind === "response" && (
525
+ <CodexResponseBlock
526
+ run={run}
527
+ segment={event.segment}
528
+ cols={cols}
529
+ streaming={streaming}
530
+ isLast={isLast}
531
+ isLiveCursorTarget={isLiveCursorTarget}
532
+ verboseMode={verboseMode}
533
+ />
534
+ )}
535
+ {event.kind === "plan" && (
536
+ <MemoizedPlanPanel
537
+ planText={event.planText}
538
+ cols={cols}
539
+ approved={event.approved}
540
+ workspaceRoot={workspaceRoot}
541
+ />
542
+ )}
543
+ </Box>
544
+ );
545
+ })}
546
+
547
+ {run.status !== "running" && !verboseMode && (
548
+ <Box marginTop={1}>
549
+ <ImpactSummary run={run} cols={cols} />
550
+ </Box>
551
+ )}
552
+ </Box>
553
+ );
554
+ }, (prev, next) => (
555
+ prev.cols === next.cols
556
+ && prev.run === next.run
557
+ && prev.assistant === next.assistant
558
+ && prev.runPhase === next.runPhase
559
+ && prev.opacity === next.opacity
560
+ && prev.verboseMode === next.verboseMode
561
+ && prev.workspaceRoot === next.workspaceRoot
562
+ ));
563
+
564
+ // ─── TurnGroup ───────────────────────────────────────────────────────────────
565
+
566
+ export function TurnGroup({
567
+ cols,
568
+ turnIndex,
569
+ user,
570
+ run,
571
+ assistant,
572
+ opacity,
573
+ question,
574
+ runPhase,
575
+ verboseMode = false,
576
+ workspaceRoot,
577
+ }: TurnGroupProps) {
578
+ return (
579
+ <Box flexDirection="column" width="100%" marginBottom={1}>
580
+ <MemoizedUserInputCard
581
+ prompt={user.prompt}
582
+ cols={cols}
583
+ dim={opacity === "dim"}
584
+ />
585
+
586
+ {run && (
587
+ <Box marginTop={1}>
588
+ <StreamEventList
589
+ cols={cols}
590
+ run={run}
591
+ assistant={assistant}
592
+ runPhase={runPhase}
593
+ opacity={opacity}
594
+ verboseMode={verboseMode}
595
+ workspaceRoot={workspaceRoot}
596
+ />
597
+ </Box>
598
+ )}
599
+
600
+ {!run && assistant && (
601
+ <Box marginTop={1}>
602
+ <AgentBlock
603
+ cols={cols}
604
+ turnIndex={turnIndex}
605
+ assistant={assistant}
606
+ run={null}
607
+ streaming={false}
608
+ dim={opacity === "dim"}
609
+ runPhase="final"
610
+ />
611
+ </Box>
612
+ )}
613
+
614
+ {run && run.status !== "running" && verboseMode && (
615
+ <>
616
+ {run.touchedFileCount > 0 && <FileScanCard run={run} cols={cols} />}
617
+ {/* ActivityCard is skipped because actions are now in the unified stream */}
618
+ </>
619
+ )}
620
+
621
+ {question && <ActionRequiredBlock cols={cols} turnIndex={turnIndex} question={question} />}
622
+ </Box>
623
+ );
624
+ }
625
+
626
+ // Memoized wrapper to prevent re-renders of finalized turns
627
+ export const MemoizedTurnGroup = memo(TurnGroup, (prev, next) => {
628
+ return (
629
+ prev.cols === next.cols &&
630
+ prev.turnIndex === next.turnIndex &&
631
+ prev.opacity === next.opacity &&
632
+ prev.question === next.question &&
633
+ prev.runPhase === next.runPhase &&
634
+ prev.streamPreviewRows === next.streamPreviewRows &&
635
+ prev.streamMode === next.streamMode &&
636
+ prev.verboseMode === next.verboseMode &&
637
+ prev.user === next.user &&
638
+ prev.run === next.run &&
639
+ prev.assistant === next.assistant &&
640
+ prev.workspaceRoot === next.workspaceRoot
641
+ );
642
+ });
643
+
644
+ export type TurnRunPhase = "none" | "thinking" | "streaming" | "final";
645
+
646
+ export function resolveTurnRunPhase(
647
+ run: RunEvent | null,
648
+ assistant: AssistantEvent | null,
649
+ uiState: UIState,
650
+ turnId: number,
651
+ ): TurnRunPhase {
652
+ if (!run) return "none";
653
+ if (run.status !== "running") return "final";
654
+
655
+ if (uiState.kind === "RESPONDING" && uiState.turnId === turnId) {
656
+ return "streaming";
657
+ }
658
+
659
+ if (uiState.kind === "ANSWER_VISIBLE" && uiState.turnId === turnId) {
660
+ return "final";
661
+ }
662
+
663
+ if (uiState.kind === "THINKING" && uiState.turnId === turnId) {
664
+ return "thinking";
665
+ }
666
+
667
+ // Defensive fallback to prevent blank/stale turn cards during rapid state churn.
668
+ if (getAssistantContent(assistant).trim()) {
669
+ return "streaming";
670
+ }
671
+
672
+ return "thinking";
673
+ }