@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.
Files changed (200) hide show
  1. package/.oxlintrc.json +8 -0
  2. package/dist/index.mjs +12603 -0
  3. package/package.json +7 -39
  4. package/resources/abacus.ico +0 -0
  5. package/resources/entitlements.plist +9 -0
  6. package/src/__e2e__/README.md +196 -0
  7. package/src/__e2e__/agent-interactions.e2e.test.tsx +61 -0
  8. package/src/__e2e__/cli-commands.e2e.test.tsx +77 -0
  9. package/src/__e2e__/conversation-throttle.e2e.test.ts +453 -0
  10. package/src/__e2e__/conversation.e2e.test.tsx +56 -0
  11. package/src/__e2e__/diff-preview.e2e.test.tsx +3399 -0
  12. package/src/__e2e__/file-creation.e2e.test.tsx +149 -0
  13. package/src/__e2e__/helpers/test-helpers.ts +450 -0
  14. package/src/__e2e__/keyboard-navigation.e2e.test.tsx +34 -0
  15. package/src/__e2e__/llm-models.e2e.test.ts +402 -0
  16. package/src/__e2e__/mcp/mcp-callback-flow.e2e.test.tsx +71 -0
  17. package/src/__e2e__/mcp/mcp-full-app-ui.e2e.test.tsx +167 -0
  18. package/src/__e2e__/mcp/mcp-ui-rendering.e2e.test.tsx +185 -0
  19. package/src/__e2e__/repl.e2e.test.tsx +78 -0
  20. package/src/__e2e__/shell-compatibility.e2e.test.tsx +76 -0
  21. package/src/__e2e__/theme-mcp.e2e.test.tsx +98 -0
  22. package/src/__e2e__/tool-permissions.e2e.test.tsx +66 -0
  23. package/src/args.ts +22 -0
  24. package/src/components/__tests__/react-compiler.test.tsx +78 -0
  25. package/src/components/__tests__/status-indicator.test.tsx +403 -0
  26. package/src/components/composer/__tests__/bash-runner.test.tsx +263 -0
  27. package/src/components/composer/agent-mode-indicator.tsx +63 -0
  28. package/src/components/composer/bash-runner.tsx +54 -0
  29. package/src/components/composer/commands/default-commands.tsx +615 -0
  30. package/src/components/composer/commands/handler.tsx +59 -0
  31. package/src/components/composer/commands/picker.tsx +273 -0
  32. package/src/components/composer/commands/registry.ts +233 -0
  33. package/src/components/composer/commands/types.ts +33 -0
  34. package/src/components/composer/context.tsx +88 -0
  35. package/src/components/composer/file-mention-picker.tsx +83 -0
  36. package/src/components/composer/help.tsx +44 -0
  37. package/src/components/composer/index.tsx +1006 -0
  38. package/src/components/composer/mentions.ts +57 -0
  39. package/src/components/composer/message-queue.tsx +70 -0
  40. package/src/components/composer/mode-panel.tsx +35 -0
  41. package/src/components/composer/modes/__tests__/bash-handler.test.tsx +755 -0
  42. package/src/components/composer/modes/__tests__/bash-renderer.test.tsx +1108 -0
  43. package/src/components/composer/modes/bash-handler.tsx +132 -0
  44. package/src/components/composer/modes/bash-renderer.tsx +175 -0
  45. package/src/components/composer/modes/default-handlers.tsx +33 -0
  46. package/src/components/composer/modes/index.ts +41 -0
  47. package/src/components/composer/modes/types.ts +21 -0
  48. package/src/components/composer/persistent-shell.ts +283 -0
  49. package/src/components/composer/process.ts +65 -0
  50. package/src/components/composer/types.ts +9 -0
  51. package/src/components/composer/use-mention-search.ts +68 -0
  52. package/src/components/error-boundry.tsx +60 -0
  53. package/src/components/exit-message.tsx +29 -0
  54. package/src/components/expanded-view.tsx +74 -0
  55. package/src/components/file-completion.tsx +127 -0
  56. package/src/components/header.tsx +47 -0
  57. package/src/components/logo.tsx +37 -0
  58. package/src/components/segments.tsx +356 -0
  59. package/src/components/status-indicator.tsx +306 -0
  60. package/src/components/tool-group-summary.tsx +263 -0
  61. package/src/components/tool-permissions/ask-user-question-permission-ui.tsx +312 -0
  62. package/src/components/tool-permissions/diff-preview.tsx +355 -0
  63. package/src/components/tool-permissions/index.ts +5 -0
  64. package/src/components/tool-permissions/permission-options.tsx +375 -0
  65. package/src/components/tool-permissions/permission-preview-header.tsx +57 -0
  66. package/src/components/tool-permissions/tool-permission-ui.tsx +398 -0
  67. package/src/components/tools/agent/ask-user-question.tsx +101 -0
  68. package/src/components/tools/agent/enter-plan-mode.tsx +49 -0
  69. package/src/components/tools/agent/exit-plan-mode.tsx +75 -0
  70. package/src/components/tools/agent/handoff-to-main.tsx +27 -0
  71. package/src/components/tools/agent/subagent.tsx +37 -0
  72. package/src/components/tools/agent/todo-write.tsx +104 -0
  73. package/src/components/tools/browser/close-tab.tsx +58 -0
  74. package/src/components/tools/browser/computer.tsx +70 -0
  75. package/src/components/tools/browser/get-interactive-elements.tsx +54 -0
  76. package/src/components/tools/browser/get-tab-content.tsx +51 -0
  77. package/src/components/tools/browser/navigate-to.tsx +59 -0
  78. package/src/components/tools/browser/new-tab.tsx +60 -0
  79. package/src/components/tools/browser/perform-action.tsx +63 -0
  80. package/src/components/tools/browser/refresh-tab.tsx +43 -0
  81. package/src/components/tools/browser/switch-tab.tsx +58 -0
  82. package/src/components/tools/filesystem/delete-file.tsx +104 -0
  83. package/src/components/tools/filesystem/edit.tsx +220 -0
  84. package/src/components/tools/filesystem/list-dir.tsx +78 -0
  85. package/src/components/tools/filesystem/read-file.tsx +180 -0
  86. package/src/components/tools/filesystem/upload-image.tsx +76 -0
  87. package/src/components/tools/ide/ide-diagnostics.tsx +62 -0
  88. package/src/components/tools/index.ts +91 -0
  89. package/src/components/tools/mcp/mcp-tool.tsx +158 -0
  90. package/src/components/tools/search/fetch-url.tsx +73 -0
  91. package/src/components/tools/search/file-search.tsx +78 -0
  92. package/src/components/tools/search/grep.tsx +90 -0
  93. package/src/components/tools/search/semantic-search.tsx +66 -0
  94. package/src/components/tools/search/web-search.tsx +71 -0
  95. package/src/components/tools/shared/index.tsx +48 -0
  96. package/src/components/tools/shared/zod-coercion.ts +35 -0
  97. package/src/components/tools/terminal/bash-tool-output.tsx +174 -0
  98. package/src/components/tools/terminal/get-terminal-output.tsx +85 -0
  99. package/src/components/tools/terminal/run-in-terminal.tsx +106 -0
  100. package/src/components/tools/types.ts +16 -0
  101. package/src/components/tools.tsx +66 -0
  102. package/src/components/ui/__tests__/divider.test.tsx +61 -0
  103. package/src/components/ui/__tests__/gradient.test.tsx +125 -0
  104. package/src/components/ui/__tests__/input.test.tsx +166 -0
  105. package/src/components/ui/__tests__/select.test.tsx +273 -0
  106. package/src/components/ui/__tests__/shimmer.test.tsx +99 -0
  107. package/src/components/ui/blinking-indicator.tsx +25 -0
  108. package/src/components/ui/divider.tsx +162 -0
  109. package/src/components/ui/gradient.tsx +56 -0
  110. package/src/components/ui/input.tsx +228 -0
  111. package/src/components/ui/select.tsx +151 -0
  112. package/src/components/ui/shimmer.tsx +84 -0
  113. package/src/context/agent-mode.tsx +95 -0
  114. package/src/context/extension-file.tsx +136 -0
  115. package/src/context/network-activity.tsx +45 -0
  116. package/src/context/notification.tsx +62 -0
  117. package/src/context/shell-size.tsx +49 -0
  118. package/src/context/shell-title.tsx +38 -0
  119. package/src/entrypoints/print-mode.ts +312 -0
  120. package/src/entrypoints/repl.tsx +401 -0
  121. package/src/hooks/use-agent.ts +15 -0
  122. package/src/hooks/use-api-client.ts +1 -0
  123. package/src/hooks/use-available-height.ts +8 -0
  124. package/src/hooks/use-cleanup.ts +29 -0
  125. package/src/hooks/use-interrupt-manager.ts +242 -0
  126. package/src/hooks/use-models.ts +22 -0
  127. package/src/index.ts +217 -0
  128. package/src/lib/__tests__/ansi.test.ts +255 -0
  129. package/src/lib/__tests__/cli.test.ts +122 -0
  130. package/src/lib/__tests__/commands.test.ts +325 -0
  131. package/src/lib/__tests__/constants.test.ts +15 -0
  132. package/src/lib/__tests__/focusables.test.ts +25 -0
  133. package/src/lib/__tests__/fs.test.ts +231 -0
  134. package/src/lib/__tests__/markdown.test.tsx +348 -0
  135. package/src/lib/__tests__/mcpCommandHandler.test.ts +173 -0
  136. package/src/lib/__tests__/mcpManagement.test.ts +38 -0
  137. package/src/lib/__tests__/path-paste.test.ts +144 -0
  138. package/src/lib/__tests__/path.test.ts +300 -0
  139. package/src/lib/__tests__/queries.test.ts +39 -0
  140. package/src/lib/__tests__/standaloneMcpService.test.ts +71 -0
  141. package/src/lib/__tests__/text-buffer.test.ts +328 -0
  142. package/src/lib/__tests__/text-utils.test.ts +32 -0
  143. package/src/lib/__tests__/timing.test.ts +78 -0
  144. package/src/lib/__tests__/utils.test.ts +238 -0
  145. package/src/lib/__tests__/vim-buffer-actions.test.ts +154 -0
  146. package/src/lib/ansi.ts +150 -0
  147. package/src/lib/cli-push-server.ts +112 -0
  148. package/src/lib/cli.ts +44 -0
  149. package/src/lib/clipboard.ts +226 -0
  150. package/src/lib/command-utils.ts +93 -0
  151. package/src/lib/commands.ts +270 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/extension-connection.ts +181 -0
  154. package/src/lib/focusables.ts +7 -0
  155. package/src/lib/fs.ts +533 -0
  156. package/src/lib/markdown/code-block.tsx +63 -0
  157. package/src/lib/markdown/index.ts +4 -0
  158. package/src/lib/markdown/link.tsx +19 -0
  159. package/src/lib/markdown/markdown.tsx +372 -0
  160. package/src/lib/markdown/types.ts +15 -0
  161. package/src/lib/mcpCommandHandler.ts +121 -0
  162. package/src/lib/mcpManagement.ts +44 -0
  163. package/src/lib/path-paste.ts +185 -0
  164. package/src/lib/path.ts +179 -0
  165. package/src/lib/queries.ts +15 -0
  166. package/src/lib/standaloneMcpService.ts +688 -0
  167. package/src/lib/status-utils.ts +237 -0
  168. package/src/lib/test-utils.tsx +72 -0
  169. package/src/lib/text-buffer.ts +2415 -0
  170. package/src/lib/text-utils.ts +272 -0
  171. package/src/lib/timing.ts +63 -0
  172. package/src/lib/types.ts +295 -0
  173. package/src/lib/utils.ts +182 -0
  174. package/src/lib/vim-buffer-actions.ts +732 -0
  175. package/src/providers/agent.tsx +1075 -0
  176. package/src/providers/api-client.tsx +43 -0
  177. package/src/services/logger.ts +85 -0
  178. package/src/terminal/detection.ts +187 -0
  179. package/src/terminal/exit.ts +279 -0
  180. package/src/terminal/notification.ts +83 -0
  181. package/src/terminal/progress.ts +201 -0
  182. package/src/terminal/setup.ts +797 -0
  183. package/src/terminal/suspend.ts +58 -0
  184. package/src/terminal/types.ts +51 -0
  185. package/src/theme/context.tsx +57 -0
  186. package/src/theme/index.ts +4 -0
  187. package/src/theme/themed.tsx +35 -0
  188. package/src/theme/themes.json +546 -0
  189. package/src/theme/types.ts +110 -0
  190. package/src/tools/types.ts +59 -0
  191. package/src/tools/utils/__tests__/zod-coercion.test.ts +33 -0
  192. package/src/tools/utils/tool-ui-components.tsx +631 -0
  193. package/src/tools/utils/zod-coercion.ts +35 -0
  194. package/tsconfig.json +11 -0
  195. package/tsconfig.node.json +29 -0
  196. package/tsconfig.test.json +27 -0
  197. package/tsdown.config.ts +17 -0
  198. package/vitest.config.ts +76 -0
  199. package/README.md +0 -28
  200. 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
+ }