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,560 @@
1
+ /**
2
+ * Responsive layout constants and hook.
3
+ *
4
+ * Five modes based on both terminal columns and rows:
5
+ *
6
+ * micro very small rows/cols → one-line shell
7
+ * compact normal short terminal → compact shell, no large logo
8
+ * normal standard terminal → compact header, roomy content
9
+ * wide large terminal → decorative header allowed
10
+ * max maximized terminal → full decorative layout
11
+ */
12
+
13
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
14
+ import { useStdout } from "ink";
15
+ import stringWidth from "string-width";
16
+ import * as renderDebug from "../core/perf/renderDebug.js";
17
+ import { setTerminalResizing, isTerminalResizing } from "../core/terminal/terminalControl.js";
18
+
19
+ export const BREAKPOINT_MAX = 180;
20
+ export const BREAKPOINT_WIDE = 140;
21
+ export const BREAKPOINT_NORMAL = 90;
22
+ export const BREAKPOINT_COMPACT = 60;
23
+ export const ROW_BREAKPOINT_MAX = 40;
24
+ export const ROW_BREAKPOINT_WIDE = 30;
25
+ export const ROW_BREAKPOINT_NORMAL = 20;
26
+ export const ROW_BREAKPOINT_COMPACT = 14;
27
+ export const MIN_TERMINAL_COLS = 20;
28
+ export const MIN_TERMINAL_ROWS = 10;
29
+ export const MIN_VIEWPORT_COLS = 20;
30
+ export const MIN_VIEWPORT_ROWS = 10;
31
+ export const RESTORE_SETTLE_MS = process.env.NODE_ENV === "test" ? 0 : 100;
32
+ export const STARTUP_TINY_MIN_COLS = 40;
33
+ export const STARTUP_TINY_MIN_ROWS = 14;
34
+ export const STARTUP_FULL_MIN_COLS = 100; // matches LOGO_LARGE_MIN_COLS in logoVariants.ts
35
+ export const STARTUP_FULL_MIN_BODY_ROWS = 4;
36
+ export const STARTUP_FULL_SAFE_PADDING_ROWS = 1;
37
+ export const STARTUP_COMPACT_INTRO_ROWS = 4;
38
+ export const STARTUP_TINY_MESSAGE_ROWS = 3;
39
+ export const transcriptContentIndent = 4; // 2 for DashCard border + 2 for prompt prefix
40
+ const DEFAULT_COLUMNS = 120;
41
+ const DEFAULT_ROWS = 24;
42
+
43
+ export type LayoutMode = "compact" | "regular" | "expanded";
44
+ export type StartupHeaderMode = "large" | "compact" | "tiny";
45
+
46
+ export interface Layout {
47
+ cols: number;
48
+ rows: number;
49
+ mode: LayoutMode;
50
+ }
51
+
52
+ export type PanelLayout = {
53
+ mode: "compact" | "regular" | "expanded";
54
+ availableRows: number;
55
+ availableCols: number;
56
+ };
57
+
58
+ export type BottomChromeBudget = {
59
+ runtimeMetadataRows: number;
60
+ composerRows: number;
61
+ transientStatusRows: number;
62
+ bottomPaddingRows: number;
63
+ totalRows: number;
64
+ };
65
+
66
+ export type AppLayoutBudget = {
67
+ mode: LayoutMode;
68
+ rows: number;
69
+ cols: number;
70
+
71
+ headerRows: number;
72
+ headerGapRows: number;
73
+ panelStagePaddingY: number;
74
+
75
+ activePanelRows: number;
76
+ activePanelCols: number;
77
+
78
+ bottomChromeBudget: BottomChromeBudget;
79
+ composerRows: number;
80
+
81
+ showNormalLogo: boolean;
82
+ showCompactHeader: boolean;
83
+ placeMetadataBesideLogo: boolean;
84
+ placeMetadataBelowLogo: boolean;
85
+
86
+ // Backward compatibility fields:
87
+ transcriptRows: number;
88
+ panelRows: number;
89
+ showLargeLogo: boolean;
90
+ showPanelSeparators: boolean;
91
+ showPanelColumnHeaders: boolean;
92
+ };
93
+
94
+ export const PanelAvailableRowsContext = createContext<number | undefined>(undefined);
95
+ export const AppLayoutBudgetContext = createContext<AppLayoutBudget | undefined>(undefined);
96
+ export const PanelLayoutContext = createContext<PanelLayout | undefined>(undefined);
97
+
98
+ export interface ActivePanelLayout {
99
+ width: number;
100
+ height: number;
101
+ availableRows: number;
102
+ availableCols: number;
103
+ }
104
+
105
+ export const ActivePanelLayoutContext = createContext<ActivePanelLayout | undefined>(undefined);
106
+
107
+ export function usePanelAvailableRows(): number | undefined {
108
+ return useContext(PanelAvailableRowsContext);
109
+ }
110
+
111
+ export function useAppLayoutBudget(): AppLayoutBudget | undefined {
112
+ return useContext(AppLayoutBudgetContext);
113
+ }
114
+
115
+ export function useActivePanelLayout(): ActivePanelLayout | undefined {
116
+ return useContext(ActivePanelLayoutContext);
117
+ }
118
+
119
+ export function usePanelLayout(): PanelLayout | undefined {
120
+ return useContext(PanelLayoutContext);
121
+ }
122
+
123
+ export interface TerminalViewport extends Layout {
124
+ rawCols?: number;
125
+ rawRows?: number;
126
+ contentWidth: number;
127
+ isCramped: boolean;
128
+ unstable: boolean;
129
+ layoutEpoch: number;
130
+ isResizing: boolean;
131
+ }
132
+
133
+ // ─── Dimension helpers ────────────────────────────────────────────────────────
134
+
135
+ function isValidDimension(value: number | undefined): value is number {
136
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
137
+ }
138
+
139
+ export function normalizeDimension(value: number | undefined, fallback: number): number {
140
+ if (!isValidDimension(value)) {
141
+ // If even the fallback is invalid, use an absolute floor.
142
+ return isValidDimension(fallback) ? Math.floor(fallback) : 10;
143
+ }
144
+
145
+ return Math.floor(value);
146
+ }
147
+
148
+ export function isRenderableViewport(cols: number | undefined, rows: number | undefined): boolean {
149
+ return isValidDimension(cols)
150
+ && isValidDimension(rows)
151
+ && Math.floor(cols) >= MIN_VIEWPORT_COLS
152
+ && Math.floor(rows) >= MIN_VIEWPORT_ROWS;
153
+ }
154
+
155
+ /** Returns true if the terminal is below the minimum supported size for a full UI. */
156
+ export function isCrampedTerminal(cols: number | undefined, rows: number | undefined): boolean {
157
+ const safeCols = normalizeDimension(cols, DEFAULT_COLUMNS);
158
+ const safeRows = normalizeDimension(rows, DEFAULT_ROWS);
159
+ return safeCols < MIN_TERMINAL_COLS || safeRows < MIN_TERMINAL_ROWS;
160
+ }
161
+
162
+ /**
163
+ * Leave a 1-column gutter so box-drawing borders never land exactly on the
164
+ * terminal edge, which can trigger a horizontal scrollbar in some Windows hosts.
165
+ */
166
+ export function getShellWidth(cols: number | undefined): number {
167
+ return Math.max(20, (cols ?? 120) - 1);
168
+ }
169
+
170
+ /**
171
+ * Returns the responsive width of the main content area. Large/maximized
172
+ * terminals retain a small gutter but continue growing with the viewport.
173
+ */
174
+ export function getContentWidth(cols: number | undefined): number {
175
+ const shellWidth = getShellWidth(cols);
176
+
177
+ if (shellWidth < 100) return shellWidth;
178
+ if (shellWidth < 150) return shellWidth - 4;
179
+ if (shellWidth < 200) return shellWidth - 8;
180
+
181
+ return shellWidth - 12;
182
+ }
183
+
184
+ export function getUsableShellWidth(cols: number | undefined, reservedColumns = 0): number {
185
+ return Math.max(1, getShellWidth(cols) - reservedColumns);
186
+ }
187
+
188
+ /**
189
+ * Leave a 1-row gutter so renders do not land exactly on the terminal bottom
190
+ * edge, which can trigger viewport scroll drift in some hosts.
191
+ */
192
+ export function getShellHeight(rows: number | undefined): number {
193
+ return Math.max(10, (rows ?? DEFAULT_ROWS) - 1);
194
+ }
195
+
196
+ export interface StartupHeaderModeParams {
197
+ cols: number | undefined;
198
+ rows: number | undefined;
199
+ introRows: number;
200
+ composerRows: number;
201
+ }
202
+
203
+ export function resolveStartupHeaderMode({
204
+ cols,
205
+ rows,
206
+ introRows,
207
+ composerRows,
208
+ }: StartupHeaderModeParams): StartupHeaderMode {
209
+ const safeCols = normalizeDimension(cols, DEFAULT_COLUMNS);
210
+ const safeRows = normalizeDimension(rows, DEFAULT_ROWS);
211
+
212
+ if (safeCols < STARTUP_TINY_MIN_COLS || safeRows < STARTUP_TINY_MIN_ROWS) {
213
+ return "tiny";
214
+ }
215
+
216
+ const shellHeight = getShellHeight(safeRows);
217
+ const fullStartupRows = introRows
218
+ + composerRows
219
+ + STARTUP_FULL_MIN_BODY_ROWS
220
+ + STARTUP_FULL_SAFE_PADDING_ROWS;
221
+
222
+ if (safeCols >= STARTUP_FULL_MIN_COLS && shellHeight >= fullStartupRows) {
223
+ return "large";
224
+ }
225
+
226
+ return "compact";
227
+ }
228
+
229
+ export function getVisualWidth(text: string): number {
230
+ return stringWidth(text);
231
+ }
232
+
233
+ export function clampVisualText(text: string, maxWidth: number): string {
234
+ if (maxWidth <= 0) return "";
235
+ if (getVisualWidth(text) <= maxWidth) return text;
236
+
237
+ const ellipsis = maxWidth > 1 ? "…" : "";
238
+ const suffixWidth = getVisualWidth(ellipsis);
239
+ let output = "";
240
+
241
+ for (const char of Array.from(text)) {
242
+ if (getVisualWidth(output + char) + suffixWidth > maxWidth) {
243
+ break;
244
+ }
245
+ output += char;
246
+ }
247
+
248
+ return output + ellipsis;
249
+ }
250
+
251
+ export function computeMode(cols: number, rows: number): LayoutMode {
252
+ if (rows <= 24 || cols <= 100) {
253
+ return "compact";
254
+ }
255
+ if (cols >= 140 && rows >= 30) {
256
+ return "expanded";
257
+ }
258
+ return "regular";
259
+ }
260
+
261
+ export function isDecorativeLayoutMode(mode: LayoutMode): boolean {
262
+ return mode === "expanded";
263
+ }
264
+
265
+ export function isCompactShellMode(mode: LayoutMode): boolean {
266
+ return mode === "compact" || mode === "regular";
267
+ }
268
+
269
+ export interface AppLayoutBudgetParams {
270
+ cols: number | undefined;
271
+ rows: number | undefined;
272
+ composerRows?: number;
273
+ panelHintRows?: number;
274
+ headerRows?: number;
275
+ headerGapRows?: number;
276
+ }
277
+
278
+ export function computeAppLayoutBudget({
279
+ cols,
280
+ rows,
281
+ composerRows = 4,
282
+ panelHintRows = 0,
283
+ headerRows,
284
+ headerGapRows,
285
+ }: AppLayoutBudgetParams): AppLayoutBudget {
286
+ const safeCols = normalizeDimension(cols, DEFAULT_COLUMNS);
287
+ const safeRows = normalizeDimension(rows, DEFAULT_ROWS);
288
+ const mode = computeMode(safeCols, safeRows);
289
+ const shellHeight = getShellHeight(safeRows);
290
+
291
+ const showNormalLogo =
292
+ process.env["UBUME_NO_ASCII_LOGO"] !== "1" && (
293
+ mode === "regular" ||
294
+ mode === "expanded" ||
295
+ (mode === "compact" && safeCols >= 72)
296
+ );
297
+
298
+ const showCompactHeader = !showNormalLogo;
299
+
300
+ const placeMetadataBesideLogo =
301
+ showNormalLogo && safeCols >= 95;
302
+
303
+ const placeMetadataBelowLogo =
304
+ showNormalLogo && !placeMetadataBesideLogo;
305
+
306
+ const resolvedHeaderRows = headerRows ?? (showNormalLogo ? 6 : 1);
307
+ const resolvedHeaderGapRows = headerGapRows ?? (mode === "compact" ? 0 : 1);
308
+ const panelStagePaddingY = mode === "compact" ? 0 : 1;
309
+ const resolvedComposerRows = mode === "compact" ? 3 : Math.max(0, composerRows);
310
+ const runtimeMetadataRows = 1;
311
+ const transientStatusRows = 0;
312
+ const bottomPaddingRows = mode === "compact" ? 0 : 1;
313
+ const bottomChromeBudget: BottomChromeBudget = {
314
+ runtimeMetadataRows,
315
+ composerRows: resolvedComposerRows,
316
+ transientStatusRows,
317
+ bottomPaddingRows,
318
+ totalRows: runtimeMetadataRows + resolvedComposerRows + transientStatusRows + bottomPaddingRows,
319
+ };
320
+ const baseReservedRows =
321
+ resolvedHeaderRows +
322
+ resolvedHeaderGapRows +
323
+ panelStagePaddingY * 2 +
324
+ bottomChromeBudget.totalRows +
325
+ Math.max(0, panelHintRows);
326
+
327
+ const activePanelRows = Math.max(1, shellHeight - baseReservedRows);
328
+ const contentWidth = getContentWidth(safeCols);
329
+
330
+ const isCompact = mode === "compact";
331
+ const borderRows = 2;
332
+ const titleRows = 1;
333
+ const headerRowsInPanel = isCompact ? 0 : 1;
334
+ const panelChromeRows = borderRows + titleRows + headerRowsInPanel;
335
+ const innerAvailableRows = Math.max(1, activePanelRows - panelChromeRows);
336
+
337
+ const borderCols = 4;
338
+ const innerAvailableCols = Math.max(20, contentWidth - borderCols);
339
+
340
+ return {
341
+ mode,
342
+ rows: safeRows,
343
+ cols: safeCols,
344
+ headerRows: resolvedHeaderRows,
345
+ headerGapRows: resolvedHeaderGapRows,
346
+ panelStagePaddingY,
347
+ activePanelRows: innerAvailableRows,
348
+ activePanelCols: innerAvailableCols,
349
+ bottomChromeBudget,
350
+ composerRows: resolvedComposerRows,
351
+ showNormalLogo,
352
+ showCompactHeader,
353
+ placeMetadataBesideLogo,
354
+ placeMetadataBelowLogo,
355
+
356
+ // Backward compatibility fields:
357
+ transcriptRows: activePanelRows,
358
+ panelRows: innerAvailableRows,
359
+ showLargeLogo: mode === "expanded",
360
+ showPanelSeparators: mode === "expanded",
361
+ showPanelColumnHeaders: mode === "expanded",
362
+ };
363
+ }
364
+
365
+ export function createLayoutSnapshot(
366
+ cols: number | undefined,
367
+ rows: number | undefined,
368
+ fallback: Layout = {
369
+ cols: DEFAULT_COLUMNS,
370
+ rows: DEFAULT_ROWS,
371
+ mode: computeMode(DEFAULT_COLUMNS, DEFAULT_ROWS),
372
+ },
373
+ ): TerminalViewport {
374
+ const nextCols = normalizeDimension(cols, fallback.cols);
375
+ const nextRows = normalizeDimension(rows, fallback.rows);
376
+
377
+ const stableLayout = {
378
+ cols: nextCols,
379
+ rows: nextRows,
380
+ mode: computeMode(nextCols, nextRows),
381
+ };
382
+
383
+ const isCramped = isCrampedTerminal(nextCols, nextRows);
384
+ const contentWidth = getContentWidth(nextCols);
385
+
386
+ return {
387
+ ...stableLayout,
388
+ contentWidth,
389
+ isCramped,
390
+ unstable: false,
391
+ layoutEpoch: 0,
392
+ isResizing: false,
393
+ };
394
+ }
395
+
396
+ function snapshot(stdout: NodeJS.WriteStream, fallback?: Layout): Layout {
397
+ return createLayoutSnapshot(stdout.columns, stdout.rows, fallback);
398
+ }
399
+
400
+ export function createTerminalViewport(
401
+ cols: number | undefined,
402
+ rows: number | undefined,
403
+ fallback?: TerminalViewport,
404
+ isResizing = false,
405
+ ): TerminalViewport {
406
+ const fallbackLayout = fallback
407
+ ? { cols: fallback.cols, rows: fallback.rows, mode: fallback.mode }
408
+ : undefined;
409
+ const unstable = !isRenderableViewport(cols, rows);
410
+ const stableLayout = unstable && fallbackLayout
411
+ ? fallbackLayout
412
+ : createLayoutSnapshot(cols, rows, fallbackLayout);
413
+
414
+ const isCramped = isCrampedTerminal(cols, rows);
415
+ const contentWidth = getContentWidth(stableLayout.cols);
416
+
417
+ return {
418
+ ...stableLayout,
419
+ rawCols: cols,
420
+ rawRows: rows,
421
+ contentWidth,
422
+ isCramped,
423
+ unstable,
424
+ layoutEpoch: fallback?.layoutEpoch ?? 0,
425
+ isResizing,
426
+ };
427
+ }
428
+
429
+ export function advanceTerminalViewport(
430
+ current: TerminalViewport,
431
+ cols: number | undefined,
432
+ rows: number | undefined,
433
+ isResizing = false,
434
+ ): TerminalViewport {
435
+ const next = createTerminalViewport(cols, rows, current, isResizing);
436
+
437
+ if (process.env.UBUME_LAYOUT_DEBUG === "1") {
438
+ renderDebug.traceEvent("layout", "advanceViewport", {
439
+ cols: next.cols,
440
+ rows: next.rows,
441
+ contentWidth: next.contentWidth,
442
+ isCramped: next.isCramped,
443
+ isResizing: next.isResizing,
444
+ mode: next.mode,
445
+ });
446
+ }
447
+
448
+ if (!next.unstable && current.unstable) {
449
+ return {
450
+ ...next,
451
+ layoutEpoch: current.layoutEpoch + 1,
452
+ };
453
+ }
454
+
455
+ return {
456
+ ...next,
457
+ layoutEpoch: current.layoutEpoch,
458
+ };
459
+ }
460
+
461
+ // ─── Hook ────────────────────────────────────────────────────────────────────
462
+
463
+ /** React hook — returns live layout that ignores transient invalid restore sizes. */
464
+ export function useTerminalViewport(): TerminalViewport {
465
+ const { stdout } = useStdout();
466
+ const [viewport, setViewport] = useState<TerminalViewport>(() => createTerminalViewport(stdout.columns, stdout.rows));
467
+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
468
+
469
+ useEffect(() => {
470
+ const commit = (isResizing = false) => {
471
+ setViewport((current) => {
472
+ const nextViewport = advanceTerminalViewport(current, stdout.columns, stdout.rows, isResizing);
473
+ if (
474
+ current.cols === nextViewport.cols &&
475
+ current.rows === nextViewport.rows &&
476
+ current.mode === nextViewport.mode &&
477
+ current.unstable === nextViewport.unstable &&
478
+ current.layoutEpoch === nextViewport.layoutEpoch &&
479
+ current.rawCols === nextViewport.rawCols &&
480
+ current.rawRows === nextViewport.rawRows &&
481
+ current.isResizing === nextViewport.isResizing
482
+ ) {
483
+ renderDebug.traceFlickerEvent("measurementUpdate", {
484
+ result: "skipped",
485
+ cols: nextViewport.cols,
486
+ rows: nextViewport.rows,
487
+ mode: nextViewport.mode,
488
+ unstable: nextViewport.unstable,
489
+ isResizing: nextViewport.isResizing,
490
+ });
491
+ return current;
492
+ }
493
+
494
+ renderDebug.traceFlickerEvent("measurementUpdate", {
495
+ result: "updated",
496
+ cols: nextViewport.cols,
497
+ rows: nextViewport.rows,
498
+ mode: nextViewport.mode,
499
+ unstable: nextViewport.unstable,
500
+ isResizing: nextViewport.isResizing,
501
+ });
502
+ return nextViewport;
503
+ });
504
+ };
505
+
506
+ const onResize = () => {
507
+ renderDebug.traceEvent("terminal", "viewportHookResize", {
508
+ cols: stdout.columns,
509
+ rows: stdout.rows,
510
+ renderable: isRenderableViewport(stdout.columns, stdout.rows),
511
+ });
512
+ renderDebug.traceLayoutValidity("useTerminalViewport", {
513
+ rawCols: stdout.columns,
514
+ rawRows: stdout.rows,
515
+ });
516
+
517
+ // Leading edge: immediately enter isResizing state but do NOT commit
518
+ // new dimensions yet. This freezes the layout to prevent tearing while
519
+ // dragging, and signals animations to pause.
520
+ setTerminalResizing(true);
521
+ setViewport((current) => ({ ...current, isResizing: true }));
522
+
523
+ if (settleTimerRef.current) {
524
+ clearTimeout(settleTimerRef.current);
525
+ }
526
+
527
+ settleTimerRef.current = setTimeout(() => {
528
+ settleTimerRef.current = null;
529
+ setTerminalResizing(false);
530
+ // Trailing edge: commit final dimensions and exit isResizing state.
531
+ commit(false);
532
+ }, RESTORE_SETTLE_MS);
533
+ };
534
+
535
+ stdout.on("resize", onResize);
536
+ return () => {
537
+ stdout.off("resize", onResize);
538
+ if (settleTimerRef.current) {
539
+ clearTimeout(settleTimerRef.current);
540
+ }
541
+ };
542
+ }, [stdout]);
543
+
544
+ return viewport;
545
+ }
546
+
547
+ /**
548
+ * Calculate available vertical rows for active panels, falling back to a layout-based
549
+ * budget if availableRows is not explicitly provided.
550
+ */
551
+ export function getAvailableRowsForPanel(
552
+ layout: Layout,
553
+ passedAvailableRows?: number
554
+ ): number {
555
+ if (passedAvailableRows !== undefined) {
556
+ return passedAvailableRows;
557
+ }
558
+
559
+ return computeAppLayoutBudget({ cols: layout.cols, rows: layout.rows }).panelRows;
560
+ }
@@ -0,0 +1,131 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { useTheme } from "../theme.js";
6
+
7
+ export interface PendingImportFile {
8
+ srcPath: string;
9
+ rawPath: string;
10
+ destFilename: string;
11
+ isImage: boolean;
12
+ }
13
+
14
+ interface AttachmentImportPanelProps {
15
+ focusId: string;
16
+ files: PendingImportFile[];
17
+ attachmentsDir: string;
18
+ workspaceRoot: string;
19
+ modelSupportsVision: boolean | null;
20
+ onConfirm: () => void;
21
+ onCancel: () => void;
22
+ }
23
+
24
+ export function compactHomePath(value: string, homeDir = os.homedir()): string {
25
+ const normalizedValue = value.replace(/\\/g, "/");
26
+ const normalizedHome = homeDir.replace(/\\/g, "/").replace(/\/$/, "");
27
+ if (!normalizedHome || normalizedValue === normalizedHome) return normalizedValue;
28
+ return normalizedValue.startsWith(`${normalizedHome}/`)
29
+ ? `~/${normalizedValue.slice(normalizedHome.length + 1)}`
30
+ : normalizedValue;
31
+ }
32
+
33
+ export function AttachmentImportPanel({
34
+ focusId,
35
+ files,
36
+ attachmentsDir,
37
+ workspaceRoot,
38
+ modelSupportsVision,
39
+ onConfirm,
40
+ onCancel,
41
+ }: AttachmentImportPanelProps) {
42
+ const theme = useTheme();
43
+ const { isFocused } = useFocus({ id: focusId, autoFocus: true });
44
+ const [selectedIndex, setSelectedIndex] = useState(0);
45
+
46
+ useInput((input, key) => {
47
+ if (key.escape) {
48
+ onCancel();
49
+ return;
50
+ }
51
+ if (key.return) {
52
+ if (selectedIndex === 0) onConfirm();
53
+ else onCancel();
54
+ return;
55
+ }
56
+ if (key.leftArrow) setSelectedIndex(0);
57
+ if (key.rightArrow || key.tab) setSelectedIndex(1);
58
+ }, { isActive: isFocused });
59
+
60
+ const relativeAttachmentsDir = path.relative(workspaceRoot, attachmentsDir).replace(/\\/g, "/");
61
+ const displayedAttachmentsDir = relativeAttachmentsDir.startsWith("..")
62
+ ? compactHomePath(attachmentsDir)
63
+ : relativeAttachmentsDir;
64
+ const hasImages = files.some((f) => f.isImage);
65
+ const showVisionWarning = hasImages && modelSupportsVision === false;
66
+ const fileLabel = files.length === 1 ? "file" : "files";
67
+
68
+ return (
69
+ <Box flexDirection="column" width="100%" marginTop={1}>
70
+ <Box
71
+ borderStyle="round"
72
+ borderColor={theme.border}
73
+ paddingX={2}
74
+ paddingY={1}
75
+ width="100%"
76
+ >
77
+ <Text color={theme.accent} bold>IMPORT FILE </Text>
78
+ <Text color={theme.textMuted}>
79
+ Copy {files.length} outside-workspace {fileLabel} into {displayedAttachmentsDir}?
80
+ </Text>
81
+ </Box>
82
+
83
+ <Box
84
+ borderStyle="round"
85
+ borderColor={theme.border}
86
+ paddingX={2}
87
+ paddingY={1}
88
+ marginTop={1}
89
+ width="100%"
90
+ flexDirection="column"
91
+ >
92
+ {files.map((file, i) => (
93
+ <Box key={i} marginBottom={i < files.length - 1 ? 1 : 0}>
94
+ <Text color={theme.text}>{path.basename(file.srcPath)} </Text>
95
+ <Text color={theme.textDim}>
96
+ {"→ "}{displayedAttachmentsDir}/{file.destFilename}
97
+ </Text>
98
+ </Box>
99
+ ))}
100
+
101
+ {showVisionWarning && (
102
+ <Box marginTop={1}>
103
+ <Text color={theme.warning}>
104
+ Note: active model may not support images.
105
+ </Text>
106
+ </Box>
107
+ )}
108
+
109
+ <Box marginTop={1}>
110
+ <Text color={selectedIndex === 0 ? theme.accent : theme.textMuted} bold={selectedIndex === 0}>
111
+ {selectedIndex === 0 ? "› " : " "}Import once
112
+ </Text>
113
+ <Text color={theme.textDim}> · </Text>
114
+ <Text color={selectedIndex === 1 ? theme.accent : theme.textMuted} bold={selectedIndex === 1}>
115
+ {selectedIndex === 1 ? "› " : " "}Cancel
116
+ </Text>
117
+ </Box>
118
+
119
+ <Box marginTop={1}>
120
+ <Text color={theme.textMuted}>
121
+ This copies only the listed {fileLabel}; it does not grant folder or workspace access.
122
+ </Text>
123
+ </Box>
124
+
125
+ <Box marginTop={1}>
126
+ <Text color={theme.textDim}>←/→ to navigate · Enter to select · Esc to cancel</Text>
127
+ </Box>
128
+ </Box>
129
+ </Box>
130
+ );
131
+ }