@abacus-ai/cli 1.106.25007 → 2.0.0-canary.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/.oxlintrc.json +8 -0
- package/dist/index.mjs +12603 -0
- package/package.json +7 -39
- package/resources/abacus.ico +0 -0
- package/resources/entitlements.plist +9 -0
- package/src/__e2e__/README.md +196 -0
- package/src/__e2e__/agent-interactions.e2e.test.tsx +61 -0
- package/src/__e2e__/cli-commands.e2e.test.tsx +77 -0
- package/src/__e2e__/conversation-throttle.e2e.test.ts +453 -0
- package/src/__e2e__/conversation.e2e.test.tsx +56 -0
- package/src/__e2e__/diff-preview.e2e.test.tsx +3399 -0
- package/src/__e2e__/file-creation.e2e.test.tsx +149 -0
- package/src/__e2e__/helpers/test-helpers.ts +450 -0
- package/src/__e2e__/keyboard-navigation.e2e.test.tsx +34 -0
- package/src/__e2e__/llm-models.e2e.test.ts +402 -0
- package/src/__e2e__/mcp/mcp-callback-flow.e2e.test.tsx +71 -0
- package/src/__e2e__/mcp/mcp-full-app-ui.e2e.test.tsx +167 -0
- package/src/__e2e__/mcp/mcp-ui-rendering.e2e.test.tsx +185 -0
- package/src/__e2e__/repl.e2e.test.tsx +78 -0
- package/src/__e2e__/shell-compatibility.e2e.test.tsx +76 -0
- package/src/__e2e__/theme-mcp.e2e.test.tsx +98 -0
- package/src/__e2e__/tool-permissions.e2e.test.tsx +66 -0
- package/src/args.ts +22 -0
- package/src/components/__tests__/react-compiler.test.tsx +78 -0
- package/src/components/__tests__/status-indicator.test.tsx +403 -0
- package/src/components/composer/__tests__/bash-runner.test.tsx +263 -0
- package/src/components/composer/agent-mode-indicator.tsx +63 -0
- package/src/components/composer/bash-runner.tsx +54 -0
- package/src/components/composer/commands/default-commands.tsx +615 -0
- package/src/components/composer/commands/handler.tsx +59 -0
- package/src/components/composer/commands/picker.tsx +273 -0
- package/src/components/composer/commands/registry.ts +233 -0
- package/src/components/composer/commands/types.ts +33 -0
- package/src/components/composer/context.tsx +88 -0
- package/src/components/composer/file-mention-picker.tsx +83 -0
- package/src/components/composer/help.tsx +44 -0
- package/src/components/composer/index.tsx +1006 -0
- package/src/components/composer/mentions.ts +57 -0
- package/src/components/composer/message-queue.tsx +70 -0
- package/src/components/composer/mode-panel.tsx +35 -0
- package/src/components/composer/modes/__tests__/bash-handler.test.tsx +755 -0
- package/src/components/composer/modes/__tests__/bash-renderer.test.tsx +1108 -0
- package/src/components/composer/modes/bash-handler.tsx +132 -0
- package/src/components/composer/modes/bash-renderer.tsx +175 -0
- package/src/components/composer/modes/default-handlers.tsx +33 -0
- package/src/components/composer/modes/index.ts +41 -0
- package/src/components/composer/modes/types.ts +21 -0
- package/src/components/composer/persistent-shell.ts +283 -0
- package/src/components/composer/process.ts +65 -0
- package/src/components/composer/types.ts +9 -0
- package/src/components/composer/use-mention-search.ts +68 -0
- package/src/components/error-boundry.tsx +60 -0
- package/src/components/exit-message.tsx +29 -0
- package/src/components/expanded-view.tsx +74 -0
- package/src/components/file-completion.tsx +127 -0
- package/src/components/header.tsx +47 -0
- package/src/components/logo.tsx +37 -0
- package/src/components/segments.tsx +356 -0
- package/src/components/status-indicator.tsx +306 -0
- package/src/components/tool-group-summary.tsx +263 -0
- package/src/components/tool-permissions/ask-user-question-permission-ui.tsx +312 -0
- package/src/components/tool-permissions/diff-preview.tsx +355 -0
- package/src/components/tool-permissions/index.ts +5 -0
- package/src/components/tool-permissions/permission-options.tsx +375 -0
- package/src/components/tool-permissions/permission-preview-header.tsx +57 -0
- package/src/components/tool-permissions/tool-permission-ui.tsx +398 -0
- package/src/components/tools/agent/ask-user-question.tsx +101 -0
- package/src/components/tools/agent/enter-plan-mode.tsx +49 -0
- package/src/components/tools/agent/exit-plan-mode.tsx +75 -0
- package/src/components/tools/agent/handoff-to-main.tsx +27 -0
- package/src/components/tools/agent/subagent.tsx +37 -0
- package/src/components/tools/agent/todo-write.tsx +104 -0
- package/src/components/tools/browser/close-tab.tsx +58 -0
- package/src/components/tools/browser/computer.tsx +70 -0
- package/src/components/tools/browser/get-interactive-elements.tsx +54 -0
- package/src/components/tools/browser/get-tab-content.tsx +51 -0
- package/src/components/tools/browser/navigate-to.tsx +59 -0
- package/src/components/tools/browser/new-tab.tsx +60 -0
- package/src/components/tools/browser/perform-action.tsx +63 -0
- package/src/components/tools/browser/refresh-tab.tsx +43 -0
- package/src/components/tools/browser/switch-tab.tsx +58 -0
- package/src/components/tools/filesystem/delete-file.tsx +104 -0
- package/src/components/tools/filesystem/edit.tsx +220 -0
- package/src/components/tools/filesystem/list-dir.tsx +78 -0
- package/src/components/tools/filesystem/read-file.tsx +180 -0
- package/src/components/tools/filesystem/upload-image.tsx +76 -0
- package/src/components/tools/ide/ide-diagnostics.tsx +62 -0
- package/src/components/tools/index.ts +91 -0
- package/src/components/tools/mcp/mcp-tool.tsx +158 -0
- package/src/components/tools/search/fetch-url.tsx +73 -0
- package/src/components/tools/search/file-search.tsx +78 -0
- package/src/components/tools/search/grep.tsx +90 -0
- package/src/components/tools/search/semantic-search.tsx +66 -0
- package/src/components/tools/search/web-search.tsx +71 -0
- package/src/components/tools/shared/index.tsx +48 -0
- package/src/components/tools/shared/zod-coercion.ts +35 -0
- package/src/components/tools/terminal/bash-tool-output.tsx +174 -0
- package/src/components/tools/terminal/get-terminal-output.tsx +85 -0
- package/src/components/tools/terminal/run-in-terminal.tsx +106 -0
- package/src/components/tools/types.ts +16 -0
- package/src/components/tools.tsx +66 -0
- package/src/components/ui/__tests__/divider.test.tsx +61 -0
- package/src/components/ui/__tests__/gradient.test.tsx +125 -0
- package/src/components/ui/__tests__/input.test.tsx +166 -0
- package/src/components/ui/__tests__/select.test.tsx +273 -0
- package/src/components/ui/__tests__/shimmer.test.tsx +99 -0
- package/src/components/ui/blinking-indicator.tsx +25 -0
- package/src/components/ui/divider.tsx +162 -0
- package/src/components/ui/gradient.tsx +56 -0
- package/src/components/ui/input.tsx +228 -0
- package/src/components/ui/select.tsx +151 -0
- package/src/components/ui/shimmer.tsx +84 -0
- package/src/context/agent-mode.tsx +95 -0
- package/src/context/extension-file.tsx +136 -0
- package/src/context/network-activity.tsx +45 -0
- package/src/context/notification.tsx +62 -0
- package/src/context/shell-size.tsx +49 -0
- package/src/context/shell-title.tsx +38 -0
- package/src/entrypoints/print-mode.ts +312 -0
- package/src/entrypoints/repl.tsx +401 -0
- package/src/hooks/use-agent.ts +15 -0
- package/src/hooks/use-api-client.ts +1 -0
- package/src/hooks/use-available-height.ts +8 -0
- package/src/hooks/use-cleanup.ts +29 -0
- package/src/hooks/use-interrupt-manager.ts +242 -0
- package/src/hooks/use-models.ts +22 -0
- package/src/index.ts +217 -0
- package/src/lib/__tests__/ansi.test.ts +255 -0
- package/src/lib/__tests__/cli.test.ts +122 -0
- package/src/lib/__tests__/commands.test.ts +325 -0
- package/src/lib/__tests__/constants.test.ts +15 -0
- package/src/lib/__tests__/focusables.test.ts +25 -0
- package/src/lib/__tests__/fs.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.tsx +348 -0
- package/src/lib/__tests__/mcpCommandHandler.test.ts +173 -0
- package/src/lib/__tests__/mcpManagement.test.ts +38 -0
- package/src/lib/__tests__/path-paste.test.ts +144 -0
- package/src/lib/__tests__/path.test.ts +300 -0
- package/src/lib/__tests__/queries.test.ts +39 -0
- package/src/lib/__tests__/standaloneMcpService.test.ts +71 -0
- package/src/lib/__tests__/text-buffer.test.ts +328 -0
- package/src/lib/__tests__/text-utils.test.ts +32 -0
- package/src/lib/__tests__/timing.test.ts +78 -0
- package/src/lib/__tests__/utils.test.ts +238 -0
- package/src/lib/__tests__/vim-buffer-actions.test.ts +154 -0
- package/src/lib/ansi.ts +150 -0
- package/src/lib/cli-push-server.ts +112 -0
- package/src/lib/cli.ts +44 -0
- package/src/lib/clipboard.ts +226 -0
- package/src/lib/command-utils.ts +93 -0
- package/src/lib/commands.ts +270 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/extension-connection.ts +181 -0
- package/src/lib/focusables.ts +7 -0
- package/src/lib/fs.ts +533 -0
- package/src/lib/markdown/code-block.tsx +63 -0
- package/src/lib/markdown/index.ts +4 -0
- package/src/lib/markdown/link.tsx +19 -0
- package/src/lib/markdown/markdown.tsx +372 -0
- package/src/lib/markdown/types.ts +15 -0
- package/src/lib/mcpCommandHandler.ts +121 -0
- package/src/lib/mcpManagement.ts +44 -0
- package/src/lib/path-paste.ts +185 -0
- package/src/lib/path.ts +179 -0
- package/src/lib/queries.ts +15 -0
- package/src/lib/standaloneMcpService.ts +688 -0
- package/src/lib/status-utils.ts +237 -0
- package/src/lib/test-utils.tsx +72 -0
- package/src/lib/text-buffer.ts +2415 -0
- package/src/lib/text-utils.ts +272 -0
- package/src/lib/timing.ts +63 -0
- package/src/lib/types.ts +295 -0
- package/src/lib/utils.ts +182 -0
- package/src/lib/vim-buffer-actions.ts +732 -0
- package/src/providers/agent.tsx +1075 -0
- package/src/providers/api-client.tsx +43 -0
- package/src/services/logger.ts +85 -0
- package/src/terminal/detection.ts +187 -0
- package/src/terminal/exit.ts +279 -0
- package/src/terminal/notification.ts +83 -0
- package/src/terminal/progress.ts +201 -0
- package/src/terminal/setup.ts +797 -0
- package/src/terminal/suspend.ts +58 -0
- package/src/terminal/types.ts +51 -0
- package/src/theme/context.tsx +57 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/themed.tsx +35 -0
- package/src/theme/themes.json +546 -0
- package/src/theme/types.ts +110 -0
- package/src/tools/types.ts +59 -0
- package/src/tools/utils/__tests__/zod-coercion.test.ts +33 -0
- package/src/tools/utils/tool-ui-components.tsx +631 -0
- package/src/tools/utils/zod-coercion.ts +35 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +29 -0
- package/tsconfig.test.json +27 -0
- package/tsdown.config.ts +17 -0
- package/vitest.config.ts +76 -0
- package/README.md +0 -28
- package/dist/index.js +0 -26
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
import { BashHandlerState, BashRenderer, CommandHistoryItem } from "./bash-renderer.js";
|
|
4
|
+
import { ModeRenderProps } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type BashHandlerApi = {
|
|
7
|
+
submitCommand: (command: string) => void;
|
|
8
|
+
renderer: (props: ModeRenderProps) => ReactElement;
|
|
9
|
+
navigateHistory: (direction: "up" | "down") => string | null;
|
|
10
|
+
getActivity: () => "idle" | "pending" | "error";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Maximum number of commands to keep in history to prevent unbounded growth
|
|
14
|
+
const MAX_COMMAND_HISTORY = 1000;
|
|
15
|
+
const MAX_INPUT_HISTORY = 1000;
|
|
16
|
+
|
|
17
|
+
export function useBashHandler(): BashHandlerApi {
|
|
18
|
+
const [state, setState] = useState<BashHandlerState>({
|
|
19
|
+
submittedCommand: null,
|
|
20
|
+
runId: 0,
|
|
21
|
+
output: "",
|
|
22
|
+
isRunning: false,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const [commandHistoryItems, setCommandHistoryItems] = useState<CommandHistoryItem[]>([]);
|
|
26
|
+
const [inputHistory, setInputHistory] = useState<string[]>([]);
|
|
27
|
+
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
|
28
|
+
const shellHistoryLoaded = useRef(false);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (shellHistoryLoaded.current) return;
|
|
32
|
+
shellHistoryLoaded.current = true;
|
|
33
|
+
// @ts-expect-error — shell-history ships no type declarations
|
|
34
|
+
import("shell-history")
|
|
35
|
+
.then((mod: { shellHistory?: () => string[]; default?: () => string[] }) => {
|
|
36
|
+
const fn = mod.shellHistory ?? mod.default;
|
|
37
|
+
const history: string[] = typeof fn === "function" ? fn() : [];
|
|
38
|
+
setInputHistory(history.slice(-MAX_INPUT_HISTORY));
|
|
39
|
+
})
|
|
40
|
+
.catch(() => {});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const submitCommand: BashHandlerApi["submitCommand"] = useCallback((command: string) => {
|
|
44
|
+
const trimmedCommand = command.trim();
|
|
45
|
+
if (!trimmedCommand) return;
|
|
46
|
+
|
|
47
|
+
// Add to input history for up/down navigation, dedup consecutive identical commands (like HISTCONTROL=ignoredups)
|
|
48
|
+
setInputHistory((prev) => {
|
|
49
|
+
if (prev.length > 0 && prev[prev.length - 1] === trimmedCommand) {
|
|
50
|
+
return prev;
|
|
51
|
+
}
|
|
52
|
+
const updated = [...prev, trimmedCommand];
|
|
53
|
+
return updated.slice(-MAX_INPUT_HISTORY);
|
|
54
|
+
});
|
|
55
|
+
setHistoryIndex(-1);
|
|
56
|
+
|
|
57
|
+
// Incrementing runId will trigger BashRenderer's useEffect to cancel any previous command
|
|
58
|
+
setState((prev) => ({
|
|
59
|
+
submittedCommand: trimmedCommand,
|
|
60
|
+
runId: prev.runId + 1,
|
|
61
|
+
output: "",
|
|
62
|
+
isRunning: true,
|
|
63
|
+
}));
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const onCommandComplete = useCallback((output: string) => {
|
|
67
|
+
// Move the completed command to history, keeping only the most recent MAX_COMMAND_HISTORY items
|
|
68
|
+
setState((prev) => {
|
|
69
|
+
if (prev.submittedCommand) {
|
|
70
|
+
setCommandHistoryItems((history) => {
|
|
71
|
+
const updated = [...history, { command: prev.submittedCommand!, output }];
|
|
72
|
+
return updated.slice(-MAX_COMMAND_HISTORY);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Clear the current command and return to idle
|
|
77
|
+
return {
|
|
78
|
+
submittedCommand: null,
|
|
79
|
+
runId: prev.runId,
|
|
80
|
+
output: "",
|
|
81
|
+
isRunning: false,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const renderer: BashHandlerApi["renderer"] = useCallback(
|
|
87
|
+
(props: ModeRenderProps) => {
|
|
88
|
+
return (
|
|
89
|
+
<BashRenderer
|
|
90
|
+
state={state}
|
|
91
|
+
setState={setState}
|
|
92
|
+
onCommandComplete={onCommandComplete}
|
|
93
|
+
commandHistory={commandHistoryItems}
|
|
94
|
+
currentPrompt={props.prompt}
|
|
95
|
+
availableHeight={props.availableHeight}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
[state, onCommandComplete, commandHistoryItems],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const navigateHistory: BashHandlerApi["navigateHistory"] = useCallback(
|
|
103
|
+
(direction: "up" | "down") => {
|
|
104
|
+
if (inputHistory.length === 0) return null;
|
|
105
|
+
|
|
106
|
+
let newIndex = historyIndex;
|
|
107
|
+
if (direction === "up") {
|
|
108
|
+
// Navigate to previous command, but don't go below index 0 (stays at first command)
|
|
109
|
+
newIndex = historyIndex === -1 ? inputHistory.length - 1 : Math.max(0, historyIndex - 1);
|
|
110
|
+
} else {
|
|
111
|
+
newIndex = historyIndex === -1 ? -1 : historyIndex + 1;
|
|
112
|
+
if (newIndex >= inputHistory.length) {
|
|
113
|
+
newIndex = -1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setHistoryIndex(newIndex);
|
|
118
|
+
return newIndex === -1 ? "" : inputHistory[newIndex];
|
|
119
|
+
},
|
|
120
|
+
[inputHistory, historyIndex],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
submitCommand,
|
|
125
|
+
renderer,
|
|
126
|
+
navigateHistory,
|
|
127
|
+
getActivity: (): "idle" | "pending" | "error" => {
|
|
128
|
+
if (state.isRunning) return "pending";
|
|
129
|
+
return "idle";
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { View } from "@codellm/jar";
|
|
2
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
3
|
+
|
|
4
|
+
import { stripDangerousSequences } from "../../../lib/text-utils.js";
|
|
5
|
+
import { BashRunner } from "../bash-runner.js";
|
|
6
|
+
import { getPersistentShell } from "../persistent-shell.js";
|
|
7
|
+
|
|
8
|
+
export type BashHandlerState = {
|
|
9
|
+
submittedCommand: string | null;
|
|
10
|
+
runId: number;
|
|
11
|
+
output: string;
|
|
12
|
+
isRunning: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CommandHistoryItem = {
|
|
16
|
+
command: string;
|
|
17
|
+
output: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type BashRendererProps = {
|
|
21
|
+
state: BashHandlerState;
|
|
22
|
+
setState: React.Dispatch<React.SetStateAction<BashHandlerState>>;
|
|
23
|
+
onCommandComplete: (output: string) => void;
|
|
24
|
+
commandHistory: CommandHistoryItem[];
|
|
25
|
+
currentPrompt: string;
|
|
26
|
+
availableHeight: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function BashRenderer({
|
|
30
|
+
state,
|
|
31
|
+
setState,
|
|
32
|
+
onCommandComplete,
|
|
33
|
+
commandHistory,
|
|
34
|
+
currentPrompt,
|
|
35
|
+
availableHeight,
|
|
36
|
+
}: BashRendererProps) {
|
|
37
|
+
const { submittedCommand, runId } = state;
|
|
38
|
+
const normalized = useMemo(() => submittedCommand?.trim() || "", [submittedCommand]);
|
|
39
|
+
const lastRunIdRef = useRef<number>(-1);
|
|
40
|
+
// Use AbortController for better cancellation handling
|
|
41
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!normalized) return;
|
|
45
|
+
if (runId === lastRunIdRef.current) return;
|
|
46
|
+
lastRunIdRef.current = runId;
|
|
47
|
+
|
|
48
|
+
// Cancel any previous command execution
|
|
49
|
+
if (abortControllerRef.current) {
|
|
50
|
+
abortControllerRef.current.abort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create new AbortController for this command
|
|
54
|
+
const abortController = new AbortController();
|
|
55
|
+
abortControllerRef.current = abortController;
|
|
56
|
+
|
|
57
|
+
setState((prev) => ({ ...prev, isRunning: true, output: "" }));
|
|
58
|
+
runBash({
|
|
59
|
+
command: normalized,
|
|
60
|
+
setOutput: (output: string) => {
|
|
61
|
+
setState((prev) => ({ ...prev, output }));
|
|
62
|
+
},
|
|
63
|
+
signal: abortController.signal,
|
|
64
|
+
})
|
|
65
|
+
.then((output) => {
|
|
66
|
+
// Only process result if not cancelled
|
|
67
|
+
if (!abortController.signal.aborted) {
|
|
68
|
+
setState((prev) => ({ ...prev, isRunning: false }));
|
|
69
|
+
// Call onCommandComplete even when output is empty to signal completion
|
|
70
|
+
// If output is undefined, it means the command was cancelled, so don't call onCommandComplete
|
|
71
|
+
if (output !== undefined) {
|
|
72
|
+
onCommandComplete(output);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.catch((_error) => {
|
|
77
|
+
// Only process error if not cancelled
|
|
78
|
+
if (!abortController.signal.aborted) {
|
|
79
|
+
setState((prev) => ({ ...prev, isRunning: false }));
|
|
80
|
+
// Pass empty string for errors (not undefined, to distinguish from cancellation)
|
|
81
|
+
onCommandComplete("");
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
// Cancel command execution on cleanup
|
|
87
|
+
abortController.abort();
|
|
88
|
+
abortControllerRef.current = null;
|
|
89
|
+
};
|
|
90
|
+
}, [normalized, runId, setState, onCommandComplete]);
|
|
91
|
+
|
|
92
|
+
const isTyping = currentPrompt.trim().length > 0;
|
|
93
|
+
|
|
94
|
+
const historyToShow = submittedCommand ? 2 : 3;
|
|
95
|
+
const displayHistory = commandHistory.slice(-historyToShow);
|
|
96
|
+
const historyStartIndex = Math.max(0, commandHistory.length - historyToShow);
|
|
97
|
+
|
|
98
|
+
const totalCommands = displayHistory.length + (submittedCommand ? 1 : 0);
|
|
99
|
+
|
|
100
|
+
const availableForOutput = Math.max(1, availableHeight - totalCommands);
|
|
101
|
+
|
|
102
|
+
const maxLinesPerCommand =
|
|
103
|
+
totalCommands > 0 ? Math.max(3, Math.floor(availableForOutput / totalCommands)) : 10;
|
|
104
|
+
|
|
105
|
+
const shouldTruncate = isTyping || availableHeight < 15 || totalCommands > 1;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<View flexDirection="column">
|
|
109
|
+
{displayHistory.map((item, index) => {
|
|
110
|
+
// Use the original index from the full commandHistory array to ensure uniqueness
|
|
111
|
+
// This prevents key conflicts when the slice changes
|
|
112
|
+
const originalIndex = historyStartIndex + index;
|
|
113
|
+
return (
|
|
114
|
+
<BashRunner
|
|
115
|
+
key={`history-${originalIndex}`}
|
|
116
|
+
output={item.output}
|
|
117
|
+
command={item.command}
|
|
118
|
+
truncate={shouldTruncate}
|
|
119
|
+
maxLines={maxLinesPerCommand}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
{submittedCommand && (
|
|
124
|
+
<BashRunner
|
|
125
|
+
key={`submitted-${runId}`}
|
|
126
|
+
output={state.output}
|
|
127
|
+
command={normalized || submittedCommand}
|
|
128
|
+
truncate={shouldTruncate}
|
|
129
|
+
maxLines={maxLinesPerCommand}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type RunProps = {
|
|
137
|
+
command: string;
|
|
138
|
+
setOutput: (output: string) => void;
|
|
139
|
+
signal: AbortSignal;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
async function runBash({ command, setOutput, signal }: RunProps): Promise<string | undefined> {
|
|
143
|
+
const shell = getPersistentShell();
|
|
144
|
+
const { stream, exit } = await shell.executeCommand(command);
|
|
145
|
+
|
|
146
|
+
let commandOutput = "";
|
|
147
|
+
try {
|
|
148
|
+
for await (const chunk of stream) {
|
|
149
|
+
// Check for cancellation at the start of each iteration
|
|
150
|
+
if (signal.aborted) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
// Strip dangerous ANSI sequences but preserve colors (claude.js pattern)
|
|
154
|
+
commandOutput += stripDangerousSequences(chunk.data);
|
|
155
|
+
// Only update output if not cancelled
|
|
156
|
+
if (!signal.aborted) {
|
|
157
|
+
setOutput(commandOutput);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check cancellation before waiting for exit
|
|
162
|
+
if (signal.aborted) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await exit;
|
|
167
|
+
return commandOutput;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// If cancelled, return undefined; otherwise rethrow
|
|
170
|
+
if (signal.aborted) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AgentModeIndicator } from "../agent-mode-indicator.js";
|
|
2
|
+
import { FileMentionPicker } from "../file-mention-picker.js";
|
|
3
|
+
import { Help } from "../help.js";
|
|
4
|
+
import { ComposerMode } from "../types.js";
|
|
5
|
+
import { ModeHandler } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export function createPromptHandler(): ModeHandler {
|
|
8
|
+
return {
|
|
9
|
+
mode: ComposerMode.Prompt,
|
|
10
|
+
render: ({ prompt: _prompt, hints, availableHeight }) => {
|
|
11
|
+
return <AgentModeIndicator hints={hints} availableHeight={availableHeight} />;
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createHelpHandler(): ModeHandler {
|
|
17
|
+
return {
|
|
18
|
+
mode: ComposerMode.Help,
|
|
19
|
+
render: ({ availableHeight }) => <Help availableHeight={availableHeight} />,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createFilesHandler(onExit: () => void): ModeHandler {
|
|
24
|
+
return {
|
|
25
|
+
mode: ComposerMode.Files,
|
|
26
|
+
render: ({ prompt: _prompt, availableHeight }) => (
|
|
27
|
+
<FileMentionPicker onAccept={onExit} onCancel={onExit} availableHeight={availableHeight} />
|
|
28
|
+
),
|
|
29
|
+
shouldPreventDefault: (_input, key) => {
|
|
30
|
+
return key.upArrow || key.downArrow || key.escape || key.return || key.tab;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
import { useCommandHandler } from "../commands/handler.js";
|
|
4
|
+
import { ComposerMode } from "../types.js";
|
|
5
|
+
import { useBashHandler } from "./bash-handler.js";
|
|
6
|
+
import { createPromptHandler, createHelpHandler, createFilesHandler } from "./default-handlers.js";
|
|
7
|
+
import { ModeHandler } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export function useModeHandlers(onExitFilesMode: () => void, clearBuffer: () => void) {
|
|
10
|
+
const bashHandler = useBashHandler();
|
|
11
|
+
const commandHandler = useCommandHandler(clearBuffer);
|
|
12
|
+
|
|
13
|
+
const handlers = useMemo(() => {
|
|
14
|
+
const bashModeHandler: ModeHandler = {
|
|
15
|
+
mode: ComposerMode.Bash,
|
|
16
|
+
render: bashHandler.renderer,
|
|
17
|
+
onSubmit: bashHandler.submitCommand,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const commandModeHandler: ModeHandler = {
|
|
21
|
+
mode: ComposerMode.Command,
|
|
22
|
+
render: commandHandler.renderer,
|
|
23
|
+
onSubmit: commandHandler.executeCommand,
|
|
24
|
+
shouldPreventDefault: commandHandler.shouldPreventDefault,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return new Map<ComposerMode, ModeHandler>([
|
|
28
|
+
[ComposerMode.Prompt, createPromptHandler()],
|
|
29
|
+
[ComposerMode.Help, createHelpHandler()],
|
|
30
|
+
[ComposerMode.Bash, bashModeHandler],
|
|
31
|
+
[ComposerMode.Command, commandModeHandler],
|
|
32
|
+
[ComposerMode.Files, createFilesHandler(onExitFilesMode)],
|
|
33
|
+
]);
|
|
34
|
+
}, [bashHandler, commandHandler, onExitFilesMode]);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
handlers,
|
|
38
|
+
getActivity: bashHandler.getActivity,
|
|
39
|
+
bashNavigateHistory: bashHandler.navigateHistory,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Key } from "@codellm/jar";
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import type { InterruptHints } from "../../../hooks/use-interrupt-manager.js";
|
|
6
|
+
|
|
7
|
+
import { ComposerMode } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export interface ModeHandler {
|
|
10
|
+
mode: ComposerMode;
|
|
11
|
+
render: (props: ModeRenderProps) => ReactNode;
|
|
12
|
+
onSubmit?: (value: string) => void;
|
|
13
|
+
shouldPreventDefault?: (input: string, key: Key) => boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ModeRenderProps {
|
|
17
|
+
prompt: string;
|
|
18
|
+
onExit: () => void;
|
|
19
|
+
hints?: InterruptHints;
|
|
20
|
+
availableHeight: number;
|
|
21
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from "node:child_process";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
|
|
4
|
+
export type StreamChunk = { type: "stdout" | "stderr"; data: string };
|
|
5
|
+
|
|
6
|
+
// Constants from claude.js patterns
|
|
7
|
+
const GRACEFUL_SHUTDOWN_DELAY_MS = 5000;
|
|
8
|
+
|
|
9
|
+
// Signals to forward to child processes (claude.js lines 3290-3294)
|
|
10
|
+
const FORWARDED_SIGNALS: NodeJS.Signals[] = ["SIGUSR1", "SIGUSR2", "SIGTERM", "SIGINT", "SIGHUP"];
|
|
11
|
+
|
|
12
|
+
function getShellCommand(): { command: string; args: string[]; env: Record<string, string> } {
|
|
13
|
+
const platform = os.platform();
|
|
14
|
+
|
|
15
|
+
// Filter out undefined values from process.env
|
|
16
|
+
const baseEnv: Record<string, string> = {};
|
|
17
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
18
|
+
if (value !== undefined) {
|
|
19
|
+
baseEnv[key] = value;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (platform === "win32") {
|
|
24
|
+
// Windows: Use PowerShell or cmd
|
|
25
|
+
return {
|
|
26
|
+
command: process.env.COMSPEC || "cmd.exe",
|
|
27
|
+
args: [],
|
|
28
|
+
env: baseEnv,
|
|
29
|
+
};
|
|
30
|
+
} else {
|
|
31
|
+
// Unix-like systems (macOS, Linux)
|
|
32
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
33
|
+
return {
|
|
34
|
+
command: shell,
|
|
35
|
+
args: [],
|
|
36
|
+
env: {
|
|
37
|
+
...baseEnv,
|
|
38
|
+
BASH_SILENCE_DEPRECATION_WARNING: "1", // Silence macOS zsh warning
|
|
39
|
+
PS1: "", // Disable prompt for bash
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class PersistentShell {
|
|
46
|
+
private shell: ChildProcess | null = null;
|
|
47
|
+
private readonly queue: StreamChunk[] = [];
|
|
48
|
+
private notify: (() => void) | null = null;
|
|
49
|
+
private isProcessing = false;
|
|
50
|
+
private destroyed = false;
|
|
51
|
+
private readonly isWindows: boolean;
|
|
52
|
+
private readonly signalHandlers = new Map<NodeJS.Signals, (...args: any[]) => void>();
|
|
53
|
+
|
|
54
|
+
constructor() {
|
|
55
|
+
this.isWindows = os.platform() === "win32";
|
|
56
|
+
this.startShell();
|
|
57
|
+
this.setupSignalForwarding();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Setup signal forwarding to shell process (claude.js pattern).
|
|
62
|
+
* Forwards SIGUSR1, SIGUSR2, SIGTERM, SIGINT, SIGHUP to child.
|
|
63
|
+
*/
|
|
64
|
+
private setupSignalForwarding(): void {
|
|
65
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
66
|
+
const handler = () => {
|
|
67
|
+
if (this.shell && !this.shell.killed && this.shell.exitCode === null) {
|
|
68
|
+
try {
|
|
69
|
+
this.shell.kill(signal);
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore errors when forwarding signals
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
this.signalHandlers.set(signal, handler);
|
|
76
|
+
process.on(signal, handler);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Remove signal handlers during cleanup.
|
|
82
|
+
*/
|
|
83
|
+
private removeSignalHandlers(): void {
|
|
84
|
+
for (const [signal, handler] of this.signalHandlers) {
|
|
85
|
+
process.removeListener(signal, handler);
|
|
86
|
+
}
|
|
87
|
+
this.signalHandlers.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private startShell(): void {
|
|
91
|
+
if (this.shell || this.destroyed) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { command, args, env } = getShellCommand();
|
|
96
|
+
|
|
97
|
+
// Start a persistent shell session
|
|
98
|
+
this.shell = spawn(command, args, {
|
|
99
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
100
|
+
env,
|
|
101
|
+
cwd: process.cwd(),
|
|
102
|
+
shell: false, // Don't spawn through another shell
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (this.shell.stdin) {
|
|
106
|
+
// Suppress EPIPE errors — these occur when the shell dies and we try to write
|
|
107
|
+
this.shell.stdin.on("error", (err: NodeJS.ErrnoException) => {
|
|
108
|
+
if (err.code !== "EPIPE") {
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (this.shell.stdout) {
|
|
115
|
+
this.shell.stdout.on("data", (chunk: Buffer) => {
|
|
116
|
+
const data = chunk.toString("utf8");
|
|
117
|
+
this.queue.push({ type: "stdout", data });
|
|
118
|
+
if (this.notify) {
|
|
119
|
+
const n = this.notify;
|
|
120
|
+
this.notify = null;
|
|
121
|
+
n();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (this.shell.stderr) {
|
|
127
|
+
this.shell.stderr.on("data", (chunk: Buffer) => {
|
|
128
|
+
const data = chunk.toString("utf8");
|
|
129
|
+
// Filter out common shell warnings/deprecation messages
|
|
130
|
+
const shouldIgnore =
|
|
131
|
+
data.includes("no job control in this shell") ||
|
|
132
|
+
data.includes("The default interactive shell is now zsh") ||
|
|
133
|
+
data.includes("To update your account to use zsh") ||
|
|
134
|
+
data.includes("please run `chsh -s /bin/zsh`") ||
|
|
135
|
+
data.includes("For more details, please visit");
|
|
136
|
+
|
|
137
|
+
if (!shouldIgnore) {
|
|
138
|
+
this.queue.push({ type: "stderr", data });
|
|
139
|
+
if (this.notify) {
|
|
140
|
+
const n = this.notify;
|
|
141
|
+
this.notify = null;
|
|
142
|
+
n();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.shell.on("exit", () => {
|
|
149
|
+
this.shell = null;
|
|
150
|
+
// Restart shell if it exits
|
|
151
|
+
this.startShell();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public async executeCommand(command: string): Promise<{
|
|
156
|
+
stream: AsyncGenerator<StreamChunk, void, void>;
|
|
157
|
+
exit: Promise<number>;
|
|
158
|
+
}> {
|
|
159
|
+
if (!this.shell || !this.shell.stdin) {
|
|
160
|
+
throw new Error("Shell not initialized");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Clear any pending output from previous commands
|
|
164
|
+
this.queue.length = 0;
|
|
165
|
+
|
|
166
|
+
const uniqueMarker = `__CMD_END_${Date.now()}_${Math.random().toString(36).substring(7)}__`;
|
|
167
|
+
|
|
168
|
+
// Platform-specific command formatting
|
|
169
|
+
let commandWithMarker: string;
|
|
170
|
+
if (this.isWindows) {
|
|
171
|
+
// Windows cmd.exe or PowerShell
|
|
172
|
+
commandWithMarker = `${command}\necho ${uniqueMarker}\n`;
|
|
173
|
+
} else {
|
|
174
|
+
// Unix-like systems (bash, zsh, etc.)
|
|
175
|
+
commandWithMarker = `${command}\necho "${uniqueMarker}"\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.shell.stdin.write(commandWithMarker);
|
|
179
|
+
this.isProcessing = true;
|
|
180
|
+
|
|
181
|
+
const stream = this.createStream(uniqueMarker);
|
|
182
|
+
const exit = this.createExitPromise(uniqueMarker);
|
|
183
|
+
|
|
184
|
+
return { stream, exit };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async *createStream(marker: string): AsyncGenerator<StreamChunk, void, void> {
|
|
188
|
+
while (true) {
|
|
189
|
+
while (this.queue.length > 0) {
|
|
190
|
+
const item = this.queue.shift();
|
|
191
|
+
if (!item) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check if this chunk contains the marker
|
|
196
|
+
if (item.data.includes(marker)) {
|
|
197
|
+
// Remove the marker from the output
|
|
198
|
+
const cleanedData = item.data.replace(marker, "").replace(/\n$/, "");
|
|
199
|
+
if (cleanedData) {
|
|
200
|
+
yield { type: item.type, data: cleanedData };
|
|
201
|
+
}
|
|
202
|
+
this.isProcessing = false;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
yield item;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!this.isProcessing) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await new Promise<void>((resolve) => {
|
|
214
|
+
this.notify = resolve;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async createExitPromise(_marker: string): Promise<number> {
|
|
220
|
+
// Wait for the marker to appear in the output
|
|
221
|
+
while (this.isProcessing) {
|
|
222
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
|
223
|
+
}
|
|
224
|
+
return 0; // Assume success for now
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Gracefully kill the shell process (claude.js pattern).
|
|
229
|
+
* Sends SIGTERM first, then SIGKILL after timeout.
|
|
230
|
+
*/
|
|
231
|
+
private killProcessGracefully(process: ChildProcess): void {
|
|
232
|
+
try {
|
|
233
|
+
process.kill("SIGTERM");
|
|
234
|
+
|
|
235
|
+
const killTimeout = setTimeout(() => {
|
|
236
|
+
if (process && !process.killed) {
|
|
237
|
+
process.kill("SIGKILL");
|
|
238
|
+
}
|
|
239
|
+
}, GRACEFUL_SHUTDOWN_DELAY_MS);
|
|
240
|
+
|
|
241
|
+
// Unref so timer doesn't keep process alive
|
|
242
|
+
if (killTimeout.unref) {
|
|
243
|
+
killTimeout.unref();
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Ignore errors when killing process
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
public destroy(): void {
|
|
251
|
+
this.destroyed = true;
|
|
252
|
+
// Remove signal handlers first
|
|
253
|
+
this.removeSignalHandlers();
|
|
254
|
+
|
|
255
|
+
if (this.shell) {
|
|
256
|
+
// Suppress EPIPE errors from stdin after the shell is killed
|
|
257
|
+
this.shell.stdin?.on("error", (err: NodeJS.ErrnoException) => {
|
|
258
|
+
if (err.code !== "EPIPE") {
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
this.killProcessGracefully(this.shell);
|
|
263
|
+
this.shell = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Singleton instance
|
|
269
|
+
let shellInstance: PersistentShell | null = null;
|
|
270
|
+
|
|
271
|
+
export function getPersistentShell(): PersistentShell {
|
|
272
|
+
if (!shellInstance) {
|
|
273
|
+
shellInstance = new PersistentShell();
|
|
274
|
+
}
|
|
275
|
+
return shellInstance;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function destroyPersistentShell(): void {
|
|
279
|
+
if (shellInstance) {
|
|
280
|
+
shellInstance.destroy();
|
|
281
|
+
shellInstance = null;
|
|
282
|
+
}
|
|
283
|
+
}
|