Skip to content

Commit fba8e84

Browse files
committed
feat: add setup script panel
1 parent 26d9650 commit fba8e84

16 files changed

Lines changed: 496 additions & 13 deletions

File tree

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

apps/code/src/renderer/features/panels/store/panelLayoutStore.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ export interface PanelLayoutStore {
133133
updateTabLabel: (taskId: string, tabId: string, label: string) => void;
134134
setFocusedPanel: (taskId: string, panelId: string) => void;
135135
addTerminalTab: (taskId: string, panelId: string) => void;
136+
addActionTab: (
137+
taskId: string,
138+
panelId: string,
139+
action: {
140+
actionId: string;
141+
command: string;
142+
cwd: string;
143+
label: string;
144+
},
145+
) => void;
136146
clearAllLayouts: () => void;
137147
}
138148

@@ -951,6 +961,52 @@ export const usePanelLayoutStore = createWithEqualityFn<PanelLayoutStore>()(
951961
);
952962
},
953963

964+
addActionTab: (taskId, panelId, action) => {
965+
const tabId = `action-${action.actionId}`;
966+
set((state) =>
967+
updateTaskLayout(state, taskId, (layout) => {
968+
const existingTab = findTabInTree(layout.panelTree, tabId);
969+
if (existingTab) return {};
970+
971+
const targetPanel = getLeafPanel(layout.panelTree, panelId);
972+
if (!targetPanel) return {};
973+
974+
const updatedTree = updateTreeNode(
975+
layout.panelTree,
976+
panelId,
977+
(panel) => {
978+
if (panel.type !== "leaf") return panel;
979+
980+
const newTab: Tab = {
981+
id: tabId,
982+
label: action.label,
983+
data: {
984+
type: "action",
985+
actionId: action.actionId,
986+
command: action.command,
987+
cwd: action.cwd,
988+
label: action.label,
989+
},
990+
component: null,
991+
draggable: true,
992+
closeable: true,
993+
};
994+
995+
return {
996+
...panel,
997+
content: {
998+
...panel.content,
999+
tabs: [...panel.content.tabs, newTab],
1000+
},
1001+
};
1002+
},
1003+
);
1004+
1005+
return { panelTree: updatedTree };
1006+
}),
1007+
);
1008+
},
1009+
9541010
clearAllLayouts: () => {
9551011
set({ taskLayouts: {} });
9561012
},

0 commit comments

Comments
 (0)