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.
- package/README.md +88 -0
- package/bin/codexa-local-harness-bridge.js +3 -0
- package/bin/codexa.js +28 -0
- package/bin/ubume-local-harness-bridge.js +325 -0
- package/bin/ubume.js +398 -0
- package/package.json +66 -0
- package/src/app.tsx +5759 -0
- package/src/commands/handler.ts +889 -0
- package/src/config/appVersion.ts +69 -0
- package/src/config/buildInfo.ts +3 -0
- package/src/config/launchArgs.ts +196 -0
- package/src/config/layeredConfig.ts +853 -0
- package/src/config/legacyEnv.ts +16 -0
- package/src/config/persistence.ts +377 -0
- package/src/config/runtimeConfig.ts +558 -0
- package/src/config/settings.ts +405 -0
- package/src/config/toml-serialize.ts +109 -0
- package/src/config/trustStore.ts +84 -0
- package/src/config/updateCheckCache.ts +85 -0
- package/src/core/README.md +55 -0
- package/src/core/agent/loop.ts +464 -0
- package/src/core/agent/protocol.ts +345 -0
- package/src/core/agent/tools.ts +423 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/codex/codexExecArgs.ts +166 -0
- package/src/core/codex/codexLaunch.ts +163 -0
- package/src/core/codex/codexPrompt.ts +429 -0
- package/src/core/debug/inputDebug.ts +51 -0
- package/src/core/debug/localStreamDebug.ts +50 -0
- package/src/core/debug/modelStateDebug.ts +35 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.ts +160 -0
- package/src/core/executables/executableResolver.ts +164 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/models/codexCapabilities.ts +97 -0
- package/src/core/models/codexModelCapabilities.ts +624 -0
- package/src/core/models/codexModelsCacheSeed.ts +153 -0
- package/src/core/models/modelSpecs.ts +303 -0
- package/src/core/models/providerModelCache.ts +94 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.ts +398 -0
- package/src/core/process/CommandRunner.ts +280 -0
- package/src/core/process/processValidation.ts +111 -0
- package/src/core/providerLauncher/launcher.ts +220 -0
- package/src/core/providerLauncher/registry.ts +354 -0
- package/src/core/providerLauncher/types.ts +95 -0
- package/src/core/providerLauncher/workspaceConfig.ts +487 -0
- package/src/core/providerRuntime/anthropic.ts +580 -0
- package/src/core/providerRuntime/antigravity.ts +500 -0
- package/src/core/providerRuntime/capabilityProfile.ts +383 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/codexaCupy.ts +97 -0
- package/src/core/providerRuntime/codexaNative.ts +425 -0
- package/src/core/providerRuntime/contextMetadata.ts +397 -0
- package/src/core/providerRuntime/gemini.ts +789 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.ts +770 -0
- package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
- package/src/core/providerRuntime/localOutputBudget.ts +17 -0
- package/src/core/providerRuntime/mistralVibe.ts +667 -0
- package/src/core/providerRuntime/models.ts +175 -0
- package/src/core/providerRuntime/reasoning.ts +20 -0
- package/src/core/providerRuntime/registry.ts +286 -0
- package/src/core/providerRuntime/types.ts +142 -0
- package/src/core/providerRuntime/unsloth.ts +216 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.ts +378 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +91 -0
- package/src/core/shared/attachments.ts +101 -0
- package/src/core/shared/cleanupFastFail.ts +67 -0
- package/src/core/shared/clipboard.ts +24 -0
- package/src/core/shared/clipboardImage.ts +111 -0
- package/src/core/shared/githubDiagnostics.ts +222 -0
- package/src/core/shared/hollowResponseFormat.ts +39 -0
- package/src/core/terminal/clearFrameBoundary.ts +852 -0
- package/src/core/terminal/frameLock.ts +110 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/startupClear.ts +20 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.ts +169 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalTitle.ts +400 -0
- package/src/core/version/channel.ts +27 -0
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +203 -0
- package/src/core/workspace/appData.ts +107 -0
- package/src/core/workspace/conversationStore.ts +335 -0
- package/src/core/workspace/launchContext.ts +259 -0
- package/src/core/workspace/planStorage.ts +135 -0
- package/src/core/workspace/projectInstructions.ts +54 -0
- package/src/core/workspace/scratchDir.ts +64 -0
- package/src/core/workspace/workspaceActivity.ts +384 -0
- package/src/core/workspace/workspaceGuard.ts +377 -0
- package/src/core/workspace/workspaceRoot.ts +47 -0
- package/src/exec.ts +73 -0
- package/src/headless/execArgs.ts +296 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.tsx +270 -0
- package/src/legacyEnvBootstrap.ts +5 -0
- package/src/session/appSession.ts +771 -0
- package/src/session/chatLifecycle.ts +994 -0
- package/src/session/conversation.ts +107 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/persistedResponse.ts +93 -0
- package/src/session/planFlow.ts +159 -0
- package/src/session/planTranscript.ts +19 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +234 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/chrome/ActivityBars.tsx +68 -0
- package/src/ui/chrome/ActivityIndicator.tsx +58 -0
- package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
- package/src/ui/chrome/AppShell.tsx +474 -0
- package/src/ui/chrome/BottomComposer.tsx +1135 -0
- package/src/ui/chrome/DashCard.tsx +82 -0
- package/src/ui/chrome/RunFooter.tsx +65 -0
- package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
- package/src/ui/chrome/Spinner.tsx +25 -0
- package/src/ui/chrome/TopHeader.tsx +439 -0
- package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
- package/src/ui/chrome/busyStatusAnimation.ts +11 -0
- package/src/ui/input/commandNormalize.ts +66 -0
- package/src/ui/input/focus.ts +73 -0
- package/src/ui/input/imageAttachments.ts +18 -0
- package/src/ui/input/inputBuffer.ts +203 -0
- package/src/ui/input/pastedContent.ts +75 -0
- package/src/ui/input/rawArrowKeys.ts +28 -0
- package/src/ui/input/slashCommands.ts +43 -0
- package/src/ui/input/useStdinRawModeLease.ts +23 -0
- package/src/ui/layout.ts +560 -0
- package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
- package/src/ui/panels/AuthPanel.tsx +149 -0
- package/src/ui/panels/BackendPicker.tsx +28 -0
- package/src/ui/panels/ModePicker.tsx +31 -0
- package/src/ui/panels/ModelPicker.tsx +31 -0
- package/src/ui/panels/ModelPickerScreen.tsx +761 -0
- package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
- package/src/ui/panels/Panel.tsx +51 -0
- package/src/ui/panels/PermissionsPanel.tsx +78 -0
- package/src/ui/panels/PlanActionPicker.tsx +187 -0
- package/src/ui/panels/ProviderPicker.tsx +753 -0
- package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
- package/src/ui/panels/ReasoningPicker.tsx +46 -0
- package/src/ui/panels/ResumePicker.tsx +90 -0
- package/src/ui/panels/SelectionPanel.tsx +138 -0
- package/src/ui/panels/SettingsPanel.tsx +156 -0
- package/src/ui/panels/TextEntryPanel.tsx +139 -0
- package/src/ui/panels/ThemePicker.tsx +32 -0
- package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
- package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
- package/src/ui/panels/responsivePickerViewport.ts +64 -0
- package/src/ui/render/Markdown.tsx +331 -0
- package/src/ui/render/diffRenderer.ts +116 -0
- package/src/ui/render/logoVariants.ts +113 -0
- package/src/ui/render/modeDisplay.ts +52 -0
- package/src/ui/render/outputPipeline.ts +64 -0
- package/src/ui/render/runtimeDisplay.ts +128 -0
- package/src/ui/render/terminalAnswerFormat.ts +128 -0
- package/src/ui/render/textLayout.ts +392 -0
- package/src/ui/theme.tsx +274 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
- package/src/ui/timeline/AgentBlock.tsx +130 -0
- package/src/ui/timeline/StaticIntroItem.tsx +54 -0
- package/src/ui/timeline/ThinkingBlock.tsx +100 -0
- package/src/ui/timeline/Timeline.tsx +1410 -0
- package/src/ui/timeline/TranscriptShell.tsx +302 -0
- package/src/ui/timeline/TurnGroup.tsx +673 -0
- package/src/ui/timeline/layoutListWindow.ts +145 -0
- package/src/ui/timeline/liveViewportWindow.ts +68 -0
- package/src/ui/timeline/progressEntries.ts +156 -0
- package/src/ui/timeline/runActivityView.ts +37 -0
- package/src/ui/timeline/staticTranscriptCache.ts +174 -0
- package/src/ui/timeline/streamCoalesce.ts +53 -0
- package/src/ui/timeline/timelineMeasure.ts +3273 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useFocus, useInput } from "ink";
|
|
3
|
+
import type { ToolApprovalDecision, ToolApprovalRequest } from "../../core/providers/types.js";
|
|
4
|
+
import { useTheme } from "../theme.js";
|
|
5
|
+
|
|
6
|
+
const OPTIONS: Array<{ label: string; value: ToolApprovalDecision }> = [
|
|
7
|
+
{ label: "Allow once", value: "allow-once" },
|
|
8
|
+
{ label: "Allow matching action for this run", value: "allow-for-run" },
|
|
9
|
+
{ label: "Deny", value: "deny" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function ToolApprovalPanel({ focusId, request, onSelect, onCancelRun }: {
|
|
13
|
+
focusId: string;
|
|
14
|
+
request: ToolApprovalRequest;
|
|
15
|
+
onSelect: (decision: ToolApprovalDecision) => void;
|
|
16
|
+
onCancelRun?: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
const theme = useTheme();
|
|
19
|
+
const { isFocused } = useFocus({ id: focusId, autoFocus: true });
|
|
20
|
+
const [selected, setSelected] = useState(0);
|
|
21
|
+
useInput((input, key) => {
|
|
22
|
+
if (key.ctrl && input === "c") return onCancelRun?.();
|
|
23
|
+
if (key.escape) return onSelect("deny");
|
|
24
|
+
if (key.upArrow || input === "k") return setSelected((value) => Math.max(0, value - 1));
|
|
25
|
+
if (key.downArrow || key.tab || input === "j") return setSelected((value) => Math.min(OPTIONS.length - 1, value + 1));
|
|
26
|
+
if (key.return) onSelect(OPTIONS[selected]!.value);
|
|
27
|
+
}, { isActive: isFocused });
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Box flexDirection="column" borderStyle="round" borderColor={theme.warning} paddingX={2} paddingY={1} width="100%">
|
|
31
|
+
<Text color={theme.warning} bold>LOCAL MODEL PERMISSION</Text>
|
|
32
|
+
<Text color={theme.text}>The local model wants to run {request.tool}.</Text>
|
|
33
|
+
{request.command && <Text color={theme.textMuted}>Command: {request.command}</Text>}
|
|
34
|
+
{request.paths.map((path) => <Text key={path} color={theme.textMuted}>Path: {path}</Text>)}
|
|
35
|
+
{request.tool === "run_shell" && <Text color={theme.warning}>Shell commands can affect files, processes, and the network.</Text>}
|
|
36
|
+
<Box flexDirection="column" marginTop={1}>
|
|
37
|
+
{OPTIONS.map((option, index) => (
|
|
38
|
+
<Text key={option.value} color={index === selected ? theme.accent : theme.textMuted} bold={index === selected}>
|
|
39
|
+
{index === selected ? "› " : " "}{option.label}
|
|
40
|
+
</Text>
|
|
41
|
+
))}
|
|
42
|
+
</Box>
|
|
43
|
+
<Text color={theme.textDim}>↑/↓ or j/k to navigate · Enter to select · Esc to deny</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, useFocus, useInput, useStdin } from "ink";
|
|
3
|
+
import { useTheme } from "../theme.js";
|
|
4
|
+
import { getHorizontalArrowDirection, type HorizontalArrowDirection } from "../input/rawArrowKeys.js";
|
|
5
|
+
import { UBUME_NPM_PACKAGE, formatVersionLabel } from "../../core/version/updateCheck.js";
|
|
6
|
+
import {
|
|
7
|
+
formatPermissionGuidance,
|
|
8
|
+
getUpdateCommand,
|
|
9
|
+
isPermissionError,
|
|
10
|
+
runUpdateCommand,
|
|
11
|
+
type GlobalPackageManager,
|
|
12
|
+
} from "../../core/version/packageManager.js";
|
|
13
|
+
import type { CommandResult, CommandStreamHandlers } from "../../core/process/CommandRunner.js";
|
|
14
|
+
|
|
15
|
+
export type UpdateUIState = "available" | "installing" | "success" | "failed";
|
|
16
|
+
|
|
17
|
+
export type RunUpdateFn = (
|
|
18
|
+
pm: GlobalPackageManager,
|
|
19
|
+
handlers?: CommandStreamHandlers,
|
|
20
|
+
) => { result: Promise<CommandResult>; cancel: () => void };
|
|
21
|
+
|
|
22
|
+
const MENU_ITEMS = [
|
|
23
|
+
{ label: "Update now" },
|
|
24
|
+
{ label: "Later" },
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
type HorizontalDirection = HorizontalArrowDirection;
|
|
28
|
+
|
|
29
|
+
export { getHorizontalArrowDirection };
|
|
30
|
+
|
|
31
|
+
interface UpdatePromptPanelProps {
|
|
32
|
+
focusId: string;
|
|
33
|
+
currentVersion: string;
|
|
34
|
+
latestVersion: string;
|
|
35
|
+
packageManager: GlobalPackageManager;
|
|
36
|
+
/** Test seam — defaults to the real cross-platform runner. */
|
|
37
|
+
runUpdate?: RunUpdateFn;
|
|
38
|
+
onSkip: () => void;
|
|
39
|
+
onRestart: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function UpdatePromptPanel({
|
|
43
|
+
focusId,
|
|
44
|
+
currentVersion,
|
|
45
|
+
latestVersion,
|
|
46
|
+
packageManager,
|
|
47
|
+
runUpdate,
|
|
48
|
+
onSkip,
|
|
49
|
+
onRestart,
|
|
50
|
+
}: UpdatePromptPanelProps) {
|
|
51
|
+
const theme = useTheme();
|
|
52
|
+
const { stdin } = useStdin();
|
|
53
|
+
const { isFocused, focus } = useFocus({ id: focusId, autoFocus: true });
|
|
54
|
+
|
|
55
|
+
const [updateState, setUpdateState] = useState<UpdateUIState>("available");
|
|
56
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
57
|
+
const [outputLines, setOutputLines] = useState<string[]>([]);
|
|
58
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
59
|
+
|
|
60
|
+
const rawArrowRef = useRef<HorizontalDirection | null>(null);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const handleRawInput = (chunk: Buffer | string) => {
|
|
64
|
+
rawArrowRef.current = getHorizontalArrowDirection(
|
|
65
|
+
typeof chunk === "string" ? chunk : chunk.toString(),
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
stdin.on("data", handleRawInput);
|
|
69
|
+
return () => {
|
|
70
|
+
stdin.off("data", handleRawInput);
|
|
71
|
+
};
|
|
72
|
+
}, [stdin]);
|
|
73
|
+
|
|
74
|
+
useInput((input, key) => {
|
|
75
|
+
if (key.escape) {
|
|
76
|
+
if (updateState === "installing") {
|
|
77
|
+
setOutputLines([]);
|
|
78
|
+
setErrorMessage(null);
|
|
79
|
+
setUpdateState("available");
|
|
80
|
+
// Ink clears the active focus id for every bare Escape before
|
|
81
|
+
// dispatching useInput handlers. Cancellation keeps this panel open,
|
|
82
|
+
// so explicitly retain its focus for the restored available state.
|
|
83
|
+
focus(focusId);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
onSkip();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (updateState === "available") {
|
|
90
|
+
const rawArrow = rawArrowRef.current;
|
|
91
|
+
rawArrowRef.current = null;
|
|
92
|
+
if (key.leftArrow || rawArrow === "left" || input === "h") {
|
|
93
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (key.rightArrow || rawArrow === "right" || input === "l") {
|
|
97
|
+
setSelectedIndex((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (key.return) {
|
|
101
|
+
if (selectedIndex === 0) {
|
|
102
|
+
setOutputLines([]);
|
|
103
|
+
setErrorMessage(null);
|
|
104
|
+
setUpdateState("installing");
|
|
105
|
+
} else {
|
|
106
|
+
onSkip();
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
} else if (updateState === "success") {
|
|
111
|
+
if (key.return) {
|
|
112
|
+
onRestart();
|
|
113
|
+
}
|
|
114
|
+
} else if (updateState === "failed") {
|
|
115
|
+
if (key.return) {
|
|
116
|
+
onSkip();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}, { isActive: isFocused });
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (updateState !== "installing") return;
|
|
123
|
+
|
|
124
|
+
let disposed = false;
|
|
125
|
+
const appendLines = (text: string) => {
|
|
126
|
+
if (disposed) return;
|
|
127
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
128
|
+
if (lines.length > 0) {
|
|
129
|
+
setOutputLines((prev) => [...prev, ...lines]);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const runner = runUpdate ?? runUpdateCommand;
|
|
134
|
+
const { result, cancel } = runner(packageManager, {
|
|
135
|
+
onStdout: appendLines,
|
|
136
|
+
onStderr: appendLines,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let settled = false;
|
|
140
|
+
void result.then((res) => {
|
|
141
|
+
if (disposed) return;
|
|
142
|
+
settled = true;
|
|
143
|
+
if (res.status === "completed" && res.exitCode === 0) {
|
|
144
|
+
setUpdateState("success");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (isPermissionError(res)) {
|
|
148
|
+
setErrorMessage(formatPermissionGuidance(packageManager));
|
|
149
|
+
} else {
|
|
150
|
+
setErrorMessage(res.userMessage);
|
|
151
|
+
}
|
|
152
|
+
setUpdateState("failed");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
disposed = true;
|
|
157
|
+
if (!settled) cancel();
|
|
158
|
+
};
|
|
159
|
+
}, [updateState, packageManager, runUpdate]);
|
|
160
|
+
|
|
161
|
+
const footerText = updateState === "available"
|
|
162
|
+
? "←/→ to choose · Enter to confirm · Esc to close"
|
|
163
|
+
: updateState === "success"
|
|
164
|
+
? "Enter to restart · Esc to stay in Ubume"
|
|
165
|
+
: updateState === "installing"
|
|
166
|
+
? "Esc to cancel"
|
|
167
|
+
: "Esc to close";
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<Box flexDirection="column" width="100%" marginTop={1}>
|
|
171
|
+
<Box
|
|
172
|
+
borderStyle="round"
|
|
173
|
+
borderColor={updateState === "available" ? theme.borderFocused : theme.border}
|
|
174
|
+
paddingX={2}
|
|
175
|
+
paddingY={1}
|
|
176
|
+
width="100%"
|
|
177
|
+
flexDirection="column"
|
|
178
|
+
>
|
|
179
|
+
{updateState === "available" && (
|
|
180
|
+
<>
|
|
181
|
+
<Text color={theme.accent} bold>{`Update available: Ubume ${latestVersion}`}</Text>
|
|
182
|
+
<Box marginTop={1}>
|
|
183
|
+
<Text color={theme.text}>{`Current version: ${currentVersion}`}</Text>
|
|
184
|
+
</Box>
|
|
185
|
+
<Text color={theme.textMuted}>{`Package: ${UBUME_NPM_PACKAGE}`}</Text>
|
|
186
|
+
<Text color={theme.textMuted}>{`Run: ${getUpdateCommand(packageManager).displayCommand}`}</Text>
|
|
187
|
+
<Box marginTop={1}>
|
|
188
|
+
{MENU_ITEMS.map((item, index) => (
|
|
189
|
+
<Text
|
|
190
|
+
key={item.label}
|
|
191
|
+
color={index === selectedIndex ? theme.text : theme.textMuted}
|
|
192
|
+
bold={index === selectedIndex}
|
|
193
|
+
>
|
|
194
|
+
{`${index === selectedIndex ? "❯ " : " "}[ ${item.label} ]${index === 0 ? " " : ""}`}
|
|
195
|
+
</Text>
|
|
196
|
+
))}
|
|
197
|
+
</Box>
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{updateState === "installing" && (
|
|
202
|
+
<>
|
|
203
|
+
<Text color={theme.text}>{`Installing Ubume ${latestVersion}...`}</Text>
|
|
204
|
+
{outputLines.map((line, i) => (
|
|
205
|
+
<Text key={i} color={theme.textMuted}>{line}</Text>
|
|
206
|
+
))}
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{updateState === "success" && (
|
|
211
|
+
<>
|
|
212
|
+
<Text color={theme.success}>{`Ubume ${formatVersionLabel(latestVersion)} installed successfully.`}</Text>
|
|
213
|
+
<Text color={theme.textMuted}>{"Restart Ubume to use the new version."}</Text>
|
|
214
|
+
<Box marginTop={1}>
|
|
215
|
+
<Text color={theme.text} bold>{"❯ [ Restart now ]"}</Text>
|
|
216
|
+
</Box>
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{updateState === "failed" && (
|
|
221
|
+
<>
|
|
222
|
+
<Text color={theme.error}>{"Update failed."}</Text>
|
|
223
|
+
{errorMessage != null && <Text color={theme.textMuted}>{errorMessage}</Text>}
|
|
224
|
+
{outputLines.slice(-5).map((line, i) => (
|
|
225
|
+
<Text key={i} color={theme.textDim}>{line}</Text>
|
|
226
|
+
))}
|
|
227
|
+
</>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
<Box marginTop={1}>
|
|
231
|
+
<Text color={theme.textDim}>{footerText}</Text>
|
|
232
|
+
</Box>
|
|
233
|
+
</Box>
|
|
234
|
+
</Box>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface ResponsivePickerViewportOptions {
|
|
2
|
+
itemCount: number;
|
|
3
|
+
selectedIndex: number;
|
|
4
|
+
availableRows: number;
|
|
5
|
+
chromeRows: number;
|
|
6
|
+
scrollOffset?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ResponsivePickerViewport {
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
capacity: number;
|
|
13
|
+
selectedIndex: number;
|
|
14
|
+
hasOverflow: boolean;
|
|
15
|
+
hiddenAbove: number;
|
|
16
|
+
hiddenBelow: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeInteger(value: number, fallback = 0): number {
|
|
20
|
+
return Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Builds a continuous list viewport from the rows left to the picker body.
|
|
25
|
+
* The previous offset is retained when possible, then moved only far enough to
|
|
26
|
+
* keep the selection visible. This makes keyboard navigation and resizes feel
|
|
27
|
+
* like scrolling one list instead of jumping between pages.
|
|
28
|
+
*/
|
|
29
|
+
export function calculateResponsivePickerViewport({
|
|
30
|
+
itemCount,
|
|
31
|
+
selectedIndex,
|
|
32
|
+
availableRows,
|
|
33
|
+
chromeRows,
|
|
34
|
+
scrollOffset = 0,
|
|
35
|
+
}: ResponsivePickerViewportOptions): ResponsivePickerViewport {
|
|
36
|
+
const count = Math.max(0, safeInteger(itemCount));
|
|
37
|
+
const rows = Math.max(0, safeInteger(availableRows));
|
|
38
|
+
const chrome = Math.max(0, safeInteger(chromeRows));
|
|
39
|
+
const capacity = count === 0 ? 0 : Math.max(1, rows - chrome);
|
|
40
|
+
const clampedSelection = count === 0
|
|
41
|
+
? 0
|
|
42
|
+
: Math.max(0, Math.min(count - 1, safeInteger(selectedIndex)));
|
|
43
|
+
const maxStart = Math.max(0, count - capacity);
|
|
44
|
+
let start = Math.max(0, Math.min(maxStart, safeInteger(scrollOffset)));
|
|
45
|
+
|
|
46
|
+
if (clampedSelection < start) {
|
|
47
|
+
start = clampedSelection;
|
|
48
|
+
} else if (clampedSelection >= start + capacity) {
|
|
49
|
+
start = clampedSelection - capacity + 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
start = Math.max(0, Math.min(maxStart, start));
|
|
53
|
+
const end = Math.min(count, start + capacity);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
start,
|
|
57
|
+
end,
|
|
58
|
+
capacity,
|
|
59
|
+
selectedIndex: clampedSelection,
|
|
60
|
+
hasOverflow: count > capacity,
|
|
61
|
+
hiddenAbove: start,
|
|
62
|
+
hiddenBelow: count - end,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTheme } from "../theme.js";
|
|
4
|
+
import { Panel } from "../panels/Panel.js";
|
|
5
|
+
import { maybeRenderDiff, type DiffRenderLineType } from "./diffRenderer.js";
|
|
6
|
+
import { formatLocalPathForTerminal, formatTerminalAnswerInline } from "./terminalAnswerFormat.js";
|
|
7
|
+
|
|
8
|
+
// ─── Markdown parser ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
type InlinePart =
|
|
11
|
+
| { kind: "text"; text: string }
|
|
12
|
+
| { kind: "code"; text: string }
|
|
13
|
+
| { kind: "bold"; text: string };
|
|
14
|
+
|
|
15
|
+
function parseInline(text: string): InlinePart[] {
|
|
16
|
+
const formattedText = formatTerminalAnswerInline(text);
|
|
17
|
+
const parts: InlinePart[] = [];
|
|
18
|
+
const pat = /`([^`\n]+)`|\*\*([^*\n]+)\*\*/g;
|
|
19
|
+
let last = 0;
|
|
20
|
+
let match: RegExpExecArray | null;
|
|
21
|
+
|
|
22
|
+
while ((match = pat.exec(formattedText)) !== null) {
|
|
23
|
+
if (match.index > last) parts.push({ kind: "text", text: formattedText.slice(last, match.index) });
|
|
24
|
+
if (match[1] !== undefined) parts.push({ kind: "code", text: formatLocalPathForTerminal(match[1]) });
|
|
25
|
+
if (match[2] !== undefined) parts.push({ kind: "bold", text: match[2] });
|
|
26
|
+
last = pat.lastIndex;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (last < formattedText.length) parts.push({ kind: "text", text: formattedText.slice(last) });
|
|
30
|
+
return parts.length > 0 ? parts : [{ kind: "text", text: formattedText }];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CodeSegment = { type: "code"; lang: string; lines: string[] };
|
|
34
|
+
export type HeaderSegment = { type: "header"; level: 1 | 2 | 3; parts: InlinePart[] };
|
|
35
|
+
export type ListItem = { num: number; parts: InlinePart[] };
|
|
36
|
+
export type ListSegment = { type: "list"; ordered: boolean; items: ListItem[] };
|
|
37
|
+
export type ParaSegment = { type: "para"; lines: InlinePart[][] };
|
|
38
|
+
export type Segment = CodeSegment | HeaderSegment | ListSegment | ParaSegment;
|
|
39
|
+
|
|
40
|
+
const SHELL_CODE_LANGUAGES = new Set([
|
|
41
|
+
"bash", "sh", "shell", "zsh", "fish", "powershell", "pwsh", "cmd", "bat", "batch",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export function isShellCodeLanguage(language: string): boolean {
|
|
45
|
+
return SHELL_CODE_LANGUAGES.has(language.trim().toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FENCE_RE = /^```(.*)$/;
|
|
49
|
+
const HEADER_RE = /^(#{1,3})\s+(.+)/;
|
|
50
|
+
const BULLET_RE = /^\s*[-*]\s+(.+)/;
|
|
51
|
+
const ORDERED_RE = /^\s*(\d+)\.\s+(.+)/;
|
|
52
|
+
|
|
53
|
+
function isBlockStart(line: string): boolean {
|
|
54
|
+
return FENCE_RE.test(line) || HEADER_RE.test(line) || BULLET_RE.test(line) || ORDERED_RE.test(line);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseMarkdown(content: string): Segment[] {
|
|
58
|
+
const lines = content.split("\n");
|
|
59
|
+
const out: Segment[] = [];
|
|
60
|
+
let index = 0;
|
|
61
|
+
|
|
62
|
+
while (index < lines.length) {
|
|
63
|
+
const line = lines[index]!;
|
|
64
|
+
|
|
65
|
+
if (!line.trim()) {
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fenceMatch = FENCE_RE.exec(line);
|
|
71
|
+
if (fenceMatch) {
|
|
72
|
+
const codeLines: string[] = [];
|
|
73
|
+
const lang = (fenceMatch[1] ?? "").trim();
|
|
74
|
+
index += 1;
|
|
75
|
+
while (index < lines.length && !FENCE_RE.test(lines[index]!)) {
|
|
76
|
+
codeLines.push(lines[index]!);
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
index += 1;
|
|
80
|
+
out.push({ type: "code", lang, lines: codeLines });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const headerMatch = HEADER_RE.exec(line);
|
|
85
|
+
if (headerMatch) {
|
|
86
|
+
out.push({
|
|
87
|
+
type: "header",
|
|
88
|
+
level: Math.min(3, headerMatch[1]!.length) as 1 | 2 | 3,
|
|
89
|
+
parts: parseInline(headerMatch[2]!),
|
|
90
|
+
});
|
|
91
|
+
index += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (BULLET_RE.test(line)) {
|
|
96
|
+
const items: ListItem[] = [];
|
|
97
|
+
let counter = 1;
|
|
98
|
+
while (index < lines.length) {
|
|
99
|
+
const match = BULLET_RE.exec(lines[index]!);
|
|
100
|
+
if (!match) break;
|
|
101
|
+
items.push({ num: counter, parts: parseInline(match[1]!) });
|
|
102
|
+
counter += 1;
|
|
103
|
+
index += 1;
|
|
104
|
+
}
|
|
105
|
+
out.push({ type: "list", ordered: false, items });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (ORDERED_RE.test(line)) {
|
|
110
|
+
const items: ListItem[] = [];
|
|
111
|
+
while (index < lines.length) {
|
|
112
|
+
const match = ORDERED_RE.exec(lines[index]!);
|
|
113
|
+
if (!match) break;
|
|
114
|
+
items.push({ num: Number.parseInt(match[1]!, 10), parts: parseInline(match[2]!) });
|
|
115
|
+
index += 1;
|
|
116
|
+
}
|
|
117
|
+
out.push({ type: "list", ordered: true, items });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const paraLines: InlinePart[][] = [];
|
|
122
|
+
while (index < lines.length && !isBlockStart(lines[index]!) && lines[index]!.trim() !== '') {
|
|
123
|
+
paraLines.push(parseInline(lines[index]!));
|
|
124
|
+
index += 1;
|
|
125
|
+
}
|
|
126
|
+
if (paraLines.length > 0) {
|
|
127
|
+
out.push({ type: "para", lines: paraLines });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Rendering helpers ────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function InlineText({ parts, color }: { parts: InlinePart[]; color: string }) {
|
|
137
|
+
const theme = useTheme();
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Text color={color} wrap="wrap">
|
|
141
|
+
{parts.map((part, index) => {
|
|
142
|
+
if (part.kind === "code") return <Text key={index} color={theme.info}>{part.text}</Text>;
|
|
143
|
+
if (part.kind === "bold") return <Text key={index} bold>{part.text}</Text>;
|
|
144
|
+
return <Text key={index}>{part.text}</Text>;
|
|
145
|
+
})}
|
|
146
|
+
</Text>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderTreeLabel(label: string, theme: ReturnType<typeof useTheme>) {
|
|
151
|
+
const trimmed = label.trimEnd();
|
|
152
|
+
const slash = trimmed.endsWith("/") ? "/" : "";
|
|
153
|
+
const base = slash ? trimmed.slice(0, -1) : trimmed;
|
|
154
|
+
const lastDot = base.lastIndexOf(".");
|
|
155
|
+
const isDirectory = slash === "/" || (lastDot <= 0 && base.length > 0);
|
|
156
|
+
|
|
157
|
+
if (isDirectory) {
|
|
158
|
+
return (
|
|
159
|
+
<Text color={theme.text} bold>
|
|
160
|
+
{base}
|
|
161
|
+
{slash}
|
|
162
|
+
</Text>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<Text color={theme.text}>
|
|
168
|
+
{base.slice(0, lastDot)}
|
|
169
|
+
<Text color={theme.textDim}>{base.slice(lastDot)}</Text>
|
|
170
|
+
</Text>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function TreeLine({ line }: { line: string }) {
|
|
175
|
+
const theme = useTheme();
|
|
176
|
+
const match = /^([│├└─\s]*)(.*)$/.exec(line);
|
|
177
|
+
const prefix = match?.[1] ?? "";
|
|
178
|
+
const label = match?.[2] ?? line;
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Box width="100%">
|
|
182
|
+
<Text color={theme.textDim}>{prefix}</Text>
|
|
183
|
+
{renderTreeLabel(label, theme)}
|
|
184
|
+
</Box>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isTreeLine(line: string): boolean {
|
|
189
|
+
return /^[│├└─\s]+/.test(line);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getDiffColor(kind: DiffRenderLineType, theme: ReturnType<typeof useTheme>): string {
|
|
193
|
+
switch (kind) {
|
|
194
|
+
case "add":
|
|
195
|
+
return theme.success;
|
|
196
|
+
case "remove":
|
|
197
|
+
return theme.error;
|
|
198
|
+
case "hunk":
|
|
199
|
+
return theme.accent;
|
|
200
|
+
case "file":
|
|
201
|
+
case "meta":
|
|
202
|
+
return theme.info;
|
|
203
|
+
case "context":
|
|
204
|
+
default:
|
|
205
|
+
return theme.textMuted;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
export function RenderMessage({ segments, width, brightHeadings = false }: { segments: Segment[]; width: number; brightHeadings?: boolean }) {
|
|
212
|
+
const theme = useTheme();
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<Box flexDirection="column" width="100%">
|
|
216
|
+
{segments.map((segment, index) => {
|
|
217
|
+
const marginTop = index > 0 ? 1 : 0;
|
|
218
|
+
|
|
219
|
+
if (segment.type === "code") {
|
|
220
|
+
const lang = (segment.lang || "").toLowerCase();
|
|
221
|
+
const looksLikeTree = segment.lines.some((line) => isTreeLine(line));
|
|
222
|
+
|
|
223
|
+
let title = segment.lang || "code";
|
|
224
|
+
let codeLines = segment.lines;
|
|
225
|
+
const firstLine = codeLines[0]?.trim() || "";
|
|
226
|
+
if (/^[a-zA-Z0-9_\-\.\/]+\.[a-zA-Z0-9]+$/.test(firstLine)) {
|
|
227
|
+
title = firstLine;
|
|
228
|
+
codeLines = codeLines.slice(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const panelWidth = Math.max(10, width);
|
|
232
|
+
const diffLines = maybeRenderDiff(codeLines.join("\n"), { force: lang === "diff" });
|
|
233
|
+
|
|
234
|
+
if (isShellCodeLanguage(lang)) {
|
|
235
|
+
const marker = lang === "cmd" || lang === "bat" || lang === "batch"
|
|
236
|
+
? `REM ${lang}`
|
|
237
|
+
: `# ${lang}`;
|
|
238
|
+
return (
|
|
239
|
+
<Box key={index} marginTop={marginTop} flexDirection="column" paddingLeft={2} width="100%">
|
|
240
|
+
<Text color={theme.textDim}>{marker}</Text>
|
|
241
|
+
{codeLines.map((line, lineIndex) => (
|
|
242
|
+
<Text key={lineIndex} color={theme.textMuted} wrap="wrap">{line || " "}</Text>
|
|
243
|
+
))}
|
|
244
|
+
</Box>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Box
|
|
250
|
+
key={index}
|
|
251
|
+
marginTop={marginTop}
|
|
252
|
+
flexDirection="column"
|
|
253
|
+
paddingLeft={2}
|
|
254
|
+
width="100%"
|
|
255
|
+
>
|
|
256
|
+
<Panel cols={panelWidth} title={title}>
|
|
257
|
+
{diffLines
|
|
258
|
+
? diffLines.map((line, lineIndex) => (
|
|
259
|
+
<Text key={lineIndex} color={getDiffColor(line.type, theme)} wrap="wrap">
|
|
260
|
+
{line.text || " "}
|
|
261
|
+
</Text>
|
|
262
|
+
))
|
|
263
|
+
: codeLines.map((line, lineIndex) => (
|
|
264
|
+
looksLikeTree ? (
|
|
265
|
+
<TreeLine key={lineIndex} line={line || " "} />
|
|
266
|
+
) : (
|
|
267
|
+
<Box key={lineIndex}>
|
|
268
|
+
<Box width={3} flexShrink={0} marginRight={1} justifyContent="flex-end">
|
|
269
|
+
<Text color={theme.textDim}>{lineIndex + 1}</Text>
|
|
270
|
+
</Box>
|
|
271
|
+
<Text color={theme.textMuted} wrap="wrap">{line || " "}</Text>
|
|
272
|
+
</Box>
|
|
273
|
+
)
|
|
274
|
+
))}
|
|
275
|
+
</Panel>
|
|
276
|
+
</Box>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (segment.type === "header") {
|
|
281
|
+
const color = segment.level === 1 ? theme.accent : segment.level === 2 ? theme.text : (brightHeadings ? theme.text : theme.textMuted);
|
|
282
|
+
return (
|
|
283
|
+
<Box key={index} flexDirection="column" marginTop={marginTop}>
|
|
284
|
+
{segment.level <= 2 && <Text color={theme.border}>{"───"}</Text>}
|
|
285
|
+
<Box>
|
|
286
|
+
<Text color={color}>{segment.level <= 2 ? "✧ " : "• "}</Text>
|
|
287
|
+
<InlineText parts={segment.parts} color={color} />
|
|
288
|
+
</Box>
|
|
289
|
+
</Box>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (segment.type === "list") {
|
|
294
|
+
return (
|
|
295
|
+
<Box key={index} flexDirection="column" marginTop={marginTop}>
|
|
296
|
+
{segment.items.map((item, itemIndex) => (
|
|
297
|
+
<Box key={itemIndex}>
|
|
298
|
+
<Text color={theme.accent}>{segment.ordered ? `${item.num}. ` : "• "}</Text>
|
|
299
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
300
|
+
<InlineText parts={item.parts} color={theme.text} />
|
|
301
|
+
</Box>
|
|
302
|
+
</Box>
|
|
303
|
+
))}
|
|
304
|
+
</Box>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const hasContent = segment.lines.some(
|
|
309
|
+
(parts) => !(parts.length === 1 && parts[0]!.kind === "text" && !parts[0]!.text.trim()),
|
|
310
|
+
);
|
|
311
|
+
if (!hasContent) return null;
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<Box key={index} flexDirection="column" marginTop={marginTop}>
|
|
315
|
+
{segment.lines.map((parts, lineIndex) => {
|
|
316
|
+
if (parts.length === 1 && parts[0]!.kind === "text" && !parts[0]!.text.trim()) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return <InlineText key={lineIndex} parts={parts} color={theme.text} />;
|
|
320
|
+
})}
|
|
321
|
+
</Box>
|
|
322
|
+
);
|
|
323
|
+
})}
|
|
324
|
+
</Box>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const MemoizedRenderMessage = React.memo(RenderMessage, (prev, next) => (
|
|
329
|
+
prev.segments === next.segments && prev.width === next.width && prev.brightHeadings === next.brightHeadings
|
|
330
|
+
));
|
|
331
|
+
|