Skip to content

Commit b019da7

Browse files
committed
feat: bash mode
1 parent 4a4d41a commit b019da7

File tree

11 files changed

+364
-65
lines changed

11 files changed

+364
-65
lines changed

apps/array/src/main/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
408408
dockBadge: {
409409
show: (): Promise<void> => ipcRenderer.invoke("dock-badge:show"),
410410
},
411+
shellExecute: (
412+
cwd: string,
413+
command: string,
414+
): Promise<{ stdout: string; stderr: string; exitCode: number }> =>
415+
ipcRenderer.invoke("shell:execute", cwd, command),
411416
});

apps/array/src/main/services/shell.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { exec } from "node:child_process";
12
import { createIpcHandler } from "../lib/ipcHandler";
23
import { shellManager } from "../lib/shellManager";
34
import { foldersStore } from "../utils/store";
@@ -56,4 +57,23 @@ export function registerShellIpc(): void {
5657
handle("shell:get-process", (_event, sessionId: string) => {
5758
return shellManager.getProcess(sessionId);
5859
});
60+
61+
handle(
62+
"shell:execute",
63+
async (
64+
_event,
65+
cwd: string,
66+
command: string,
67+
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
68+
return new Promise((resolve) => {
69+
exec(command, { cwd, timeout: 60000 }, (error, stdout, stderr) => {
70+
resolve({
71+
stdout: stdout || "",
72+
stderr: stderr || "",
73+
exitCode: error?.code ?? 0,
74+
});
75+
});
76+
});
77+
},
78+
);
5979
}

apps/array/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import {
1010
isJsonRpcNotification,
1111
isJsonRpcRequest,
1212
isJsonRpcResponse,
13+
type UserShellExecuteParams,
1314
} from "@shared/types/session-events";
14-
import { memo, useEffect, useMemo, useRef } from "react";
15+
import { memo, useLayoutEffect, useMemo, useRef } from "react";
1516
import { GitActionMessage, parseGitActionMessage } from "./GitActionMessage";
1617
import { GitActionResult } from "./GitActionResult";
1718
import { SessionFooter } from "./SessionFooter";
@@ -20,8 +21,13 @@ import {
2021
SessionUpdateView,
2122
} from "./session-update/SessionUpdateView";
2223
import { UserMessage } from "./session-update/UserMessage";
24+
import {
25+
type UserShellExecute,
26+
UserShellExecuteView,
27+
} from "./session-update/UserShellExecuteView";
2328

2429
interface Turn {
30+
type: "turn";
2531
id: string;
2632
promptId: number;
2733
userContent: string;
@@ -32,6 +38,8 @@ interface Turn {
3238
toolCalls: Map<string, ToolCall>;
3339
}
3440

41+
type ConversationItem = Turn | UserShellExecute;
42+
3543
interface ConversationViewProps {
3644
events: AcpMessage[];
3745
isPromptPending: boolean;
@@ -46,31 +54,39 @@ export function ConversationView({
4654
isCloud = false,
4755
}: ConversationViewProps) {
4856
const scrollRef = useRef<HTMLDivElement>(null);
49-
const turns = useMemo(() => buildTurns(events), [events]);
50-
const lastTurn = turns[turns.length - 1];
57+
const items = useMemo(() => buildConversationItems(events), [events]);
58+
const lastTurn = items.filter((i): i is Turn => i.type === "turn").pop();
5159
const lastTurnComplete = lastTurn?.isComplete ?? true;
5260

53-
useEffect(() => {
61+
// Scroll to bottom on initial mount
62+
const hasScrolledRef = useRef(false);
63+
useLayoutEffect(() => {
64+
if (hasScrolledRef.current) return;
5465
const el = scrollRef.current;
55-
if (el) {
66+
if (el && items.length > 0) {
5667
el.scrollTop = el.scrollHeight;
68+
hasScrolledRef.current = true;
5769
}
58-
}, []);
70+
}, [items]);
5971

6072
return (
6173
<div
6274
ref={scrollRef}
6375
className="scrollbar-hide flex-1 overflow-auto bg-white p-2 pb-16 dark:bg-gray-1"
6476
>
6577
<div className="flex flex-col gap-3">
66-
{turns.map((turn) => (
67-
<TurnView
68-
key={turn.id}
69-
turn={turn}
70-
repoPath={repoPath}
71-
isCloud={isCloud}
72-
/>
73-
))}
78+
{items.map((item) =>
79+
item.type === "turn" ? (
80+
<TurnView
81+
key={item.id}
82+
turn={item}
83+
repoPath={repoPath}
84+
isCloud={isCloud}
85+
/>
86+
) : (
87+
<UserShellExecuteView key={item.id} item={item} />
88+
),
89+
)}
7490
</div>
7591
<SessionFooter
7692
isPromptPending={isPromptPending || !lastTurnComplete}
@@ -138,20 +154,38 @@ const TurnView = memo(function TurnView({
138154

139155
// --- Event Processing ---
140156

141-
function buildTurns(events: AcpMessage[]): Turn[] {
142-
const turns: Turn[] = [];
143-
let current: Turn | null = null;
157+
function buildConversationItems(events: AcpMessage[]): ConversationItem[] {
158+
const items: ConversationItem[] = [];
159+
let currentTurn: Turn | null = null;
144160
// Map prompt request IDs to their turns for matching responses
145161
const pendingPrompts = new Map<number, Turn>();
162+
let shellExecuteCounter = 0;
146163

147164
for (const event of events) {
148165
const msg = event.message;
149166

167+
// User shell execute notification - standalone item
168+
if (
169+
isJsonRpcNotification(msg) &&
170+
msg.method === "_array/user_shell_execute"
171+
) {
172+
const params = msg.params as UserShellExecuteParams;
173+
items.push({
174+
type: "user_shell_execute",
175+
id: `shell-exec-${shellExecuteCounter++}`,
176+
command: params.command,
177+
cwd: params.cwd,
178+
result: params.result,
179+
});
180+
continue;
181+
}
182+
150183
// session/prompt request - starts a new turn
151184
if (isJsonRpcRequest(msg) && msg.method === "session/prompt") {
152185
const userContent = extractUserContent(msg.params);
153186

154-
current = {
187+
currentTurn = {
188+
type: "turn",
155189
id: `turn-${msg.id}`,
156190
promptId: msg.id,
157191
userContent,
@@ -160,10 +194,10 @@ function buildTurns(events: AcpMessage[]): Turn[] {
160194
durationMs: 0,
161195
toolCalls: new Map(),
162196
};
163-
current.durationMs = -event.ts; // Will add end timestamp later
197+
currentTurn.durationMs = -event.ts; // Will add end timestamp later
164198

165-
pendingPrompts.set(msg.id, current);
166-
turns.push(current);
199+
pendingPrompts.set(msg.id, currentTurn);
200+
items.push(currentTurn);
167201
continue;
168202
}
169203

@@ -182,24 +216,24 @@ function buildTurns(events: AcpMessage[]): Turn[] {
182216
if (
183217
isJsonRpcNotification(msg) &&
184218
msg.method === "session/update" &&
185-
current
219+
currentTurn
186220
) {
187221
const update = (msg.params as SessionNotification)?.update;
188222
if (!update) continue;
189223

190-
processSessionUpdate(current, update);
224+
processSessionUpdate(currentTurn, update);
191225
continue;
192226
}
193227

194228
// PostHog console messages
195229
if (
196230
isJsonRpcNotification(msg) &&
197231
msg.method === "_posthog/console" &&
198-
current
232+
currentTurn
199233
) {
200234
const params = msg.params as { level?: string; message?: string };
201235
if (params?.message) {
202-
current.items.push({
236+
currentTurn.items.push({
203237
sessionUpdate: "console",
204238
level: params.level ?? "info",
205239
message: params.message,
@@ -209,16 +243,25 @@ function buildTurns(events: AcpMessage[]): Turn[] {
209243
}
210244
}
211245

212-
return turns;
246+
return items;
247+
}
248+
249+
interface TextBlockWithMeta {
250+
type: "text";
251+
text: string;
252+
_meta?: { ui?: { hidden?: boolean } };
213253
}
214254

215255
function extractUserContent(params: unknown): string {
216256
const p = params as { prompt?: ContentBlock[] };
217257
if (!p?.prompt?.length) return "";
218258

219-
const textBlock = p.prompt.find(
220-
(b): b is { type: "text"; text: string } => b.type === "text",
221-
);
259+
// Find first visible text block (skip hidden context blocks)
260+
const textBlock = p.prompt.find((b): b is TextBlockWithMeta => {
261+
if (b.type !== "text") return false;
262+
const meta = (b as TextBlockWithMeta)._meta;
263+
return !meta?.ui?.hidden;
264+
});
222265
return textBlock?.text ?? "";
223266
}
224267

apps/array/src/renderer/features/sessions/components/MessageEditor.tsx

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import "@features/task-detail/components/TaskInput.css";
22
import { ArrowUp, Paperclip, Stop } from "@phosphor-icons/react";
3-
import { Box, Flex, IconButton, Tooltip } from "@radix-ui/themes";
3+
import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
44
import { logger } from "@renderer/lib/logger";
5+
import { toast } from "@renderer/utils/toast";
56
import type { MentionItem } from "@shared/types";
67
import { Extension, type JSONContent } from "@tiptap/core";
78
import { Mention } from "@tiptap/extension-mention";
@@ -184,7 +185,10 @@ interface MessageEditorProps {
184185
repoPath?: string | null;
185186
disabled?: boolean;
186187
isLoading?: boolean;
188+
isCloud?: boolean;
187189
onSubmit?: (text: string) => void;
190+
onBashCommand?: (command: string) => void;
191+
onBashModeChange?: (isBashMode: boolean) => void;
188192
onCancel?: () => void;
189193
onAttachFiles?: (files: File[]) => void;
190194
autoFocus?: boolean;
@@ -202,7 +206,10 @@ export const MessageEditor = forwardRef<
202206
repoPath,
203207
disabled = false,
204208
isLoading = false,
209+
isCloud = false,
205210
onSubmit,
211+
onBashCommand,
212+
onBashModeChange,
206213
onCancel,
207214
onAttachFiles,
208215
autoFocus = false,
@@ -240,7 +247,6 @@ export const MessageEditor = forwardRef<
240247
};
241248
const [mentionItems, setMentionItems] = useState<MentionItem[]>([]);
242249
const repoPathRef = useRef(repoPath);
243-
const onSubmitRef = useRef(onSubmit);
244250
const componentRef = useRef<ReactRenderer<MentionListRef> | null>(null);
245251
const commandRef = useRef<
246252
((item: { id: string; label: string; type?: string }) => void) | null
@@ -259,20 +265,45 @@ export const MessageEditor = forwardRef<
259265
repoPathRef.current = repoPath;
260266
}, [repoPath]);
261267

262-
useEffect(() => {
263-
onSubmitRef.current = onSubmit;
264-
}, [onSubmit]);
268+
const [isEmpty, setIsEmpty] = useState(true);
269+
const [isBashMode, setIsBashMode] = useState(false);
270+
271+
const handleEditorUpdate = (editor: Editor) => {
272+
setIsEmpty(editor.isEmpty);
273+
const newBashMode = editor.getText().trimStart().startsWith("!");
274+
if (newBashMode !== isBashMode) {
275+
setIsBashMode(newBashMode);
276+
onBashModeChange?.(newBashMode);
277+
}
278+
setDraft(sessionId, editor.isEmpty ? null : editor.getJSON());
279+
};
280+
281+
const handleBashSubmit = (text: string) => {
282+
if (isCloud) {
283+
toast.error("Bash mode is not supported in cloud sessions");
284+
return false;
285+
}
286+
const command = text.slice(1).trim();
287+
if (command) {
288+
onBashCommand?.(command);
289+
}
290+
return true;
291+
};
265292

266293
const handleSubmit = () => {
267294
if (!editor || editor.isEmpty) return;
268-
const text = editor.getText();
269-
onSubmitRef.current?.(text);
295+
const text = editor.getText().trim();
296+
297+
if (text.startsWith("!")) {
298+
if (!handleBashSubmit(text)) return;
299+
} else {
300+
onSubmit?.(text);
301+
}
302+
270303
editor.commands.clearContent();
271304
setDraft(sessionId, null);
272305
};
273306

274-
const [isEmpty, setIsEmpty] = useState(true);
275-
276307
const editor = useEditor({
277308
extensions: [
278309
StarterKit,
@@ -406,10 +437,7 @@ export const MessageEditor = forwardRef<
406437
},
407438
},
408439
autofocus: autoFocus,
409-
onUpdate: ({ editor }) => {
410-
setIsEmpty(editor.isEmpty);
411-
setDraft(sessionId, editor.isEmpty ? null : editor.getJSON());
412-
},
440+
onUpdate: ({ editor }) => handleEditorUpdate(editor),
413441
});
414442

415443
useEffect(() => {
@@ -479,6 +507,11 @@ export const MessageEditor = forwardRef<
479507
</IconButton>
480508
</Tooltip>
481509
<ModelSelector taskId={taskId} disabled={disabled} />
510+
{isBashMode && (
511+
<Text size="1" className="font-mono text-accent-11">
512+
bash mode
513+
</Text>
514+
)}
482515
</Flex>
483516
<Flex gap="4" align="center">
484517
{isLoading && onCancel ? (

0 commit comments

Comments
 (0)