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,771 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import type { BackendProgressUpdate } from "../core/providers/types.js";
3
+ import type { AssistantEvent, ExternalCliStatus, RunEvent, ShellEvent, TimelineEvent, UIState, UserPromptEvent } from "./types.js";
4
+ import { getAssistantContent, getRunPlanText } from "./types.js";
5
+ import {
6
+ appendRunActivity,
7
+ appendRunPlanChunk,
8
+ appendRunResponseChunk,
9
+ appendRunThinking,
10
+ appendStaticEvents,
11
+ cancelRunEvent,
12
+ completeRunEvent,
13
+ failRunEvent,
14
+ finalizePlanBlock,
15
+ finalizeResponseSegments,
16
+ markResponseSegmentsCompleted,
17
+ reduceUIState,
18
+ upsertRunToolActivity,
19
+ type UIStateAction,
20
+ } from "./chatLifecycle.js";
21
+ import type { RunFileActivity } from "../core/workspace/workspaceActivity.js";
22
+ import type { RunToolActivity } from "./types.js";
23
+ import type { LiveRenderUpdate } from "./liveRenderScheduler.js";
24
+ import * as renderDebug from "../core/perf/renderDebug.js";
25
+
26
+ // ─── Types ───────────────────────────────────────────────────────────────────
27
+
28
+ export interface SessionState {
29
+ staticEvents: TimelineEvent[];
30
+ activeEvents: TimelineEvent[];
31
+ uiState: UIState;
32
+ externalCliStatus: ExternalCliStatus;
33
+ inputValue: string;
34
+ cursor: number;
35
+ history: string[];
36
+ historyIndex: number;
37
+ clearCount: number;
38
+ clearEpoch: number; // Incremented on each /clear to suppress stale async events
39
+ }
40
+
41
+ export type SessionAction =
42
+ | { type: "APPEND_STATIC_EVENT"; event: TimelineEvent }
43
+ | { type: "APPEND_STATIC_EVENTS"; events: TimelineEvent[] }
44
+ | { type: "SET_INPUT"; value: string; cursor?: number }
45
+ | { type: "RESET_INPUT" }
46
+ | { type: "PUSH_HISTORY"; value: string }
47
+ | { type: "SUBMIT_PROMPT_RUN"; historyValue?: string; events: TimelineEvent[]; turnId: number; runId: number }
48
+ | { type: "HISTORY_UP" }
49
+ | { type: "HISTORY_DOWN" }
50
+ | { type: "CLEAR_TRANSCRIPT"; seedEvents?: TimelineEvent[] }
51
+ | { type: "SET_ACTIVE_EVENTS"; events: TimelineEvent[] }
52
+ | { type: "RUN_APPEND_ACTIVITY"; runId: number; activity: RunFileActivity[] }
53
+ | { type: "RUN_APPLY_PROGRESS_UPDATES"; runId: number; updates: BackendProgressUpdate[] }
54
+ | { type: "RUN_UPSERT_TOOL_ACTIVITY"; runId: number; activity: RunToolActivity }
55
+ | {
56
+ type: "RUN_APPEND_PLAN_DELTA";
57
+ turnId: number;
58
+ runId: number;
59
+ chunk: string;
60
+ }
61
+ | {
62
+ type: "RUN_APPEND_ASSISTANT_DELTA";
63
+ turnId: number;
64
+ runId: number;
65
+ chunk: string;
66
+ eventFactory: () => AssistantEvent;
67
+ }
68
+ | {
69
+ type: "RUN_MARK_FINAL_ANSWER_OBSERVED";
70
+ runId: number;
71
+ turnId: number;
72
+ response?: string;
73
+ }
74
+ | {
75
+ type: "RUN_APPLY_LIVE_UPDATES";
76
+ turnId: number;
77
+ runId: number;
78
+ updates: LiveRenderUpdate[];
79
+ assistantEventFactory: (chunk: string) => AssistantEvent;
80
+ }
81
+ | {
82
+ type: "FINALIZE_RUN";
83
+ runId: number;
84
+ turnId: number;
85
+ status: "completed" | "failed" | "canceled";
86
+ message?: string;
87
+ response?: string;
88
+ durationMs?: number;
89
+ responsePresentation?: "assistant" | "plan";
90
+ question?: string | null;
91
+ assistantFactory: () => AssistantEvent;
92
+ }
93
+ | { type: "FINALIZE_SHELL"; shellId: number; finalEvent: ShellEvent }
94
+ | { type: "UPDATE_SHELL_LINES"; shellId: number; stream: "stdout" | "stderr"; lines: string[] }
95
+ | { type: "REMOVE_ACTIVE_RUNTIME"; runId: number; turnId?: number | null }
96
+ | { type: "UI_ACTION"; action: UIStateAction }
97
+ | { type: "SET_EXTERNAL_CLI_STATUS"; status: ExternalCliStatus };
98
+
99
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
100
+
101
+ export function createInitialSessionState(options: { staticEvents?: TimelineEvent[] } = {}): SessionState {
102
+ return {
103
+ staticEvents: options.staticEvents ?? [],
104
+ activeEvents: [],
105
+ uiState: { kind: "IDLE" },
106
+ externalCliStatus: "idle",
107
+ inputValue: "",
108
+ cursor: 0,
109
+ history: [],
110
+ historyIndex: -1,
111
+ clearCount: 0,
112
+ clearEpoch: 0,
113
+ };
114
+ }
115
+
116
+ function updateShellLines(event: ShellEvent, action: Extract<SessionAction, { type: "UPDATE_SHELL_LINES" }>): ShellEvent {
117
+ if (action.stream === "stdout") {
118
+ return { ...event, lines: [...event.lines, ...action.lines] };
119
+ }
120
+ return { ...event, stderrLines: [...event.stderrLines, ...action.lines] };
121
+ }
122
+
123
+ export function findUserPrompt(events: TimelineEvent[], turnId: number): UserPromptEvent | null {
124
+ const event = events.find((entry): entry is UserPromptEvent => entry.type === "user" && entry.turnId === turnId);
125
+ return event ?? null;
126
+ }
127
+
128
+ function reconcileAssistantContent(
129
+ streamed: string | undefined,
130
+ response: string | undefined,
131
+ status: "completed" | "failed" | "canceled",
132
+ ): string {
133
+ if (status !== "completed") return streamed?.trim() ? streamed : "";
134
+ if (!response?.trim()) return streamed ?? "";
135
+ if (!streamed?.trim()) return response;
136
+
137
+ const norm = (s: string) => s.replace(/\s+/g, " ").trim();
138
+ const sNorm = norm(streamed);
139
+ const rNorm = norm(response);
140
+
141
+ if (sNorm === rNorm) return streamed; // exact match → keep streamed formatting
142
+ // streamed is a prefix of response, or they differ — authoritative response wins either way
143
+ return response;
144
+ }
145
+
146
+ function isAnimatedLifecycleKind(kind: UIState["kind"]): boolean {
147
+ return kind === "THINKING" || kind === "RESPONDING" || kind === "SHELL_RUNNING";
148
+ }
149
+
150
+ function stateMatchesTurn(state: UIState, turnId: number): boolean {
151
+ return "turnId" in state && state.turnId === turnId;
152
+ }
153
+
154
+ function getUIActionTurnId(action: UIStateAction): number | null {
155
+ return "turnId" in action ? action.turnId : null;
156
+ }
157
+
158
+ function getUIStateTurnId(state: UIState): number | null {
159
+ return "turnId" in state ? state.turnId : null;
160
+ }
161
+
162
+ function traceUITransition(params: {
163
+ previous: UIState;
164
+ next: UIState;
165
+ reason: string;
166
+ runId?: number;
167
+ turnId?: number | null;
168
+ }): void {
169
+ if (params.previous === params.next) return;
170
+
171
+ renderDebug.traceLifecycleTransition({
172
+ runId: params.runId,
173
+ turnId: params.turnId ?? getUIStateTurnId(params.next) ?? getUIStateTurnId(params.previous),
174
+ prevKind: params.previous.kind,
175
+ nextKind: params.next.kind,
176
+ reason: params.reason,
177
+ composerEnabled: !isAnimatedLifecycleKind(params.next.kind),
178
+ animationActive: isAnimatedLifecycleKind(params.next.kind),
179
+ ts: Date.now(),
180
+ });
181
+ }
182
+
183
+ function reduceTracedUIState(
184
+ state: UIState,
185
+ action: UIStateAction,
186
+ options: { reason?: string; runId?: number } = {},
187
+ ): UIState {
188
+ const next = reduceUIState(state, action);
189
+ traceUITransition({
190
+ previous: state,
191
+ next,
192
+ reason: options.reason ?? action.type,
193
+ runId: options.runId,
194
+ turnId: getUIActionTurnId(action),
195
+ });
196
+ return next;
197
+ }
198
+
199
+ function terminalActionForFinalize(action: Extract<SessionAction, { type: "FINALIZE_RUN" }>): UIStateAction {
200
+ if (action.status === "completed") {
201
+ return action.question
202
+ ? { type: "AWAITING_USER_ACTION", turnId: action.turnId, question: action.question }
203
+ : { type: "RUN_COMPLETED", turnId: action.turnId };
204
+ }
205
+
206
+ if (action.status === "failed") {
207
+ return { type: "RUN_FAILED", turnId: action.turnId, message: action.message ?? "Run failed" };
208
+ }
209
+
210
+ return { type: "RUN_CANCELED", turnId: action.turnId };
211
+ }
212
+
213
+ function enforceFinalizePostCondition(
214
+ previous: UIState,
215
+ reduced: UIState,
216
+ action: Extract<SessionAction, { type: "FINALIZE_RUN" }>,
217
+ ): UIState {
218
+ if (!stateMatchesTurn(previous, action.turnId)) {
219
+ return reduced;
220
+ }
221
+
222
+ const forced: UIState = action.status === "completed" && action.question
223
+ ? { kind: "AWAITING_USER_ACTION", turnId: action.turnId, question: action.question }
224
+ : action.status === "failed"
225
+ ? { kind: "ERROR", turnId: action.turnId, message: action.message ?? "Run failed" }
226
+ : { kind: "IDLE" };
227
+
228
+ if (
229
+ reduced.kind === forced.kind
230
+ && getUIStateTurnId(reduced) === getUIStateTurnId(forced)
231
+ && (!("message" in forced) || ("message" in reduced && reduced.message === forced.message))
232
+ && (!("question" in forced) || ("question" in reduced && reduced.question === forced.question))
233
+ ) {
234
+ return reduced;
235
+ }
236
+
237
+ traceUITransition({
238
+ previous: reduced,
239
+ next: forced,
240
+ reason: "FINALIZE_RUN_POST_CONDITION",
241
+ runId: action.runId,
242
+ turnId: action.turnId,
243
+ });
244
+ return forced;
245
+ }
246
+
247
+ function reduceFinalizeUIState(
248
+ state: UIState,
249
+ action: Extract<SessionAction, { type: "FINALIZE_RUN" }>,
250
+ ): UIState {
251
+ const reduced = reduceTracedUIState(
252
+ state,
253
+ terminalActionForFinalize(action),
254
+ { reason: `FINALIZE_RUN:${action.status}`, runId: action.runId },
255
+ );
256
+ return enforceFinalizePostCondition(state, reduced, action);
257
+ }
258
+
259
+ function preserveUIStateIdentity(previous: UIState, next: UIState): UIState {
260
+ if (previous === next) return previous;
261
+ if (previous.kind !== next.kind) return next;
262
+ if ("message" in previous && "message" in next && previous.message !== next.message) return next;
263
+ if ("question" in previous && "question" in next && previous.question !== next.question) return next;
264
+ if ("turnId" in previous && "turnId" in next && previous.turnId !== next.turnId) return next;
265
+ return previous;
266
+ }
267
+
268
+ function eventCount(state: SessionState): number {
269
+ return state.staticEvents.length + state.activeEvents.length;
270
+ }
271
+
272
+ // ─── Reducer ─────────────────────────────────────────────────────────────────
273
+
274
+ export function reduceSessionState(state: SessionState, action: SessionAction): SessionState {
275
+ switch (action.type) {
276
+ case "APPEND_STATIC_EVENT":
277
+ return { ...state, staticEvents: appendStaticEvents(state.staticEvents, [action.event]) };
278
+ case "APPEND_STATIC_EVENTS":
279
+ return { ...state, staticEvents: appendStaticEvents(state.staticEvents, action.events) };
280
+ case "SET_INPUT":
281
+ return {
282
+ ...state,
283
+ inputValue: action.value,
284
+ cursor: Math.max(0, Math.min(action.cursor ?? action.value.length, action.value.length)),
285
+ };
286
+ case "RESET_INPUT":
287
+ return { ...state, inputValue: "", cursor: 0, historyIndex: -1 };
288
+ case "PUSH_HISTORY":
289
+ return {
290
+ ...state,
291
+ history: [action.value, ...state.history.filter((entry) => entry !== action.value)].slice(0, 50),
292
+ historyIndex: -1,
293
+ };
294
+ case "SUBMIT_PROMPT_RUN": {
295
+ const nextHistory = action.historyValue
296
+ ? [action.historyValue, ...state.history.filter((entry) => entry !== action.historyValue)].slice(0, 50)
297
+ : state.history;
298
+ return {
299
+ ...state,
300
+ activeEvents: action.events,
301
+ uiState: reduceTracedUIState(
302
+ state.uiState,
303
+ { type: "PROMPT_RUN_STARTED", turnId: action.turnId },
304
+ { reason: "SUBMIT_PROMPT_RUN", runId: action.runId },
305
+ ),
306
+ inputValue: "",
307
+ cursor: 0,
308
+ history: nextHistory,
309
+ historyIndex: -1,
310
+ };
311
+ }
312
+ case "HISTORY_UP": {
313
+ if (state.history.length === 0) return state;
314
+ const nextIndex = Math.min(state.historyIndex + 1, state.history.length - 1);
315
+ const nextValue = state.history[nextIndex] ?? "";
316
+ return { ...state, historyIndex: nextIndex, inputValue: nextValue, cursor: nextValue.length };
317
+ }
318
+ case "HISTORY_DOWN": {
319
+ if (state.historyIndex <= 0) {
320
+ return { ...state, historyIndex: -1, inputValue: "", cursor: 0 };
321
+ }
322
+ const nextIndex = state.historyIndex - 1;
323
+ const nextValue = state.history[nextIndex] ?? "";
324
+ return { ...state, historyIndex: nextIndex, inputValue: nextValue, cursor: nextValue.length };
325
+ }
326
+ case "CLEAR_TRANSCRIPT":
327
+ renderDebug.traceEvent("transcript", "clear", {
328
+ previousStaticEventsLength: state.staticEvents.length,
329
+ previousActiveEventsLength: state.activeEvents.length,
330
+ seedEventsLength: action.seedEvents?.length ?? 0,
331
+ uiStateKind: state.uiState.kind,
332
+ });
333
+ return {
334
+ ...state,
335
+ staticEvents: action.seedEvents ?? [],
336
+ activeEvents: [],
337
+ uiState: { kind: "IDLE" },
338
+ clearCount: state.clearCount + 1,
339
+ clearEpoch: state.clearEpoch + 1,
340
+ };
341
+ case "SET_ACTIVE_EVENTS":
342
+ renderDebug.traceEvent("transcript", "activeEventsReplace", {
343
+ previousLength: state.activeEvents.length,
344
+ nextLength: action.events.length,
345
+ uiStateKind: state.uiState.kind,
346
+ preventedEmptyIntermediate: action.events.length === 0 && state.activeEvents.length > 0 && isAnimatedLifecycleKind(state.uiState.kind),
347
+ });
348
+ if (action.events.length === 0 && state.activeEvents.length > 0 && isAnimatedLifecycleKind(state.uiState.kind)) {
349
+ renderDebug.traceBlankFrame("Session", {
350
+ reason: "prevented-empty-active-events-replacement",
351
+ previousActiveEventsLength: state.activeEvents.length,
352
+ uiStateKind: state.uiState.kind,
353
+ });
354
+ return state;
355
+ }
356
+ return { ...state, activeEvents: action.events };
357
+ case "RUN_APPEND_ACTIVITY": {
358
+ if (!state.activeEvents.some((event) => event.id === action.runId && event.type === "run")) {
359
+ return state;
360
+ }
361
+ return {
362
+ ...state,
363
+ activeEvents: state.activeEvents.map((event) =>
364
+ event.id === action.runId && event.type === "run"
365
+ ? appendRunActivity(event as RunEvent, action.activity)
366
+ : event
367
+ ),
368
+ };
369
+ }
370
+ case "RUN_APPLY_PROGRESS_UPDATES": {
371
+ if (!state.activeEvents.some((event) => event.id === action.runId && event.type === "run")) {
372
+ return state;
373
+ }
374
+ return {
375
+ ...state,
376
+ activeEvents: state.activeEvents.map((event) =>
377
+ event.id === action.runId && event.type === "run"
378
+ ? appendRunThinking(event as RunEvent, action.updates)
379
+ : event
380
+ ),
381
+ };
382
+ }
383
+ case "RUN_UPSERT_TOOL_ACTIVITY": {
384
+ if (!state.activeEvents.some((event) => event.id === action.runId && event.type === "run")) {
385
+ return state;
386
+ }
387
+ renderDebug.traceEvent("action", "upsert", {
388
+ runId: action.runId,
389
+ actionId: action.activity.id,
390
+ status: action.activity.status,
391
+ });
392
+ return {
393
+ ...state,
394
+ activeEvents: state.activeEvents.map((event) =>
395
+ event.id === action.runId && event.type === "run"
396
+ ? upsertRunToolActivity(event as RunEvent, action.activity)
397
+ : event
398
+ ),
399
+ };
400
+ }
401
+ case "RUN_APPEND_PLAN_DELTA": {
402
+ const existingRun = state.activeEvents.find(
403
+ (event): event is RunEvent =>
404
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
405
+ );
406
+ if (!existingRun) {
407
+ return state;
408
+ }
409
+
410
+ return {
411
+ ...state,
412
+ activeEvents: state.activeEvents.map((event) =>
413
+ event.id === action.runId && event.type === "run"
414
+ ? appendRunPlanChunk(event as RunEvent, action.chunk)
415
+ : event
416
+ ),
417
+ uiState: reduceTracedUIState(
418
+ state.uiState,
419
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
420
+ { runId: action.runId },
421
+ ),
422
+ };
423
+ }
424
+ case "RUN_APPEND_ASSISTANT_DELTA": {
425
+ const existingRun = state.activeEvents.find(
426
+ (event): event is RunEvent =>
427
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
428
+ );
429
+ if (!existingRun) {
430
+ return state;
431
+ }
432
+
433
+ const existingAssistant = state.activeEvents.find(
434
+ (event): event is AssistantEvent => event.type === "assistant" && event.turnId === action.turnId,
435
+ );
436
+
437
+ const updateRun = (event: TimelineEvent): TimelineEvent => (
438
+ event.id === action.runId && event.type === "run"
439
+ ? appendRunResponseChunk(event as RunEvent, action.chunk)
440
+ : event
441
+ );
442
+
443
+ if (existingAssistant) {
444
+ return {
445
+ ...state,
446
+ activeEvents: state.activeEvents.map((event) => {
447
+ if (event.type === "assistant" && event.turnId === action.turnId) {
448
+ return { ...event, contentChunks: [...(event as AssistantEvent).contentChunks, action.chunk] };
449
+ }
450
+ return updateRun(event);
451
+ }),
452
+ uiState: reduceTracedUIState(
453
+ state.uiState,
454
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
455
+ { runId: action.runId },
456
+ ),
457
+ };
458
+ }
459
+
460
+ return {
461
+ ...state,
462
+ activeEvents: [...state.activeEvents.map(updateRun), action.eventFactory()],
463
+ uiState: reduceTracedUIState(
464
+ state.uiState,
465
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
466
+ { runId: action.runId },
467
+ ),
468
+ };
469
+ }
470
+ case "RUN_APPLY_LIVE_UPDATES": {
471
+ const existingRun = state.activeEvents.find(
472
+ (event): event is RunEvent =>
473
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
474
+ );
475
+ if (!existingRun) {
476
+ return state;
477
+ }
478
+
479
+ const existingAssistant = state.activeEvents.find(
480
+ (event): event is AssistantEvent => event.type === "assistant" && event.turnId === action.turnId,
481
+ );
482
+ let nextRun = existingRun;
483
+ let nextAssistant: AssistantEvent | null = existingAssistant ?? null;
484
+ let assistantCreated = false;
485
+ let sawAssistantOrPlanDelta = false;
486
+
487
+ for (const update of action.updates) {
488
+ if (update.type === "activity") {
489
+ nextRun = appendRunActivity(nextRun, update.activity);
490
+ } else if (update.type === "progress") {
491
+ nextRun = appendRunThinking(nextRun, [update.update]);
492
+ } else if (update.type === "tool") {
493
+ renderDebug.traceEvent("action", "upsert", {
494
+ runId: action.runId,
495
+ actionId: update.activity.id,
496
+ status: update.activity.status,
497
+ });
498
+ nextRun = upsertRunToolActivity(nextRun, update.activity);
499
+ } else if (update.type === "plan") {
500
+ sawAssistantOrPlanDelta = true;
501
+ nextRun = appendRunPlanChunk(nextRun, update.chunk);
502
+ } else {
503
+ sawAssistantOrPlanDelta = true;
504
+ nextRun = appendRunResponseChunk(nextRun, update.chunk);
505
+ if (nextAssistant) {
506
+ nextAssistant = {
507
+ ...nextAssistant,
508
+ contentChunks: [...nextAssistant.contentChunks, update.chunk],
509
+ };
510
+ } else {
511
+ nextAssistant = action.assistantEventFactory(update.chunk);
512
+ assistantCreated = true;
513
+ }
514
+ }
515
+ }
516
+
517
+ const activeEvents = state.activeEvents.map((event) => {
518
+ if (event.type === "run" && event.id === action.runId) {
519
+ return nextRun;
520
+ }
521
+ if (nextAssistant && event.type === "assistant" && event.turnId === action.turnId) {
522
+ return nextAssistant;
523
+ }
524
+ return event;
525
+ });
526
+
527
+ const withAssistant = assistantCreated && nextAssistant
528
+ ? [...activeEvents, nextAssistant]
529
+ : activeEvents;
530
+
531
+ return {
532
+ ...state,
533
+ activeEvents: withAssistant,
534
+ uiState: sawAssistantOrPlanDelta
535
+ ? reduceTracedUIState(
536
+ state.uiState,
537
+ { type: "FIRST_ASSISTANT_DELTA", turnId: action.turnId },
538
+ { runId: action.runId },
539
+ )
540
+ : state.uiState,
541
+ };
542
+ }
543
+ case "RUN_MARK_FINAL_ANSWER_OBSERVED": {
544
+ const existingRun = state.activeEvents.find(
545
+ (event): event is RunEvent =>
546
+ event.type === "run" && event.id === action.runId && event.turnId === action.turnId,
547
+ );
548
+ if (!existingRun) {
549
+ return state;
550
+ }
551
+
552
+ return {
553
+ ...state,
554
+ activeEvents: state.activeEvents.map((event) =>
555
+ event.id === action.runId && event.type === "run"
556
+ ? markResponseSegmentsCompleted(
557
+ event as RunEvent,
558
+ // Plan runs keep their text in the plan block; passing the final
559
+ // response here would synthesize a duplicate response segment.
560
+ (event as RunEvent).responsePresentation === "plan" ? undefined : action.response,
561
+ )
562
+ : event
563
+ ),
564
+ uiState: reduceTracedUIState(
565
+ state.uiState,
566
+ { type: "FINAL_ANSWER_VISIBLE", turnId: action.turnId },
567
+ { runId: action.runId },
568
+ ),
569
+ };
570
+ }
571
+ case "FINALIZE_RUN": {
572
+ const userEvent = state.activeEvents.find(
573
+ (event): event is UserPromptEvent => event.type === "user" && event.turnId === action.turnId,
574
+ );
575
+ const runEvent = state.activeEvents.find(
576
+ (event): event is RunEvent => event.type === "run" && event.id === action.runId,
577
+ );
578
+ const assistantEvent = state.activeEvents.find(
579
+ (event): event is AssistantEvent => event.type === "assistant" && event.turnId === action.turnId,
580
+ );
581
+
582
+ const remainingEvents = state.activeEvents.filter((event) =>
583
+ !(event.type === "run" && event.id === action.runId)
584
+ && !(event.type === "assistant" && event.turnId === action.turnId)
585
+ && !(event.type === "user" && event.turnId === action.turnId),
586
+ );
587
+
588
+ if (!runEvent) {
589
+ renderDebug.traceEvent("transcript", "finalizeWithoutRunEvent", {
590
+ runId: action.runId,
591
+ turnId: action.turnId,
592
+ remainingEventsLength: remainingEvents.length,
593
+ });
594
+ return {
595
+ ...state,
596
+ activeEvents: remainingEvents,
597
+ uiState: reduceFinalizeUIState(state.uiState, action),
598
+ };
599
+ }
600
+
601
+ const baseFinalizedRun =
602
+ action.status === "completed"
603
+ ? completeRunEvent(runEvent, action.durationMs)
604
+ : action.status === "failed"
605
+ ? failRunEvent(runEvent, action.message ?? "Run failed", action.message ?? "Run failed", action.durationMs)
606
+ : cancelRunEvent(runEvent, action.durationMs);
607
+
608
+ const planPresentation = action.responsePresentation === "plan";
609
+ if (planPresentation) {
610
+ // The plan is the text streamed after the last tool call (earlier text
611
+ // was demoted to prose segments). The backend's final response contains
612
+ // that chatter too, so only fall back to it when nothing was streamed.
613
+ const streamedPlan = getRunPlanText(runEvent.plan);
614
+ const planContent = streamedPlan.trim()
615
+ ? streamedPlan
616
+ : reconcileAssistantContent("", action.response, action.status);
617
+ const finalizedRun = finalizePlanBlock(finalizeResponseSegments(baseFinalizedRun), planContent);
618
+
619
+ const additions: TimelineEvent[] = [];
620
+ if (userEvent) additions.push(userEvent);
621
+ additions.push(finalizedRun);
622
+
623
+ return {
624
+ ...state,
625
+ staticEvents: appendStaticEvents(state.staticEvents, additions),
626
+ activeEvents: remainingEvents,
627
+ uiState: reduceFinalizeUIState(state.uiState, action),
628
+ };
629
+ }
630
+
631
+ const streamedContent = getAssistantContent(assistantEvent);
632
+ const assistantContent = reconcileAssistantContent(
633
+ streamedContent,
634
+ action.response,
635
+ action.status,
636
+ );
637
+
638
+ // Reconcile response segments with the authoritative final text.
639
+ // If the authoritative text differs from streamed (or no segments exist),
640
+ // rewrite/synthesize the trailing segment so the rendered timeline
641
+ // shows the final answer in chronological position.
642
+ const trimmedFinal = assistantContent.trim();
643
+ const streamedTrim = streamedContent.trim();
644
+ const overrideSegmentText = trimmedFinal && trimmedFinal !== streamedTrim
645
+ ? assistantContent
646
+ : undefined;
647
+ const finalizedRun = finalizeResponseSegments(baseFinalizedRun, overrideSegmentText);
648
+
649
+ const additions: TimelineEvent[] = [];
650
+ if (userEvent) additions.push(userEvent);
651
+ additions.push(finalizedRun);
652
+ if (assistantContent.trim()) {
653
+ additions.push(
654
+ assistantEvent
655
+ ? { ...assistantEvent, content: assistantContent, contentChunks: [] }
656
+ : action.assistantFactory(),
657
+ );
658
+ }
659
+
660
+ return {
661
+ ...state,
662
+ staticEvents: appendStaticEvents(state.staticEvents, additions),
663
+ activeEvents: remainingEvents,
664
+ uiState: reduceFinalizeUIState(state.uiState, action),
665
+ };
666
+ }
667
+ case "FINALIZE_SHELL":
668
+ return {
669
+ ...state,
670
+ staticEvents: appendStaticEvents(state.staticEvents, [action.finalEvent]),
671
+ activeEvents: state.activeEvents.filter((event) => !(event.type === "shell" && event.id === action.shellId)),
672
+ uiState: reduceTracedUIState(state.uiState, { type: "SHELL_FINISHED", shellId: action.shellId }),
673
+ };
674
+ case "UPDATE_SHELL_LINES":
675
+ return {
676
+ ...state,
677
+ activeEvents: state.activeEvents.map((event) =>
678
+ event.id === action.shellId && event.type === "shell"
679
+ ? updateShellLines(event as ShellEvent, action)
680
+ : event
681
+ ),
682
+ };
683
+ case "REMOVE_ACTIVE_RUNTIME":
684
+ return {
685
+ ...state,
686
+ activeEvents: state.activeEvents.filter((event) =>
687
+ !(event.type === "run" && event.id === action.runId)
688
+ && !(event.type === "assistant" && action.turnId != null && event.turnId === action.turnId)
689
+ && !(event.type === "shell" && event.id === action.runId)
690
+ && !(event.type === "user" && action.turnId != null && event.turnId === action.turnId),
691
+ ),
692
+ };
693
+ case "UI_ACTION":
694
+ return { ...state, uiState: reduceTracedUIState(state.uiState, action.action) };
695
+ case "SET_EXTERNAL_CLI_STATUS":
696
+ if (state.externalCliStatus === action.status) return state;
697
+ return { ...state, externalCliStatus: action.status };
698
+ default:
699
+ return state;
700
+ }
701
+ }
702
+
703
+ // ─── Hook ────────────────────────────────────────────────────────────────────
704
+
705
+ export function useAppSessionState(initialStaticEvents?: () => TimelineEvent[]) {
706
+ const [state, setState] = useState<SessionState>(() =>
707
+ createInitialSessionState({ staticEvents: initialStaticEvents?.() ?? [] }),
708
+ );
709
+ const queueRef = useRef<SessionAction[]>([]);
710
+ const scheduledRef = useRef(false);
711
+
712
+ const dispatch = useCallback((action: SessionAction) => {
713
+ queueRef.current.push(action);
714
+ if (scheduledRef.current) return;
715
+
716
+ scheduledRef.current = true;
717
+ queueMicrotask(() => {
718
+ scheduledRef.current = false;
719
+ const queued = queueRef.current.splice(0, queueRef.current.length);
720
+ if (queued.length === 0) return;
721
+
722
+ renderDebug.traceTimelineUpdate({
723
+ queuedActions: queued.length,
724
+ actionTypes: queued.map((item) => item.type),
725
+ });
726
+ setState((current) => {
727
+ const next = queued.reduce(reduceSessionState, current);
728
+ const previousCount = eventCount(current);
729
+ const nextCount = eventCount(next);
730
+ if (
731
+ previousCount !== nextCount
732
+ || current.staticEvents.length !== next.staticEvents.length
733
+ || current.activeEvents.length !== next.activeEvents.length
734
+ ) {
735
+ renderDebug.traceEvent("transcript", "eventArrayLengthChange", {
736
+ actionTypes: queued.map((item) => item.type),
737
+ previousTotalLength: previousCount,
738
+ nextTotalLength: nextCount,
739
+ previousStaticEventsLength: current.staticEvents.length,
740
+ nextStaticEventsLength: next.staticEvents.length,
741
+ previousActiveEventsLength: current.activeEvents.length,
742
+ nextActiveEventsLength: next.activeEvents.length,
743
+ previousUiStateKind: current.uiState.kind,
744
+ nextUiStateKind: next.uiState.kind,
745
+ });
746
+ }
747
+ if (previousCount > 0 && nextCount === 0) {
748
+ renderDebug.traceBlankFrame("Session", {
749
+ reason: "transcript-length-dropped-to-zero",
750
+ actionTypes: queued.map((item) => item.type),
751
+ previousTotalLength: previousCount,
752
+ previousStaticEventsLength: current.staticEvents.length,
753
+ previousActiveEventsLength: current.activeEvents.length,
754
+ nextUiStateKind: next.uiState.kind,
755
+ });
756
+ }
757
+ // Preserve uiState identity when nothing meaningful changed,
758
+ // so AppShell's memo check (prev.uiState === next.uiState) can bail out.
759
+ if (next !== current && next.uiState !== current.uiState) {
760
+ const preserved = preserveUIStateIdentity(current.uiState, next.uiState);
761
+ if (preserved === current.uiState) {
762
+ return { ...next, uiState: preserved };
763
+ }
764
+ }
765
+ return next;
766
+ });
767
+ });
768
+ }, []);
769
+
770
+ return { state, dispatch };
771
+ }