Skip to content

Commit 12e0124

Browse files
authored
feat: add setup script panel (#1301)
the setup script provided in the toml should be ran and displayed somewhere, so we spawn an additional terminal tab with some special UI we can later reuse this for displaying environment actions as well
1 parent 2b8b6c9 commit 12e0124

18 files changed

Lines changed: 504 additions & 155 deletions

File tree

apps/code/src/main/services/agent/service.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ function createMockDependencies() {
140140
register: vi.fn(),
141141
unregister: vi.fn(),
142142
killByTaskId: vi.fn(),
143+
getByTaskId: vi.fn(() => []),
144+
kill: vi.fn(),
143145
},
144146
sleepService: {
145147
acquire: vi.fn(),

apps/code/src/main/services/agent/service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,8 +623,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
623623
return existing;
624624
}
625625

626-
// Kill any lingering processes from previous runs of this task
627-
this.processTracking.killByTaskId(taskId);
626+
for (const proc of this.processTracking.getByTaskId(taskId)) {
627+
if (proc.category === "agent" || proc.category === "child") {
628+
this.processTracking.kill(proc.pid);
629+
}
630+
}
628631

629632
// Clean up any prior session for this taskRunId before creating a new one
630633
await this.cleanupSession(taskRunId);

apps/code/src/main/services/shell/schemas.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export const createInput = sessionIdInput.extend({
99
taskId: z.string().optional(),
1010
});
1111

12+
export const createCommandInput = sessionIdInput.extend({
13+
command: z.string().min(1),
14+
cwd: z.string(),
15+
taskId: z.string().optional(),
16+
});
17+
1218
export const writeInput = sessionIdInput.extend({
1319
data: z.string(),
1420
});
@@ -31,6 +37,7 @@ export const executeOutput = z.object({
3137

3238
export type SessionIdInput = z.infer<typeof sessionIdInput>;
3339
export type CreateInput = z.infer<typeof createInput>;
40+
export type CreateCommandInput = z.infer<typeof createCommandInput>;
3441
export type WriteInput = z.infer<typeof writeInput>;
3542
export type ResizeInput = z.infer<typeof resizeInput>;
3643
export type ExecuteInput = z.infer<typeof executeInput>;

apps/code/src/main/services/shell/service.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,84 @@ export class ShellService extends TypedEventEmitter<ShellEvents> {
186186
return session;
187187
}
188188

189+
async createCommandSession(options: {
190+
sessionId: string;
191+
command: string;
192+
cwd: string;
193+
taskId?: string;
194+
}): Promise<void> {
195+
const { sessionId, command, cwd, taskId } = options;
196+
197+
const existing = this.sessions.get(sessionId);
198+
if (existing) {
199+
return;
200+
}
201+
202+
const taskEnv = await this.getTaskEnv(taskId);
203+
const workingDir = this.resolveWorkingDir(sessionId, cwd);
204+
const shell = getDefaultShell();
205+
206+
log.info(
207+
`Creating command session ${sessionId}: shell=${shell} -c ..., cwd=${workingDir}`,
208+
);
209+
210+
const ptyProcess = pty.spawn(shell, ["-c", command], {
211+
name: "xterm-256color",
212+
cols: 80,
213+
rows: 24,
214+
cwd: workingDir,
215+
env: buildShellEnv(taskEnv),
216+
encoding: null,
217+
});
218+
219+
this.processTracking.register(
220+
ptyProcess.pid,
221+
"shell",
222+
`shell:${sessionId}`,
223+
{ sessionId, cwd: workingDir, command },
224+
taskId,
225+
);
226+
227+
let resolveExit: (result: { exitCode: number }) => void;
228+
const exitPromise = new Promise<{ exitCode: number }>((resolve) => {
229+
resolveExit = resolve;
230+
});
231+
232+
const disposables: pty.IDisposable[] = [];
233+
234+
disposables.push(
235+
ptyProcess.onData((data: string) => {
236+
this.emit(ShellEvent.Data, { sessionId, data });
237+
}),
238+
);
239+
240+
disposables.push(
241+
ptyProcess.onExit(({ exitCode }) => {
242+
log.info(`Command session ${sessionId} exited with code ${exitCode}`);
243+
this.processTracking.unregister(ptyProcess.pid, "exited");
244+
const session = this.sessions.get(sessionId);
245+
if (session) {
246+
for (const d of session.disposables) {
247+
d.dispose();
248+
}
249+
session.pty.destroy();
250+
this.sessions.delete(sessionId);
251+
}
252+
this.emit(ShellEvent.Exit, { sessionId, exitCode });
253+
resolveExit({ exitCode });
254+
}),
255+
);
256+
257+
const session: ShellSession = {
258+
pty: ptyProcess,
259+
exitPromise,
260+
command,
261+
disposables,
262+
};
263+
264+
this.sessions.set(sessionId, session);
265+
}
266+
189267
write(sessionId: string, data: string): void {
190268
this.getSessionOrThrow(sessionId).pty.write(data);
191269
}

apps/code/src/main/trpc/routers/shell.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { container } from "../../di/container";
22
import { MAIN_TOKENS } from "../../di/tokens";
33
import {
4+
createCommandInput,
45
createInput,
56
executeInput,
67
executeOutput,
@@ -38,6 +39,17 @@ export const shellRouter = router({
3839
getService().create(input.sessionId, input.cwd, input.taskId),
3940
),
4041

42+
createCommand: publicProcedure
43+
.input(createCommandInput)
44+
.mutation(({ input }) =>
45+
getService().createCommandSession({
46+
sessionId: input.sessionId,
47+
command: input.command,
48+
cwd: input.cwd,
49+
taskId: input.taskId,
50+
}),
51+
),
52+
4153
write: publicProcedure
4254
.input(writeInput)
4355
.mutation(({ input }) => getService().write(input.sessionId, input.data)),
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Tooltip } from "@components/ui/Tooltip";
2+
import {
3+
getActionSessionId,
4+
useActionStore,
5+
} from "@features/actions/stores/actionStore";
6+
import { terminalManager } from "@features/terminal/services/TerminalManager";
7+
import { ArrowClockwise, Check, X } from "@phosphor-icons/react";
8+
import { Spinner } from "@radix-ui/themes";
9+
import { trpcClient } from "@renderer/trpc/client";
10+
import { useCallback, useState } from "react";
11+
12+
interface ActionTabIconProps {
13+
actionId: string;
14+
}
15+
16+
export function ActionTabIcon({ actionId }: ActionTabIconProps) {
17+
const [hovered, setHovered] = useState(false);
18+
const status = useActionStore((state) => state.statuses[actionId]);
19+
const generation = useActionStore(
20+
(state) => state.generations[actionId] ?? 0,
21+
);
22+
const rerun = useActionStore((state) => state.rerun);
23+
24+
const triggerRerun = useCallback(() => {
25+
const sessionId = getActionSessionId(actionId, generation);
26+
terminalManager.destroy(sessionId);
27+
trpcClient.shell.destroy.mutate({ sessionId });
28+
rerun(actionId);
29+
}, [actionId, generation, rerun]);
30+
31+
const handleClick = useCallback(
32+
(e: React.MouseEvent) => {
33+
if (!hovered) return;
34+
e.stopPropagation();
35+
triggerRerun();
36+
},
37+
[hovered, triggerRerun],
38+
);
39+
40+
let icon: React.ReactNode;
41+
if (hovered) {
42+
icon = <ArrowClockwise size={14} weight="bold" />;
43+
} else if (status === "success") {
44+
icon = <Check size={14} weight="bold" className="text-green-9" />;
45+
} else if (status === "error") {
46+
icon = <X size={14} weight="bold" className="text-red-9" />;
47+
} else {
48+
icon = <Spinner size="1" />;
49+
}
50+
51+
const content = (
52+
<button
53+
type="button"
54+
onMouseEnter={() => setHovered(true)}
55+
onMouseLeave={() => setHovered(false)}
56+
onClick={handleClick}
57+
style={{
58+
display: "flex",
59+
alignItems: "center",
60+
cursor: hovered ? "pointer" : undefined,
61+
background: "none",
62+
border: "none",
63+
padding: 0,
64+
margin: 0,
65+
color: "inherit",
66+
}}
67+
>
68+
{icon}
69+
</button>
70+
);
71+
72+
if (hovered) {
73+
return (
74+
<Tooltip content="Rerun action" side="bottom">
75+
{content}
76+
</Tooltip>
77+
);
78+
}
79+
80+
return content;
81+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { create } from "zustand";
2+
import { persist } from "zustand/middleware";
3+
4+
export type ActionStatus = "running" | "success" | "error";
5+
6+
export function getActionSessionId(
7+
actionId: string,
8+
generation: number,
9+
): string {
10+
return `action-${actionId}-${generation}`;
11+
}
12+
13+
interface ActionStoreState {
14+
statuses: Record<string, ActionStatus>;
15+
generations: Record<string, number>;
16+
}
17+
18+
interface ActionStoreActions {
19+
setStatus: (actionId: string, status: ActionStatus) => void;
20+
rerun: (actionId: string) => void;
21+
clear: (actionId: string) => void;
22+
}
23+
24+
type ActionStore = ActionStoreState & ActionStoreActions;
25+
26+
export const useActionStore = create<ActionStore>()(
27+
persist(
28+
(set) => ({
29+
statuses: {},
30+
generations: {},
31+
32+
setStatus: (actionId, status) =>
33+
set((state) => ({
34+
statuses: { ...state.statuses, [actionId]: status },
35+
})),
36+
37+
rerun: (actionId) =>
38+
set((state) => {
39+
const { [actionId]: _, ...restStatuses } = state.statuses;
40+
return {
41+
statuses: restStatuses,
42+
generations: {
43+
...state.generations,
44+
[actionId]: (state.generations[actionId] ?? 0) + 1,
45+
},
46+
};
47+
}),
48+
49+
clear: (actionId) =>
50+
set((state) => {
51+
const { [actionId]: _s, ...restStatuses } = state.statuses;
52+
const { [actionId]: _g, ...restGenerations } = state.generations;
53+
return { statuses: restStatuses, generations: restGenerations };
54+
}),
55+
}),
56+
{
57+
name: "action-storage",
58+
partialize: (state) => ({
59+
statuses: state.statuses,
60+
generations: state.generations,
61+
}),
62+
},
63+
),
64+
);

apps/code/src/renderer/features/panels/components/TabbedPanel.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,19 @@ import type { PanelContent } from "../store/panelStore";
1010
import { PanelDropZones } from "./PanelDropZones";
1111
import { PanelTab } from "./PanelTab";
1212

13-
const activeTabStyle = { height: "100%" } as const;
14-
const hiddenTabStyle = { display: "none" } as const;
13+
const activeTabStyle: React.CSSProperties = {
14+
height: "100%",
15+
width: "100%",
16+
};
17+
const hiddenTabStyle: React.CSSProperties = {
18+
height: "100%",
19+
width: "100%",
20+
position: "absolute",
21+
top: 0,
22+
left: 0,
23+
visibility: "hidden",
24+
pointerEvents: "none",
25+
};
1526

1627
interface TabBarButtonProps {
1728
ariaLabel: string;

apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FileIcon } from "@components/ui/FileIcon";
2+
import { ActionTabIcon } from "@features/actions/components/ActionTabIcon";
23
import { useCwd } from "@features/sidebar/hooks/useCwd";
34
import { TabContentRenderer } from "@features/task-detail/components/TabContentRenderer";
45
import { ChatCenteredText, Terminal } from "@phosphor-icons/react";
@@ -102,6 +103,8 @@ export function useTabInjection(
102103
icon = <Terminal size={14} />;
103104
} else if (tab.data.type === "logs") {
104105
icon = <ChatCenteredText size={14} />;
106+
} else if (tab.data.type === "action") {
107+
icon = <ActionTabIcon actionId={tab.data.actionId} />;
105108
}
106109
}
107110

0 commit comments

Comments
 (0)