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,145 @@
1
+ /**
2
+ * Shared utility for windowing large lists in limited terminal rows.
3
+ */
4
+
5
+ export interface ListWindowOptions {
6
+ itemCount: number;
7
+ selectedIndex: number;
8
+ availableRows: number;
9
+ /** Fixed rows consumed by headers, borders, titles, etc. */
10
+ chromeRows: number;
11
+ /** Whether to show "↑ N more" and "↓ N more" indicators. */
12
+ showIndicators?: boolean;
13
+ /** Whether to show "Showing X-Y of N" range line. */
14
+ showRangeLine?: boolean;
15
+ }
16
+
17
+ export interface ListWindowResult {
18
+ start: number;
19
+ end: number;
20
+ visibleCount: number;
21
+ showAbove: boolean;
22
+ showBelow: boolean;
23
+ showRange: boolean;
24
+ hiddenAbove: number;
25
+ hiddenBelow: number;
26
+ /** Total rows consumed including chrome and indicators. */
27
+ totalRows: number;
28
+ }
29
+
30
+ /**
31
+ * Calculates a contiguous window of items that fits within available rows,
32
+ * ensuring the selected item is always visible.
33
+ */
34
+ export function calculateListWindow({
35
+ itemCount,
36
+ selectedIndex,
37
+ availableRows,
38
+ chromeRows,
39
+ showIndicators = true,
40
+ showRangeLine = false,
41
+ }: ListWindowOptions): ListWindowResult {
42
+ if (itemCount === 0) {
43
+ return {
44
+ start: 0,
45
+ end: 0,
46
+ visibleCount: 0,
47
+ showAbove: false,
48
+ showBelow: false,
49
+ showRange: false,
50
+ hiddenAbove: 0,
51
+ hiddenBelow: 0,
52
+ totalRows: chromeRows,
53
+ };
54
+ }
55
+
56
+ const rangeReserved = showRangeLine ? 1 : 0;
57
+
58
+ // If everything fits including the range line, just show everything.
59
+ if (itemCount + chromeRows + rangeReserved <= availableRows) {
60
+ return {
61
+ start: 0,
62
+ end: itemCount,
63
+ visibleCount: itemCount,
64
+ showAbove: false,
65
+ showBelow: false,
66
+ showRange: showRangeLine,
67
+ hiddenAbove: 0,
68
+ hiddenBelow: 0,
69
+ totalRows: itemCount + chromeRows + rangeReserved,
70
+ };
71
+ }
72
+
73
+ // Otherwise we must window.
74
+ // We reserve space for indicators if requested.
75
+ // We MUST fit within availableRows.
76
+ const indicatorReserved = showIndicators ? 2 : 0;
77
+ const baseReserved = chromeRows + indicatorReserved + rangeReserved;
78
+
79
+ let itemRows = Math.max(1, availableRows - baseReserved);
80
+ let start = 0;
81
+ let end = 0;
82
+
83
+ // Iteratively refine to account for dynamic indicators.
84
+ for (let attempt = 0; attempt < 3; attempt++) {
85
+ const visibleCount = Math.max(1, Math.min(itemCount, itemRows));
86
+
87
+ // Center selected item if possible
88
+ start = Math.max(0, selectedIndex - Math.floor(visibleCount / 2));
89
+ if (start + visibleCount > itemCount) {
90
+ start = Math.max(0, itemCount - visibleCount);
91
+ }
92
+ end = Math.min(itemCount, start + visibleCount);
93
+
94
+ const actualAbove = start > 0;
95
+ const actualBelow = end < itemCount;
96
+ const dynamicReserved = chromeRows
97
+ + (showRangeLine ? 1 : 0)
98
+ + (showIndicators && actualAbove ? 1 : 0)
99
+ + (showIndicators && actualBelow ? 1 : 0);
100
+
101
+ const nextItemRows = Math.max(1, availableRows - dynamicReserved);
102
+ if (nextItemRows === itemRows) break;
103
+ itemRows = nextItemRows;
104
+ }
105
+
106
+ // Final visibility check for selectedIndex
107
+ if (selectedIndex < start) {
108
+ start = selectedIndex;
109
+ end = Math.min(itemCount, start + itemRows);
110
+ } else if (selectedIndex >= end) {
111
+ end = selectedIndex + 1;
112
+ start = Math.max(0, end - itemRows);
113
+ }
114
+
115
+ // Final clamping to ensure we don't exceed availableRows if indicators are shown
116
+ let finalAbove = showIndicators && start > 0;
117
+ let finalBelow = showIndicators && end < itemCount;
118
+
119
+ while (end - start + chromeRows + (showRangeLine ? 1 : 0) + (finalAbove ? 1 : 0) + (finalBelow ? 1 : 0) > availableRows && end - start > 1) {
120
+ // Shrink window from the side further from selectedIndex
121
+ if (selectedIndex - start > end - 1 - selectedIndex) {
122
+ start++;
123
+ } else {
124
+ end--;
125
+ }
126
+ finalAbove = showIndicators && start > 0;
127
+ finalBelow = showIndicators && end < itemCount;
128
+ }
129
+
130
+ return {
131
+ start,
132
+ end,
133
+ visibleCount: end - start,
134
+ showAbove: finalAbove,
135
+ showBelow: finalBelow,
136
+ showRange: showRangeLine,
137
+ hiddenAbove: start,
138
+ hiddenBelow: itemCount - end,
139
+ totalRows: (end - start)
140
+ + chromeRows
141
+ + (showRangeLine ? 1 : 0)
142
+ + (finalAbove ? 1 : 0)
143
+ + (finalBelow ? 1 : 0),
144
+ };
145
+ }
@@ -0,0 +1,68 @@
1
+ import type { TimelineRow } from "./timelineMeasure.js";
2
+ import { buildFrameElisionRow } from "./timelineMeasure.js";
3
+
4
+ /**
5
+ * Rows kept free below the live window to absorb composer-measurement drift.
6
+ * Ink erases the scrollback (`\x1b[2J\x1b[3J\x1b[H`) on every frame whose live
7
+ * output is taller than the terminal, which drops the user to the bottom while
8
+ * they are reading scrollback. The window must therefore stay strictly inside
9
+ * the viewport even when the composer is one row taller than measured.
10
+ */
11
+ export const LIVE_WINDOW_SAFETY_ROWS = 1;
12
+
13
+ /** Rows a re-capped card costs: its own top border plus the elision notice. */
14
+ const FRAME_RECAP_ROWS = 2;
15
+
16
+ /**
17
+ * Walk back from `start` to the top row of the card `start` sits inside.
18
+ * Returns null when the slice does not cut a card open — either the row carries
19
+ * no frame at all, or it already is a card's top row.
20
+ */
21
+ function findOpenFrameTop(
22
+ rows: TimelineRow[],
23
+ start: number,
24
+ ): { topRow: TimelineRow; topIndex: number } | null {
25
+ const frame = rows[start]?.frame;
26
+ if (!frame || frame.role === "top") return null;
27
+
28
+ for (let index = start - 1; index >= 0; index -= 1) {
29
+ const candidate = rows[index];
30
+ const candidateFrame = candidate?.frame;
31
+ if (!candidateFrame || candidateFrame.id !== frame.id) return null;
32
+ if (candidateFrame.role === "top") return { topRow: candidate!, topIndex: index };
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Keep only the last `maxRows` live rows. Returns the input array itself when
40
+ * it already fits so memoized consumers keep their identity.
41
+ *
42
+ * The window may only ever shrink — growing past `maxRows` is what makes Ink
43
+ * wipe the scrollback. So when the cut lands inside a bordered card, the card is
44
+ * re-capped rather than extended: two rows of tail are traded for the card's own
45
+ * top border plus an `⋯ N rows hidden` notice, which keeps the box complete
46
+ * instead of leaving a headless frame that starts mid-sentence.
47
+ */
48
+ export function windowLiveRows(rows: TimelineRow[], maxRows: number): TimelineRow[] {
49
+ const limit = Math.max(0, Math.floor(maxRows));
50
+ if (rows.length <= limit) return rows;
51
+ if (limit === 0) return [];
52
+
53
+ const plainStart = rows.length - limit;
54
+ if (limit <= FRAME_RECAP_ROWS + 1 || !findOpenFrameTop(rows, plainStart)) {
55
+ return rows.slice(plainStart);
56
+ }
57
+
58
+ const recapStart = rows.length - (limit - FRAME_RECAP_ROWS);
59
+ const frame = findOpenFrameTop(rows, recapStart);
60
+ if (!frame) return rows.slice(plainStart);
61
+
62
+ const hiddenRows = recapStart - frame.topIndex - 1;
63
+ return [
64
+ frame.topRow,
65
+ buildFrameElisionRow(frame.topRow, hiddenRows),
66
+ ...rows.slice(recapStart),
67
+ ];
68
+ }
@@ -0,0 +1,156 @@
1
+ import type { RunProgressBlock, RunProgressEntry, RunProgressSource } from "../../session/types.js";
2
+ import { sanitizeTerminalOutput } from "../../core/terminal/terminalSanitize.js";
3
+ import { wrapPlainText } from "../render/textLayout.js";
4
+
5
+ // ─── Types ───────────────────────────────────────────────────────────────────
6
+
7
+ export interface VisibleProgressBlock {
8
+ id: string;
9
+ key: string;
10
+ label: string;
11
+ headline: string;
12
+ text: string;
13
+ source: RunProgressSource;
14
+ entryId: string;
15
+ entrySequence: number;
16
+ blockSequence: number;
17
+ status: RunProgressBlock["status"];
18
+ isLatest: boolean;
19
+ isActive: boolean;
20
+ }
21
+
22
+ export interface VisibleProgressBlocks {
23
+ hiddenCount: number;
24
+ totalCount: number;
25
+ blocks: VisibleProgressBlock[];
26
+ latestBlock: VisibleProgressBlock | null;
27
+ latestActiveBlock: VisibleProgressBlock | null;
28
+ }
29
+
30
+ // ─── Block selection ─────────────────────────────────────────────────────────
31
+
32
+ function sourceLabel(source: RunProgressSource): string {
33
+ switch (source) {
34
+ case "reasoning":
35
+ return "Reasoning";
36
+ case "todo":
37
+ return "Todo";
38
+ case "tool":
39
+ return "Tool";
40
+ case "activity":
41
+ return "Activity";
42
+ case "stderr":
43
+ return "Process";
44
+ case "transcript":
45
+ return "Progress";
46
+ default:
47
+ return "Update";
48
+ }
49
+ }
50
+
51
+ function firstMeaningfulLine(text: string): string {
52
+ return sanitizeTerminalOutput(text)
53
+ .replace(/\r\n/g, "\n")
54
+ .replace(/\r/g, "\n")
55
+ .split("\n")
56
+ .map((line) => line.trim())
57
+ .find(Boolean) ?? "";
58
+ }
59
+
60
+ function buildProgressHeadline(source: RunProgressSource, text: string, status: RunProgressBlock["status"]): string {
61
+ const label = status === "active" ? "Current" : sourceLabel(source);
62
+ const firstLine = firstMeaningfulLine(text);
63
+ return firstLine ? `${label}: ${firstLine}` : label;
64
+ }
65
+
66
+ function toVisibleProgressBlocks(entries: RunProgressEntry[]): VisibleProgressBlock[] {
67
+ const blocks: VisibleProgressBlock[] = [];
68
+ let visibleSequence = 0;
69
+
70
+ for (const entry of entries) {
71
+ for (const block of entry.blocks) {
72
+ if (!block.text.trim()) continue;
73
+ visibleSequence += 1;
74
+ blocks.push({
75
+ id: block.id,
76
+ key: block.id,
77
+ label: `Update ${visibleSequence}`,
78
+ headline: buildProgressHeadline(entry.source, block.text, block.status),
79
+ text: block.text,
80
+ source: entry.source,
81
+ entryId: entry.id,
82
+ entrySequence: entry.sequence,
83
+ blockSequence: block.sequence,
84
+ status: block.status,
85
+ isLatest: false,
86
+ isActive: block.status === "active",
87
+ });
88
+ }
89
+ }
90
+
91
+ return blocks.map((block, index) => ({
92
+ ...block,
93
+ isLatest: index === blocks.length - 1,
94
+ }));
95
+ }
96
+
97
+ export function getProgressUpdateCount(entries: RunProgressEntry[]): number {
98
+ return toVisibleProgressBlocks(entries).length;
99
+ }
100
+
101
+ export function selectVisibleProgressBlocks(entries: RunProgressEntry[], maxVisible: number): VisibleProgressBlocks {
102
+ const blocks = toVisibleProgressBlocks(entries);
103
+ const safeMax = Math.max(0, maxVisible);
104
+ const findLatestActiveBlock = (candidates: VisibleProgressBlock[]): VisibleProgressBlock | null => {
105
+ for (let index = candidates.length - 1; index >= 0; index -= 1) {
106
+ if (candidates[index]?.isActive) {
107
+ return candidates[index]!;
108
+ }
109
+ }
110
+ return null;
111
+ };
112
+
113
+ if (blocks.length <= safeMax) {
114
+ return {
115
+ hiddenCount: 0,
116
+ totalCount: blocks.length,
117
+ blocks,
118
+ latestBlock: blocks[blocks.length - 1] ?? null,
119
+ latestActiveBlock: findLatestActiveBlock(blocks),
120
+ };
121
+ }
122
+
123
+ const visible = blocks.slice(-safeMax);
124
+ return {
125
+ hiddenCount: blocks.length - safeMax,
126
+ totalCount: blocks.length,
127
+ blocks: visible,
128
+ latestBlock: visible[visible.length - 1] ?? null,
129
+ latestActiveBlock: findLatestActiveBlock(visible),
130
+ };
131
+ }
132
+
133
+ export function formatProgressBlockBodyLines(text: string, width: number): string[] {
134
+ const contentWidth = Math.max(1, width);
135
+ const normalized = sanitizeTerminalOutput(text)
136
+ .replace(/\r\n/g, "\n")
137
+ .replace(/\r/g, "\n")
138
+ .replace(/[ \t]+\n/g, "\n");
139
+
140
+ if (!normalized.trim()) {
141
+ return [" "];
142
+ }
143
+
144
+ const rows: string[] = [];
145
+ for (const rawLine of normalized.split("\n")) {
146
+ if (!rawLine.trim()) {
147
+ rows.push("");
148
+ continue;
149
+ }
150
+
151
+ const wrapped = wrapPlainText(rawLine, contentWidth);
152
+ rows.push(...(wrapped.length > 0 ? wrapped : [" "]));
153
+ }
154
+
155
+ return rows.length > 0 ? rows : [" "];
156
+ }
@@ -0,0 +1,37 @@
1
+ import type { RunEvent } from "../../session/types.js";
2
+
3
+ const MAX_VISIBLE_ACTIVE_ACTIVITY = 6;
4
+ const MAX_VISIBLE_SUMMARY_ACTIVITY = 4;
5
+ export function selectVisibleRunActivity(event: RunEvent) {
6
+ const source = event.status === "running"
7
+ ? event.activity
8
+ : event.activitySummary?.recent ?? event.activity;
9
+ const limit = event.status === "running" ? MAX_VISIBLE_ACTIVE_ACTIVITY : MAX_VISIBLE_SUMMARY_ACTIVITY;
10
+ const visible = source.slice(-limit);
11
+ return {
12
+ visible,
13
+ hiddenCount: Math.max(0, source.length - visible.length),
14
+ };
15
+ }
16
+
17
+ export function shouldShowRawOutputFallback(event: RunEvent): boolean {
18
+ void event;
19
+ return false;
20
+ }
21
+
22
+ export function getVisibleRawOutputLines(event: RunEvent): string[] {
23
+ void event;
24
+ return [];
25
+ }
26
+
27
+ export function formatRunActivityStats(event: RunEvent): string | null {
28
+ const summary = event.activitySummary;
29
+ if (!summary || event.touchedFileCount === 0) return null;
30
+
31
+ return [
32
+ `${event.touchedFileCount} file${event.touchedFileCount === 1 ? "" : "s"} touched`,
33
+ `+${summary.created}`,
34
+ `~${summary.modified}`,
35
+ `-${summary.deleted}`,
36
+ ].join(" ");
37
+ }
@@ -0,0 +1,174 @@
1
+ import type { AssistantEvent, ErrorEvent, RunEvent, ShellEvent, SystemEvent, UserPromptEvent } from "../../session/types.js";
2
+ import type { RenderTimelineItem } from "./Timeline.js";
3
+ import type { TurnOpacity } from "./TurnGroup.js";
4
+ import { buildNativeTranscriptParts, type NativeTranscriptRowItem, type TimelineRow } from "./timelineMeasure.js";
5
+
6
+ /**
7
+ * Incremental row builder for the finalized (static) half of the transcript.
8
+ *
9
+ * Ink's <Static> prints each item once and never re-renders it, and finalized
10
+ * timeline events are immutable, so rebuilding every past turn's rows on each
11
+ * React commit (every keystroke, every streaming flush) is pure waste that
12
+ * grows with session length. This cache keys rows by the render item's key
13
+ * and revalidates by event identity plus the derived opacity, so a commit
14
+ * that changes nothing in the static half costs one Map lookup per item.
15
+ *
16
+ * Retention: only the newest MAX_RETAINED_STATIC_TURNS turns keep row objects.
17
+ * Older turns still emit placeholder items (with no rows) so the <Static> item
18
+ * count stays monotonic — Ink advances its printed index by items.length, so a
19
+ * shrinking list would swallow the next newly finalized turn. A turn that
20
+ * leaves the window keeps exactly the item keys it had while retained; those
21
+ * keys survive generation changes so the count never drops. Their rows are
22
+ * already in the terminal's scrollback; the only visible effect is that a
23
+ * width resize (which re-flushes <Static> from these items) redraws just the
24
+ * retained window.
25
+ */
26
+ export const MAX_RETAINED_STATIC_TURNS = 200;
27
+
28
+ const EMPTY_ROWS: TimelineRow[] = Object.freeze([]) as unknown as TimelineRow[];
29
+
30
+ interface CachedTurnEntry {
31
+ kind: "turn";
32
+ user: UserPromptEvent | null;
33
+ run: RunEvent | null;
34
+ assistant: AssistantEvent | null;
35
+ opacity: TurnOpacity;
36
+ items: NativeTranscriptRowItem[];
37
+ }
38
+
39
+ interface CachedEventEntry {
40
+ kind: "event";
41
+ event: SystemEvent | ErrorEvent | ShellEvent;
42
+ items: NativeTranscriptRowItem[];
43
+ }
44
+
45
+ type CachedEntry = CachedTurnEntry | CachedEventEntry;
46
+
47
+ export interface StaticTranscriptBuildOptions {
48
+ totalWidth: number;
49
+ verboseMode: boolean;
50
+ workspaceRoot: string | null;
51
+ }
52
+
53
+ export interface StaticTranscriptCache {
54
+ generation: string;
55
+ entries: Map<string, CachedEntry>;
56
+ /** Item keys of turns outside the retention window; kept across generations. */
57
+ placeholders: Map<string, string[]>;
58
+ }
59
+
60
+ export function createStaticTranscriptCache(): StaticTranscriptCache {
61
+ return { generation: "", entries: new Map(), placeholders: new Map() };
62
+ }
63
+
64
+ export function staticTranscriptGeneration(options: StaticTranscriptBuildOptions): string {
65
+ return `${options.totalWidth}|${options.verboseMode ? 1 : 0}|${options.workspaceRoot ?? ""}`;
66
+ }
67
+
68
+ function isCacheHit(cached: CachedEntry, item: RenderTimelineItem): boolean {
69
+ if (item.type === "event") return cached.kind === "event" && cached.event === item.event;
70
+ if (item.type !== "turn" || cached.kind !== "turn") return false;
71
+ return cached.user === item.item.user
72
+ && cached.run === item.item.run
73
+ && cached.assistant === item.item.assistant
74
+ && cached.opacity === item.renderState.opacity;
75
+ }
76
+
77
+ function isCacheable(item: RenderTimelineItem, liveRowCount: number): boolean {
78
+ if (item.type === "event") return true;
79
+ if (item.type !== "turn") return false;
80
+ return item.item.run?.status !== "running" && item.renderState.question === null && liveRowCount === 0;
81
+ }
82
+
83
+ /**
84
+ * Build native row items for the given render items, reusing cached rows for
85
+ * every item whose inputs are unchanged. Output is identical to a single
86
+ * batched buildNativeTranscriptParts call over the same items, except that
87
+ * turns outside the retention window carry empty rows.
88
+ */
89
+ export function buildStaticTranscript(
90
+ cache: StaticTranscriptCache,
91
+ renderItems: RenderTimelineItem[],
92
+ options: StaticTranscriptBuildOptions,
93
+ ): { staticItems: NativeTranscriptRowItem[]; liveRows: TimelineRow[] } {
94
+ const generation = staticTranscriptGeneration(options);
95
+ if (cache.generation !== generation) {
96
+ cache.generation = generation;
97
+ cache.entries.clear();
98
+ }
99
+
100
+ const buildOptions = {
101
+ totalWidth: options.totalWidth,
102
+ verboseMode: options.verboseMode,
103
+ workspaceRoot: options.workspaceRoot,
104
+ debugLabel: "transcript-shell-static",
105
+ };
106
+ const staticItems: NativeTranscriptRowItem[] = [];
107
+ const liveRows: TimelineRow[] = [];
108
+ const seen = new Set<string>();
109
+
110
+ let turnCount = 0;
111
+ for (const item of renderItems) if (item.type === "turn") turnCount += 1;
112
+ const firstRetainedTurnOrdinal = Math.max(0, turnCount - MAX_RETAINED_STATIC_TURNS);
113
+ let turnOrdinal = 0;
114
+
115
+ for (const item of renderItems) {
116
+ if (item.type === "intro") {
117
+ staticItems.push(...buildNativeTranscriptParts([item], buildOptions).staticItems);
118
+ continue;
119
+ }
120
+
121
+ if (item.type === "turn") {
122
+ const ordinal = turnOrdinal;
123
+ turnOrdinal += 1;
124
+ if (ordinal < firstRetainedTurnOrdinal) {
125
+ let keys = cache.placeholders.get(item.key);
126
+ if (!keys) {
127
+ // Leaving the window: keep this turn's item keys so the count stays
128
+ // exactly what Ink already printed. A turn first seen outside the
129
+ // window was never printed by this <Static>, so one item suffices.
130
+ keys = cache.entries.get(item.key)?.items.map((entry) => entry.key) ?? [item.key];
131
+ cache.placeholders.set(item.key, keys);
132
+ cache.entries.delete(item.key);
133
+ }
134
+ for (const key of keys) staticItems.push({ key, rows: EMPTY_ROWS });
135
+ continue;
136
+ }
137
+ }
138
+
139
+ const cached = cache.entries.get(item.key);
140
+ if (cached && isCacheHit(cached, item)) {
141
+ staticItems.push(...cached.items);
142
+ seen.add(item.key);
143
+ continue;
144
+ }
145
+
146
+ const parts = buildNativeTranscriptParts([item], buildOptions);
147
+ staticItems.push(...parts.staticItems);
148
+ liveRows.push(...parts.liveRows);
149
+ if (!isCacheable(item, parts.liveRows.length)) continue;
150
+
151
+ cache.entries.set(
152
+ item.key,
153
+ item.type === "event"
154
+ ? { kind: "event", event: item.event, items: parts.staticItems }
155
+ : {
156
+ kind: "turn",
157
+ user: item.item.user,
158
+ run: item.item.run,
159
+ assistant: item.item.assistant,
160
+ opacity: item.renderState.opacity,
161
+ items: parts.staticItems,
162
+ },
163
+ );
164
+ seen.add(item.key);
165
+ }
166
+
167
+ if (cache.entries.size > seen.size) {
168
+ for (const key of cache.entries.keys()) {
169
+ if (!seen.has(key)) cache.entries.delete(key);
170
+ }
171
+ }
172
+
173
+ return { staticItems, liveRows };
174
+ }
@@ -0,0 +1,53 @@
1
+ import type { RunProgressBlock } from "../../session/types.js";
2
+
3
+ interface ThinkingLikeEvent {
4
+ kind: string;
5
+ streamSeq: number;
6
+ block: RunProgressBlock;
7
+ }
8
+
9
+ /**
10
+ * Merge each run of consecutive `kind === "thinking"` events into a single
11
+ * event so contiguous chain-of-thought renders under one header. Local models
12
+ * emit every reasoning paragraph as its own progress item, which otherwise
13
+ * stacks one labeled block per sentence. Thoughts separated by a tool call or
14
+ * response segment stay separate — only uninterrupted runs merge.
15
+ */
16
+ export function coalesceConsecutiveThinking<T extends { kind: string; streamSeq: number }>(events: T[]): T[] {
17
+ const result: T[] = [];
18
+ let index = 0;
19
+ while (index < events.length) {
20
+ const event = events[index]!;
21
+ if (event.kind !== "thinking") {
22
+ result.push(event);
23
+ index += 1;
24
+ continue;
25
+ }
26
+
27
+ let end = index + 1;
28
+ while (end < events.length && events[end]!.kind === "thinking") {
29
+ end += 1;
30
+ }
31
+ if (end - index === 1) {
32
+ result.push(event);
33
+ index = end;
34
+ continue;
35
+ }
36
+
37
+ const members = events.slice(index, end) as unknown as ThinkingLikeEvent[];
38
+ const first = members[0]!;
39
+ const last = members[members.length - 1]!;
40
+ const mergedBlock: RunProgressBlock = {
41
+ ...first.block,
42
+ text: members
43
+ .map((member) => member.block.text)
44
+ .filter((text) => text.trim().length > 0)
45
+ .join("\n\n"),
46
+ updatedAt: Math.max(...members.map((member) => member.block.updatedAt)),
47
+ status: last.block.status,
48
+ };
49
+ result.push({ ...event, block: mergedBlock });
50
+ index = end;
51
+ }
52
+ return result;
53
+ }