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,110 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveUbumeDebugLogPath } from "../workspace/appData.js";
4
+ import { isTerminalResizing } from "./terminalControl.js";
5
+
6
+ export interface FrameLockOptions {
7
+ stdout: any;
8
+ env: Record<string, string | undefined>;
9
+ }
10
+
11
+ const resizeFrameCacheResetters = new WeakMap<object, () => void>();
12
+
13
+ export function resetFrameLockForResize(stdout: object): void {
14
+ resizeFrameCacheResetters.get(stdout)?.();
15
+ }
16
+
17
+ /**
18
+ * Wraps the stdout stream to enforce frame-level deduplication, a flush lock,
19
+ * and width-safe row padding via ANSI clear-to-EOL (\x1b[K) injection.
20
+ */
21
+ export function wrapStdoutWithFrameLock({
22
+ stdout,
23
+ env,
24
+ }: FrameLockOptions) {
25
+ let lastFrame = "";
26
+ let isFlushing = false;
27
+ let debugLogStream: fs.WriteStream | null = null;
28
+
29
+ if (env.UBUME_RENDER_DEBUG === "1") {
30
+ try {
31
+ const logPath = env.UBUME_RENDER_DEBUG_FILE?.trim()
32
+ || resolveUbumeDebugLogPath(env);
33
+ const logDir = path.dirname(logPath);
34
+ if (!fs.existsSync(logDir)) {
35
+ fs.mkdirSync(logDir, { recursive: true });
36
+ }
37
+ debugLogStream = fs.createWriteStream(logPath, { flags: "a" });
38
+ } catch (e) {
39
+ // Gracefully fall back if logging fails (e.g. read-only filesystem)
40
+ void e;
41
+ }
42
+ }
43
+
44
+ const logDebug = (msg: string) => {
45
+ if (debugLogStream) {
46
+ debugLogStream.write(`${new Date().toISOString()} ${msg}\n`);
47
+ }
48
+ };
49
+
50
+ const originalWrite = stdout.write.bind(stdout);
51
+
52
+ // A terminal resize can make an otherwise identical Ink frame meaningful:
53
+ // rows reflow at a new width and height-driven spacer/layout changes must be
54
+ // written even when their source text did not change. src/index.tsx owns the
55
+ // single resize listener and invokes this reset through the exported helper.
56
+ resizeFrameCacheResetters.set(stdout, () => {
57
+ lastFrame = "";
58
+ logDebug("Frame cache reset: terminal resize");
59
+ });
60
+
61
+ stdout.write = (chunk: string | Uint8Array) => {
62
+ if (typeof chunk !== "string") {
63
+ return originalWrite(chunk);
64
+ }
65
+
66
+ // Task 7: Frame Lock
67
+ // Ensure only one render flush can write to stdout at a time.
68
+ if (isFlushing) {
69
+ logDebug("Frame dropped: lock active (concurrent flush)");
70
+ return true;
71
+ }
72
+
73
+ // Task 8: Full-frame Deduplication
74
+ // Skip if identical to last frame.
75
+ if (chunk === lastFrame) {
76
+ logDebug("Frame dropped: identical to last frame");
77
+ return true;
78
+ }
79
+
80
+ // Task 4: Width-Safe Injection
81
+ // Ensure every row clears to the end of the line. This fixes artifacts
82
+ // left behind when a shorter row replaces a longer row or when terminal
83
+ // wrapping occurs during resize.
84
+ //
85
+ // We inject \x1b[K before every newline and at the end of the frame.
86
+ // padding via \x1b[K injection.
87
+ // Only apply if the chunk is non-empty and doesn't look like a control-only
88
+ // sequence (e.g. terminal title OSC).
89
+ let processed = chunk;
90
+ const isControlSequence = chunk.includes("\x1b]");
91
+ if (chunk.length > 0 && !isControlSequence) {
92
+ processed = chunk.replace(/\n/g, "\x1b[K\n");
93
+ if (!processed.endsWith("\x1b[K") && !processed.endsWith("\x1b[K\n")) {
94
+ processed += "\x1b[K";
95
+ }
96
+ }
97
+
98
+ isFlushing = true;
99
+ try {
100
+ lastFrame = chunk;
101
+ const resizing = isTerminalResizing();
102
+ logDebug(`Frame written: length=${processed.length} original=${chunk.length} resizing=${resizing}`);
103
+ return originalWrite(processed);
104
+ } finally {
105
+ isFlushing = false;
106
+ }
107
+ };
108
+
109
+ return stdout;
110
+ }
@@ -0,0 +1,123 @@
1
+ import * as renderDebug from "../perf/renderDebug.js";
2
+
3
+ /**
4
+ * Typed subset of the internal Ink class instance we reach into.
5
+ *
6
+ * `unsubscribeResize` is used at startup to disable Ink's competing resize
7
+ * handler (see src/index.tsx). The remaining fields are Ink's per-frame render
8
+ * caches; we reset them on the explicit /clear boundary so the next frame is
9
+ * written authoritatively from a clean baseline — exactly like the first frame
10
+ * after a cold startup — rather than diffed against pre-clear output.
11
+ *
12
+ * All fields are optional so the type also describes the minimal capability
13
+ * needed by callers that only disable resize, and so resolution degrades
14
+ * gracefully across Ink versions / test mocks.
15
+ */
16
+ export interface InkRenderInstance {
17
+ unsubscribeResize?: () => void;
18
+ renderInteractiveFrame?: (output: string, outputHeight: number, staticOutput: string) => void;
19
+ lastOutput?: string;
20
+ lastOutputToRender?: string;
21
+ lastOutputHeight?: number;
22
+ lastTerminalWidth?: number;
23
+ fullStaticOutput?: string;
24
+ log?: { reset?: () => void };
25
+ throttledOnRender?: { cancel?: () => void };
26
+ throttledLog?: { cancel?: () => void };
27
+ }
28
+
29
+ export interface AppStdoutLike {
30
+ columns?: number;
31
+ rows?: number;
32
+ }
33
+
34
+ /**
35
+ * Resolve the real Ink class instance via Ink's internal WeakMap<stdout, Ink>.
36
+ * Returns null if resolution fails (e.g. different Ink version, test mocks).
37
+ *
38
+ * Bun doesn't resolve bare subpath imports like "ink/build/instances.js" so we
39
+ * resolve ink's main entry first, then derive the sibling path.
40
+ */
41
+ export function resolveInkRenderInstance(stdout: object): InkRenderInstance | null {
42
+ try {
43
+ const { createRequire } = require("node:module");
44
+ const req = createRequire(import.meta.url);
45
+ const inkMain = req.resolve("ink");
46
+ const instancesPath = inkMain.replace(/index\.js$/, "instances.js");
47
+ const instances = req(instancesPath);
48
+ const weakMap: WeakMap<object, InkRenderInstance> =
49
+ instances.default ?? instances;
50
+ return weakMap.get(stdout) ?? null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export interface ResetInkOutputOptions {
57
+ instance: InkRenderInstance | null;
58
+ /** Current terminal column count, used to reseat lastTerminalWidth. */
59
+ columns?: number;
60
+ }
61
+
62
+ /**
63
+ * Reset Ink's render-state caches to the fresh-startup baseline.
64
+ *
65
+ * Call this immediately AFTER the terminal has been physically cleared (e.g. by
66
+ * terminalControl.clearTranscript on /clear) and BEFORE the post-clear React
67
+ * render commits. It mirrors what a brand-new Ink instance looks like at cold
68
+ * startup: empty frame caches and zero previous height, so the next frame is
69
+ * authoritative and the subsequent resize math (Ink's renderInteractiveFrame /
70
+ * shouldClearTerminalForFrame) is evaluated against truthful state.
71
+ *
72
+ * This does NOT emit any clear/erase escape sequences itself — the terminal is
73
+ * already physically cleared, and Ink's log.reset() zeroes log-update's
74
+ * accounting without writing. Returns false (graceful no-op) if no live Ink
75
+ * instance is available.
76
+ */
77
+ export function resetInkOutputForFreshFrame({ instance, columns }: ResetInkOutputOptions): boolean {
78
+ if (!instance) {
79
+ renderDebug.traceEvent("terminal", "clearRenderReset", { instanceResolved: false });
80
+ return false;
81
+ }
82
+
83
+ const before = {
84
+ lastOutputLength: instance.lastOutput?.length ?? 0,
85
+ lastOutputToRenderLength: instance.lastOutputToRender?.length ?? 0,
86
+ lastOutputHeight: instance.lastOutputHeight ?? 0,
87
+ fullStaticOutputLength: instance.fullStaticOutput?.length ?? 0,
88
+ lastTerminalWidth: instance.lastTerminalWidth,
89
+ };
90
+
91
+ // Drop any pending throttled render/log so a stale pre-clear frame can't be
92
+ // flushed after we reset the caches.
93
+ instance.throttledOnRender?.cancel?.();
94
+ instance.throttledLog?.cancel?.();
95
+
96
+ // Zero log-update's previousOutput/previousLineCount WITHOUT emitting escape
97
+ // sequences (the terminal was already physically cleared).
98
+ instance.log?.reset?.();
99
+
100
+ // Reset Ink's frame caches to the constructor/startup state.
101
+ instance.lastOutput = "";
102
+ instance.lastOutputToRender = "";
103
+ instance.lastOutputHeight = 0;
104
+ instance.fullStaticOutput = "";
105
+ if (typeof columns === "number" && Number.isFinite(columns)) {
106
+ instance.lastTerminalWidth = columns;
107
+ }
108
+
109
+ renderDebug.traceEvent("terminal", "clearRenderReset", {
110
+ instanceResolved: true,
111
+ columns,
112
+ before,
113
+ after: {
114
+ lastOutputLength: instance.lastOutput.length,
115
+ lastOutputToRenderLength: instance.lastOutputToRender.length,
116
+ lastOutputHeight: instance.lastOutputHeight,
117
+ fullStaticOutputLength: instance.fullStaticOutput.length,
118
+ lastTerminalWidth: instance.lastTerminalWidth,
119
+ },
120
+ });
121
+
122
+ return true;
123
+ }
@@ -0,0 +1,20 @@
1
+ export interface StartupClearOptions {
2
+ write: (chunk: string) => boolean | void;
3
+ /** True when --no-clear was passed on the CLI. */
4
+ noClear: boolean;
5
+ env: Record<string, string | undefined>;
6
+ }
7
+
8
+ /**
9
+ * Writes a full terminal clear (viewport + scrollback + cursor home) to stdout
10
+ * before the first Ink render. Skipped when --no-clear or UBUME_NO_CLEAR=1.
11
+ *
12
+ * Only call this from startApp() in TTY mode — never inside render loops.
13
+ */
14
+ export function performStartupClear(options: StartupClearOptions): void {
15
+ if (options.noClear || options.env["UBUME_NO_CLEAR"] === "1") return;
16
+ // \x1b[2J: clear visible viewport
17
+ // \x1b[3J: clear scrollback buffer
18
+ // \x1b[H: move cursor to top-left
19
+ options.write("\x1b[2J\x1b[3J\x1b[H");
20
+ }
@@ -0,0 +1,100 @@
1
+ export interface TerminalCapabilityInput {
2
+ stdinIsTTY: boolean;
3
+ stdoutIsTTY: boolean;
4
+ platform: NodeJS.Platform | string;
5
+ env: Record<string, string | undefined>;
6
+ }
7
+
8
+ export interface TerminalCapabilityResult {
9
+ supported: boolean;
10
+ reason: "supported" | "notty" | "unsupported-terminal";
11
+ message: string;
12
+ warning?: string;
13
+ }
14
+
15
+ const WINDOWS_SUPPORTED_TERM_PATTERNS = [
16
+ /^xterm/i,
17
+ /^screen/i,
18
+ /^tmux/i,
19
+ /^vt\d+/i,
20
+ /^ansi/i,
21
+ /^cygwin/i,
22
+ /^linux/i,
23
+ ];
24
+
25
+ function hasSupportedWindowsTerminal(env: Record<string, string | undefined>): boolean {
26
+ const term = env.TERM ?? "";
27
+ const termProgram = env.TERM_PROGRAM ?? "";
28
+
29
+ if (env.WT_SESSION) return true;
30
+ if (env.ANSICON) return true;
31
+ if ((env.ConEmuANSI ?? "").toUpperCase() === "ON") return true;
32
+ if (termProgram.toLowerCase() === "vscode") return true;
33
+ if (termProgram.toLowerCase() === "hyper") return true;
34
+ if (termProgram.toLowerCase() === "jetbrains-jediterm") return true;
35
+
36
+ return WINDOWS_SUPPORTED_TERM_PATTERNS.some((pattern) => pattern.test(term));
37
+ }
38
+
39
+ export function getTerminalCapability(input: TerminalCapabilityInput): TerminalCapabilityResult {
40
+ if (!input.stdinIsTTY || !input.stdoutIsTTY) {
41
+ return {
42
+ supported: false,
43
+ reason: "notty",
44
+ message: "This UI requires an interactive terminal.",
45
+ };
46
+ }
47
+
48
+ // UBUME_FORCE_VT=1 bypasses terminal detection entirely — useful when the terminal
49
+ // doesn't advertise VT support through standard env vars but is actually compatible.
50
+ if (input.env.UBUME_FORCE_VT === "1") {
51
+ return {
52
+ supported: true,
53
+ reason: "supported",
54
+ message: "",
55
+ };
56
+ }
57
+
58
+ const term = (input.env.TERM ?? "").trim().toLowerCase();
59
+ if (term === "dumb") {
60
+ return {
61
+ supported: false,
62
+ reason: "unsupported-terminal",
63
+ message: "This terminal does not support the VT control sequences required by the Ubume UI. Use a VT-compatible terminal such as Windows Terminal or the VS Code terminal.",
64
+ };
65
+ }
66
+
67
+ if (input.platform !== "win32") {
68
+ return {
69
+ supported: true,
70
+ reason: "supported",
71
+ message: "",
72
+ };
73
+ }
74
+
75
+ // Windows-specific terminal detection below.
76
+ if (hasSupportedWindowsTerminal(input.env)) {
77
+ return {
78
+ supported: true,
79
+ reason: "supported",
80
+ message: "",
81
+ };
82
+ }
83
+
84
+ const message = "This terminal does not advertise VT control sequence support. Ubume will continue because modern Windows terminals usually support VT; set UBUME_REQUIRE_VT=1 to hard-fail when support is not detected.";
85
+
86
+ if (input.env.UBUME_REQUIRE_VT === "1") {
87
+ return {
88
+ supported: false,
89
+ reason: "unsupported-terminal",
90
+ message: "This terminal does not appear to support the VT control sequences required by the Ubume UI. Use Windows Terminal, the VS Code terminal, or another VT-compatible terminal, or set UBUME_FORCE_VT=1 to bypass this check.",
91
+ };
92
+ }
93
+
94
+ return {
95
+ supported: true,
96
+ reason: "supported",
97
+ message: "",
98
+ warning: message,
99
+ };
100
+ }
@@ -0,0 +1,169 @@
1
+ import * as renderDebug from "../perf/renderDebug.js";
2
+ import { APP_NAME } from "../../config/settings.js";
3
+ import { setTerminalTitleLifecycleState, traceTerminalTitleSequences, writeGuardedTerminalOutput } from "./terminalTitle.js";
4
+
5
+ export const TERMINAL_TITLE = APP_NAME;
6
+
7
+ export const TERMINAL_SEQUENCES = {
8
+ // \x1b[2J clears the visible viewport; \x1b[3J clears scrollback.
9
+ hardRepaint: "\x1b[2J\x1b[H",
10
+ viewportClear: "\x1b[2J\x1b[H",
11
+ transcriptClear: "\x1b[2J\x1b[3J\x1b[H",
12
+ bracketedPasteEnable: "\x1b[?2004h",
13
+ bracketedPasteDisable: "\x1b[?2004l",
14
+ mouseEnable: "\x1b[?1000h\x1b[?1006h",
15
+ mouseDisable: "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l",
16
+ title: `\x1b]0;${TERMINAL_TITLE}\x07\x1b]2;${TERMINAL_TITLE}\x07`,
17
+ alternateScreenEnable: "\x1b[?1049h",
18
+ alternateScreenDisable: "\x1b[?1049l",
19
+ } as const;
20
+
21
+ export type TerminalWrite = (chunk: string) => boolean | void;
22
+ export type TerminalChannel = "stdout" | "stderr";
23
+
24
+ // Tracks whether the terminal is currently in a live resize debounce.
25
+ let terminalResizing = false;
26
+
27
+ export function setTerminalResizing(resizing: boolean): void {
28
+ terminalResizing = resizing;
29
+ }
30
+
31
+ export function isTerminalResizing(): boolean {
32
+ return terminalResizing;
33
+ }
34
+
35
+ // Tracks current UI state kind for diagnostic assertions.
36
+ let currentUIStateKind: string = "IDLE";
37
+
38
+ export function setTerminalControlUIState(kind: string): void {
39
+ if (currentUIStateKind !== kind) {
40
+ renderDebug.traceEvent("terminal", "uiStateTransition", {
41
+ from: currentUIStateKind,
42
+ to: kind,
43
+ });
44
+ currentUIStateKind = kind;
45
+ setTerminalTitleLifecycleState(kind);
46
+ }
47
+ }
48
+
49
+ export function writeTerminalControl(
50
+ write: TerminalWrite,
51
+ channel: TerminalChannel,
52
+ source: string,
53
+ sequence: string,
54
+ ): boolean {
55
+ traceTerminalTitleSequences(sequence, {
56
+ source,
57
+ stream: channel,
58
+ origin: "ubume",
59
+ action: "allowed",
60
+ lifecycleState: currentUIStateKind,
61
+ });
62
+ renderDebug.traceTerminalWrite(channel, source, sequence);
63
+ const containsClearOrReset = sequence.includes("\x1b[2J")
64
+ || sequence.includes("\x1b[3J")
65
+ || sequence.includes("\x1bc")
66
+ || sequence.includes("\x1b[H");
67
+ const isStartupWrite = source.includes(":startup");
68
+ const isTranscriptClear = source.includes(":transcriptClear");
69
+ const isViewportClear = source.includes(":viewportClear");
70
+
71
+ // Aggressively block any clearing or reset sequences after startup,
72
+ // especially during active states, to prevent the UI from disappearing.
73
+ if (containsClearOrReset && !isStartupWrite && !isTranscriptClear && !isViewportClear) {
74
+ renderDebug.traceEvent("terminal", "blockedPostStartupClearOrReset", {
75
+ source,
76
+ uiStateKind: currentUIStateKind,
77
+ sequenceLength: sequence.length,
78
+ containsViewportClear: sequence.includes("\x1b[2J"),
79
+ containsScrollbackClear: sequence.includes("\x1b[3J"),
80
+ containsCursorHome: sequence.includes("\x1b[H"),
81
+ containsTerminalReset: sequence.includes("\x1bc"),
82
+ });
83
+ return true;
84
+ }
85
+
86
+ // Diagnostic: warn if a viewport-clearing sequence fires during streaming
87
+ // even if it claims to be from startup (which shouldn't happen).
88
+ if (
89
+ (currentUIStateKind === "RESPONDING" || currentUIStateKind === "THINKING")
90
+ && (sequence.includes("\x1b[2J") || sequence.includes("\x1b[3J"))
91
+ ) {
92
+ renderDebug.traceEvent("terminal", "unexpectedClearDuringStreaming", {
93
+ source,
94
+ uiStateKind: currentUIStateKind,
95
+ sequenceLength: sequence.length,
96
+ isStartupWrite,
97
+ });
98
+
99
+ if (!isStartupWrite && !isTranscriptClear) {
100
+ return true;
101
+ }
102
+ }
103
+
104
+ return writeGuardedTerminalOutput(write, sequence, {
105
+ source,
106
+ stream: channel,
107
+ origin: "ubume",
108
+ action: "allowed",
109
+ lifecycleState: currentUIStateKind,
110
+ });
111
+ }
112
+
113
+ export function traceTerminalClear(source: string, fields: Record<string, unknown>): void {
114
+ renderDebug.traceTerminalClear(source, fields);
115
+ }
116
+
117
+ export interface TerminalModeController {
118
+ write(sequence: string, source: string): boolean;
119
+ clearTranscript(source: string): void;
120
+ clearViewport(source: string): void;
121
+ setMouseReporting(enabled: boolean, source: string): void;
122
+ setBracketedPaste(enabled: boolean, source: string): void;
123
+ setAlternateScreen(enabled: boolean, source: string): void;
124
+ resetModes(): void;
125
+ }
126
+
127
+ export function createTerminalModeController(write: TerminalWrite): TerminalModeController {
128
+ let mouseReporting: boolean | null = null;
129
+ let bracketedPaste: boolean | null = null;
130
+ let alternateScreen: boolean | null = null;
131
+
132
+ const writeStdout = (sequence: string, source: string) =>
133
+ writeTerminalControl(write, "stdout", source, sequence);
134
+
135
+ return {
136
+ write: writeStdout,
137
+ clearTranscript(source) {
138
+ writeStdout(TERMINAL_SEQUENCES.transcriptClear, source.includes(":transcriptClear") ? source : `${source}:transcriptClear`);
139
+ },
140
+ clearViewport(source) {
141
+ writeStdout(TERMINAL_SEQUENCES.viewportClear, source.includes(":viewportClear") ? source : `${source}:viewportClear`);
142
+ },
143
+ setMouseReporting(enabled, source) {
144
+ if (mouseReporting === enabled) return;
145
+ mouseReporting = enabled;
146
+ writeStdout(enabled ? TERMINAL_SEQUENCES.mouseEnable : TERMINAL_SEQUENCES.mouseDisable, source);
147
+ },
148
+ setBracketedPaste(enabled, source) {
149
+ if (bracketedPaste === enabled) return;
150
+ bracketedPaste = enabled;
151
+ writeStdout(enabled ? TERMINAL_SEQUENCES.bracketedPasteEnable : TERMINAL_SEQUENCES.bracketedPasteDisable, source);
152
+ },
153
+ setAlternateScreen(enabled, source) {
154
+ if (alternateScreen === enabled) return;
155
+ alternateScreen = enabled;
156
+ writeStdout(enabled ? TERMINAL_SEQUENCES.alternateScreenEnable : TERMINAL_SEQUENCES.alternateScreenDisable, source);
157
+ },
158
+ resetModes() {
159
+ renderDebug.traceEvent("terminal", "resetModes", {
160
+ mouseReporting,
161
+ bracketedPaste,
162
+ alternateScreen,
163
+ });
164
+ mouseReporting = null;
165
+ bracketedPaste = null;
166
+ alternateScreen = null;
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,147 @@
1
+ // ─── terminalSanitize ─────────────────────────────────────────────────────────
2
+ // Strips unsafe ANSI/control sequences from subprocess and user input.
3
+ //
4
+ // Two levels of sanitization are provided:
5
+ //
6
+ // 1. sanitizeTerminalOutput / sanitizeTerminalLines / sanitizeTerminalInput
7
+ // — Full strip: removes ALL escape sequences (OSC, CSI, DCS, etc.) plus
8
+ // non-printable control bytes. Used for arbitrary subprocess output,
9
+ // user-typed text, and assistant deltas where we cannot trust the source.
10
+ //
11
+ // 2. sanitizeDiffOutput
12
+ // — Safe-passthrough mode for diff content that has already been classified
13
+ // as a code/diff segment by the markdown parser. Strips dangerous
14
+ // sequences (cursor movement, screen clear, bracketed-paste toggle, OSC
15
+ // hyperlinks, etc.) but INTENTIONALLY preserves SGR colour-only sequences
16
+ // (e.g. \x1b[32m … \x1b[0m) so that raw diff colour codes from tools like
17
+ // `git diff --color=always` survive into the Ink layer.
18
+ // NOTE: The recommended rendering path still uses React/Ink theme-based
19
+ // colouring (no raw ANSI needed), so this helper is available for future
20
+ // use but diff colour is primarily applied via getDiffTone() tones.
21
+
22
+ export interface SanitizeTerminalOptions {
23
+ preserveTabs?: boolean;
24
+ tabSize?: number;
25
+ }
26
+
27
+ const DEFAULT_TAB_SIZE = 2;
28
+
29
+ // ── Dangerous sequence patterns (always stripped) ─────────────────────────────
30
+ // OSC: Operating System Command — can set window titles, hyperlinks, etc.
31
+ const OSC_SEQUENCE = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
32
+ // DCS/PM/APC: Device Control String and friends — rare but risky
33
+ const DCS_PM_APC_SEQUENCE = /\u001B[PX^_][\s\S]*?\u001B\\/g;
34
+ // CSI: Control Sequence Introducer — covers cursor movement, erase, colour, etc.
35
+ const CSI_SEQUENCE = /\u001B\[[0-?]*[ -/]*[@-~]/g;
36
+ // ESC + single intermediate — Fe sequences (e.g. ESC M = reverse index)
37
+ const ESC_INTERMEDIATE_SEQUENCE = /\u001B[@-Z\\-_]/g;
38
+ // C1 control codes (0x80–0x9F) — can masquerade as CSI openers on some terminals
39
+ const SINGLE_C1_SEQUENCE = /[\u0080-\u009F]/g;
40
+ // Remaining non-printable bytes after the above passes
41
+ const DISALLOWED_CONTROL_BYTES = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
42
+
43
+ // ── SGR-only colour-safe allowlist (used by sanitizeDiffOutput) ───────────────
44
+ // Matches ONLY SGR (Select Graphic Rendition) sequences — the subset of CSI
45
+ // that carries colour/style information and has no side-effects on terminal
46
+ // state (no cursor movement, no erase, no mode changes).
47
+ // Pattern: ESC [ <params> m where <params> is digits/semicolons only.
48
+ // We keep these sequences when the caller asserts the content is safe diff output.
49
+ const SGR_COLOUR_SEQUENCE = /\u001B\[[\d;]*m/g;
50
+
51
+ function normalizeTabs(text: string, preserveTabs: boolean, tabSize: number): string {
52
+ if (preserveTabs) return text;
53
+ return text.replace(/\t/g, " ".repeat(Math.max(1, tabSize)));
54
+ }
55
+
56
+ /** Strip all terminal escape sequences. */
57
+ function stripTerminalSequences(raw: string): string {
58
+ return raw
59
+ .replace(OSC_SEQUENCE, "")
60
+ .replace(DCS_PM_APC_SEQUENCE, "")
61
+ .replace(CSI_SEQUENCE, "")
62
+ .replace(ESC_INTERMEDIATE_SEQUENCE, "")
63
+ .replace(SINGLE_C1_SEQUENCE, "");
64
+ }
65
+
66
+ /**
67
+ * Strip only the dangerous subset of terminal sequences, preserving SGR colour codes.
68
+ * Used for content we know is diff output and want to pass colour through.
69
+ * NOTE: Only call this on content that has already been classified as a safe diff
70
+ * segment — never on arbitrary subprocess or user input.
71
+ */
72
+ function stripDangerousSequencesPreserveSGR(raw: string): string {
73
+ return raw
74
+ .replace(OSC_SEQUENCE, "") // OSC: hyperlinks, titles — always strip
75
+ .replace(DCS_PM_APC_SEQUENCE, "") // DCS/PM/APC — always strip
76
+ // Strip CSI sequences that are NOT pure SGR colour codes.
77
+ // First mark safe SGR sequences with a placeholder, strip all CSI,
78
+ // then restore the safe ones.
79
+ .replace(SGR_COLOUR_SEQUENCE, (match) => `\u0000SGR:${match}\u0000`) // protect SGR
80
+ .replace(CSI_SEQUENCE, "") // strip dangerous CSI
81
+ .replace(/\u0000SGR:(\u001B\[[\d;]*m)\u0000/g, "$1") // restore SGR
82
+ .replace(ESC_INTERMEDIATE_SEQUENCE, "")
83
+ .replace(SINGLE_C1_SEQUENCE, "");
84
+ }
85
+
86
+ function stripUnsafeControls(text: string): string {
87
+ return text.replace(DISALLOWED_CONTROL_BYTES, "");
88
+ }
89
+
90
+ // ── Public API ────────────────────────────────────────────────────────────────
91
+
92
+ export function sanitizeTerminalOutput(raw: string, options: SanitizeTerminalOptions = {}): string {
93
+ if (!raw) return "";
94
+ const preserveTabs = options.preserveTabs ?? false;
95
+ const tabSize = options.tabSize ?? DEFAULT_TAB_SIZE;
96
+
97
+ const withoutSequences = stripTerminalSequences(raw);
98
+ const normalizedBreaks = withoutSequences
99
+ .replace(/\r\n/g, "\n")
100
+ .replace(/\r/g, "\n");
101
+ const withoutUnsafeControls = stripUnsafeControls(normalizedBreaks);
102
+ return normalizeTabs(withoutUnsafeControls, preserveTabs, tabSize);
103
+ }
104
+
105
+ export function sanitizeTerminalInput(raw: string): string {
106
+ return sanitizeTerminalOutput(raw, { preserveTabs: false, tabSize: DEFAULT_TAB_SIZE });
107
+ }
108
+
109
+ export function sanitizeTerminalLines(lines: string[]): string[] {
110
+ return lines
111
+ .map((line) => sanitizeTerminalOutput(line))
112
+ .map((line) => line.trimEnd())
113
+ .filter((line) => line.length > 0);
114
+ }
115
+
116
+ /**
117
+ * Sanitize diff output while preserving SGR colour escape sequences.
118
+ *
119
+ * This is intentionally less aggressive than sanitizeTerminalOutput so that
120
+ * diffs piped through `git diff --color=always` or similar tools retain their
121
+ * ANSI colour information. Only safe SGR (colour/style) codes are preserved;
122
+ * all cursor-movement, erase, and other side-effecting sequences are stripped.
123
+ *
124
+ * Width measurement of the resulting text must account for the invisible ANSI
125
+ * bytes — use stripAnsiForMeasurement() on the string before measuring.
126
+ *
127
+ * Safety: Never call this on arbitrary subprocess or user input. Only use it
128
+ * after the markdown parser has classified a segment as a code/diff block.
129
+ */
130
+ export function sanitizeDiffOutput(raw: string): string {
131
+ if (!raw) return "";
132
+ const withSafrSgr = stripDangerousSequencesPreserveSGR(raw);
133
+ const normalizedBreaks = withSafrSgr
134
+ .replace(/\r\n/g, "\n")
135
+ .replace(/\r/g, "\n");
136
+ return stripUnsafeControls(normalizedBreaks);
137
+ }
138
+
139
+ /**
140
+ * Strip ALL ANSI sequences from a string purely for width measurement purposes.
141
+ * Use this when you need the visual width of a string that may contain SGR codes.
142
+ */
143
+ export function stripAnsiForMeasurement(text: string): string {
144
+ return text
145
+ .replace(SGR_COLOUR_SEQUENCE, "")
146
+ .replace(DISALLOWED_CONTROL_BYTES, "");
147
+ }