@saadnvd1/agent-os 0.3.610 → 0.3.612
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/app/api/sessions/[id]/simulator/run/route.ts +1 -0
- package/components/Pane/MobileTabBar.tsx +56 -106
- package/components/Pane/MobileViewPicker.test.ts +92 -0
- package/components/Pane/MobileViewPicker.tsx +106 -0
- package/lib/project-config/brief.ts +4 -0
- package/lib/simulator/brief.ts +2 -1
- package/lib/simulator/index.ts +50 -8
- package/lib/simulator/screenshot.ts +180 -0
- package/package.json +1 -1
|
@@ -17,69 +17,26 @@ import {
|
|
|
17
17
|
ChevronDown,
|
|
18
18
|
ChevronLeft,
|
|
19
19
|
ChevronRight,
|
|
20
|
+
Ellipsis,
|
|
20
21
|
Circle,
|
|
21
|
-
FolderOpen,
|
|
22
|
-
GitBranch,
|
|
23
22
|
Menu,
|
|
24
23
|
Plus,
|
|
25
24
|
Search,
|
|
25
|
+
Star,
|
|
26
26
|
Share2,
|
|
27
|
-
Terminal as TerminalIcon,
|
|
28
27
|
TextSearch,
|
|
29
|
-
Users,
|
|
30
|
-
ScrollText,
|
|
31
|
-
Package,
|
|
32
28
|
} from "lucide-react";
|
|
33
29
|
import { cn } from "@/lib/utils";
|
|
34
30
|
import { useAwayQuery } from "@/data/away";
|
|
35
31
|
import { awayUntil } from "@/components/Away/time";
|
|
36
32
|
import type { Session, Project } from "@/lib/db";
|
|
37
|
-
import type { LucideIcon } from "lucide-react";
|
|
38
33
|
import { HostBadge } from "@/components/Hosts/HostBadge";
|
|
34
|
+
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
39
35
|
import { shareUiActions } from "@/stores/shareUi";
|
|
40
36
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
41
37
|
import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
|
|
42
38
|
import type { ViewMode } from "@/contexts/PaneContext";
|
|
43
|
-
|
|
44
|
-
interface ViewModeButtonProps {
|
|
45
|
-
mode: ViewMode;
|
|
46
|
-
currentMode: ViewMode;
|
|
47
|
-
icon: LucideIcon;
|
|
48
|
-
label?: string;
|
|
49
|
-
onClick: (mode: ViewMode) => void;
|
|
50
|
-
badge?: React.ReactNode;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function ViewModeButton({
|
|
54
|
-
mode,
|
|
55
|
-
currentMode,
|
|
56
|
-
icon: Icon,
|
|
57
|
-
label,
|
|
58
|
-
onClick,
|
|
59
|
-
badge,
|
|
60
|
-
}: ViewModeButtonProps) {
|
|
61
|
-
return (
|
|
62
|
-
<button
|
|
63
|
-
type="button"
|
|
64
|
-
aria-label={label}
|
|
65
|
-
aria-pressed={currentMode === mode}
|
|
66
|
-
onClick={(e) => {
|
|
67
|
-
e.stopPropagation();
|
|
68
|
-
onClick(mode);
|
|
69
|
-
}}
|
|
70
|
-
className={cn(
|
|
71
|
-
"rounded p-1.5 transition-colors",
|
|
72
|
-
badge && "flex items-center gap-0.5",
|
|
73
|
-
currentMode === mode
|
|
74
|
-
? "bg-secondary text-foreground"
|
|
75
|
-
: "text-muted-foreground"
|
|
76
|
-
)}
|
|
77
|
-
>
|
|
78
|
-
<Icon className="h-4 w-4" />
|
|
79
|
-
{badge}
|
|
80
|
-
</button>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
39
|
+
import { MobileViewPicker } from "./MobileViewPicker";
|
|
83
40
|
|
|
84
41
|
interface MobileTabBarProps {
|
|
85
42
|
session: Session | null | undefined;
|
|
@@ -205,7 +162,7 @@ export function MobileTabBar({
|
|
|
205
162
|
onClick={handlePrev}
|
|
206
163
|
onTouchEnd={(e) => e.stopPropagation()}
|
|
207
164
|
disabled={!hasPrev || isNavigating}
|
|
208
|
-
className="hover:bg-accent flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:pointer-events-none disabled:opacity-50"
|
|
165
|
+
className="hover:bg-accent flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:pointer-events-none disabled:opacity-50 max-[429px]:hidden"
|
|
209
166
|
>
|
|
210
167
|
<ChevronLeft className="h-4 w-4" />
|
|
211
168
|
</button>
|
|
@@ -304,7 +261,7 @@ export function MobileTabBar({
|
|
|
304
261
|
onClick={handleNext}
|
|
305
262
|
onTouchEnd={(e) => e.stopPropagation()}
|
|
306
263
|
disabled={!hasNext || isNavigating}
|
|
307
|
-
className="hover:bg-accent flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:pointer-events-none disabled:opacity-50"
|
|
264
|
+
className="hover:bg-accent flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:pointer-events-none disabled:opacity-50 max-[429px]:hidden"
|
|
308
265
|
>
|
|
309
266
|
<ChevronRight className="h-4 w-4" />
|
|
310
267
|
</button>
|
|
@@ -313,7 +270,7 @@ export function MobileTabBar({
|
|
|
313
270
|
{bookmarksSessionId && (
|
|
314
271
|
<ChatBookmarksButton
|
|
315
272
|
sessionId={bookmarksSessionId}
|
|
316
|
-
className="-my-1.5 h-11 min-w-11"
|
|
273
|
+
className="-my-1.5 h-11 min-w-11 max-[429px]:hidden"
|
|
317
274
|
/>
|
|
318
275
|
)}
|
|
319
276
|
{onFind && (
|
|
@@ -325,7 +282,7 @@ export function MobileTabBar({
|
|
|
325
282
|
e.stopPropagation();
|
|
326
283
|
onFind();
|
|
327
284
|
}}
|
|
328
|
-
className="-my-1.5 h-11 w-11 shrink-0"
|
|
285
|
+
className="-my-1.5 h-11 w-11 shrink-0 max-[429px]:hidden"
|
|
329
286
|
>
|
|
330
287
|
<TextSearch className="h-4 w-4" />
|
|
331
288
|
</Button>
|
|
@@ -338,11 +295,49 @@ export function MobileTabBar({
|
|
|
338
295
|
e.stopPropagation();
|
|
339
296
|
paletteActions.open();
|
|
340
297
|
}}
|
|
341
|
-
//
|
|
342
|
-
className="-my-1.5 h-11 w-11 shrink-0"
|
|
298
|
+
// 44px targets in a 32px bar row, via -my-1.5.
|
|
299
|
+
className="-my-1.5 h-11 w-11 shrink-0 max-[429px]:hidden"
|
|
343
300
|
>
|
|
344
301
|
<Search className="h-4 w-4" />
|
|
345
302
|
</Button>
|
|
303
|
+
{/* Below 430px Find, Palette and Bookmarks fold into one menu. */}
|
|
304
|
+
<DropdownMenu modal={false}>
|
|
305
|
+
<DropdownMenuTrigger asChild>
|
|
306
|
+
<Button
|
|
307
|
+
variant="ghost"
|
|
308
|
+
size="icon-sm"
|
|
309
|
+
aria-label="More"
|
|
310
|
+
onClick={(e) => e.stopPropagation()}
|
|
311
|
+
className="-my-1.5 hidden h-11 w-11 shrink-0 max-[429px]:flex"
|
|
312
|
+
>
|
|
313
|
+
<Ellipsis className="h-4 w-4" />
|
|
314
|
+
</Button>
|
|
315
|
+
</DropdownMenuTrigger>
|
|
316
|
+
<DropdownMenuContent align="end" className="min-w-48">
|
|
317
|
+
{onFind && (
|
|
318
|
+
<DropdownMenuItem className="h-11 gap-2" onSelect={onFind}>
|
|
319
|
+
<TextSearch className="h-4 w-4" />
|
|
320
|
+
Find in conversation
|
|
321
|
+
</DropdownMenuItem>
|
|
322
|
+
)}
|
|
323
|
+
<DropdownMenuItem
|
|
324
|
+
className="h-11 gap-2"
|
|
325
|
+
onSelect={() => paletteActions.open()}
|
|
326
|
+
>
|
|
327
|
+
<Search className="h-4 w-4" />
|
|
328
|
+
Command palette
|
|
329
|
+
</DropdownMenuItem>
|
|
330
|
+
{bookmarksSessionId && (
|
|
331
|
+
<DropdownMenuItem
|
|
332
|
+
className="h-11 gap-2"
|
|
333
|
+
onSelect={() => bookmarksUiActions.openChat(bookmarksSessionId)}
|
|
334
|
+
>
|
|
335
|
+
<Star className="h-4 w-4" />
|
|
336
|
+
Bookmarks
|
|
337
|
+
</DropdownMenuItem>
|
|
338
|
+
)}
|
|
339
|
+
</DropdownMenuContent>
|
|
340
|
+
</DropdownMenu>
|
|
346
341
|
<Button
|
|
347
342
|
variant="ghost"
|
|
348
343
|
size="icon-sm"
|
|
@@ -358,60 +353,15 @@ export function MobileTabBar({
|
|
|
358
353
|
<SessionPrStage sessionId={session?.id} />
|
|
359
354
|
<ViewSwitch session={session} />
|
|
360
355
|
|
|
361
|
-
{/* View mode toggle */}
|
|
362
356
|
{session?.working_directory && (
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
<ViewModeButton
|
|
372
|
-
mode="files"
|
|
373
|
-
currentMode={viewMode}
|
|
374
|
-
icon={FolderOpen}
|
|
375
|
-
onClick={onViewModeChange}
|
|
376
|
-
/>
|
|
377
|
-
)}
|
|
378
|
-
{isLocalSession && (
|
|
379
|
-
<ViewModeButton
|
|
380
|
-
mode="git"
|
|
381
|
-
currentMode={viewMode}
|
|
382
|
-
icon={GitBranch}
|
|
383
|
-
onClick={onViewModeChange}
|
|
384
|
-
/>
|
|
385
|
-
)}
|
|
386
|
-
<ViewModeButton
|
|
387
|
-
mode="artifacts"
|
|
388
|
-
currentMode={viewMode}
|
|
389
|
-
icon={Package}
|
|
390
|
-
label="Artifacts"
|
|
391
|
-
onClick={onViewModeChange}
|
|
392
|
-
/>
|
|
393
|
-
{hasLog && (
|
|
394
|
-
<ViewModeButton
|
|
395
|
-
mode="log"
|
|
396
|
-
currentMode={viewMode}
|
|
397
|
-
icon={ScrollText}
|
|
398
|
-
onClick={onViewModeChange}
|
|
399
|
-
/>
|
|
400
|
-
)}
|
|
401
|
-
{isConductor && (
|
|
402
|
-
<ViewModeButton
|
|
403
|
-
mode="workers"
|
|
404
|
-
currentMode={viewMode}
|
|
405
|
-
icon={Users}
|
|
406
|
-
onClick={onViewModeChange}
|
|
407
|
-
badge={
|
|
408
|
-
<span className="bg-primary/20 text-primary rounded px-1 text-[10px]">
|
|
409
|
-
{workerCount}
|
|
410
|
-
</span>
|
|
411
|
-
}
|
|
412
|
-
/>
|
|
413
|
-
)}
|
|
414
|
-
</div>
|
|
357
|
+
<MobileViewPicker
|
|
358
|
+
viewMode={viewMode}
|
|
359
|
+
onViewModeChange={onViewModeChange}
|
|
360
|
+
isLocalSession={isLocalSession}
|
|
361
|
+
hasLog={hasLog}
|
|
362
|
+
isConductor={isConductor}
|
|
363
|
+
workerCount={workerCount}
|
|
364
|
+
/>
|
|
415
365
|
)}
|
|
416
366
|
</div>
|
|
417
367
|
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { MobileViewPicker, mobileViews } from "./MobileViewPicker";
|
|
6
|
+
|
|
7
|
+
// Radix dispatches CustomEvents; the node global is not jsdom's.
|
|
8
|
+
Object.defineProperty(globalThis, "CustomEvent", {
|
|
9
|
+
value: (window as unknown as { CustomEvent: unknown }).CustomEvent,
|
|
10
|
+
configurable: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const all = {
|
|
14
|
+
isLocalSession: true,
|
|
15
|
+
hasLog: true,
|
|
16
|
+
isConductor: true,
|
|
17
|
+
workerCount: 3,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("mobileViews", () => {
|
|
21
|
+
it("lists every view, Artifacts included", () => {
|
|
22
|
+
expect(mobileViews(all).map((v) => v.label)).toEqual([
|
|
23
|
+
"Chat",
|
|
24
|
+
"Files",
|
|
25
|
+
"Git",
|
|
26
|
+
"Artifacts",
|
|
27
|
+
"Log",
|
|
28
|
+
"Workers",
|
|
29
|
+
]);
|
|
30
|
+
expect(mobileViews(all).at(-1)?.count).toBe(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("hides views that do not apply", () => {
|
|
34
|
+
expect(
|
|
35
|
+
mobileViews({
|
|
36
|
+
isLocalSession: false,
|
|
37
|
+
isConductor: false,
|
|
38
|
+
workerCount: 0,
|
|
39
|
+
}).map((v) => v.label)
|
|
40
|
+
).toEqual(["Chat", "Artifacts"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("MobileViewPicker", () => {
|
|
45
|
+
async function open(viewMode: "terminal" | "artifacts", onChange = vi.fn()) {
|
|
46
|
+
const el = document.createElement("div");
|
|
47
|
+
document.body.appendChild(el);
|
|
48
|
+
const root = createRoot(el);
|
|
49
|
+
await act(async () => {
|
|
50
|
+
root.render(
|
|
51
|
+
createElement(MobileViewPicker, {
|
|
52
|
+
...all,
|
|
53
|
+
viewMode,
|
|
54
|
+
onViewModeChange: onChange,
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
const trigger = el.querySelector("button") as HTMLButtonElement;
|
|
59
|
+
await act(async () => {
|
|
60
|
+
trigger.dispatchEvent(
|
|
61
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
return { trigger, onChange };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("shows the current view and lists all views in the menu", async () => {
|
|
68
|
+
const { trigger } = await open("artifacts");
|
|
69
|
+
expect(trigger.textContent).toContain("Artifacts");
|
|
70
|
+
const items = [...document.querySelectorAll('[role="menuitem"]')];
|
|
71
|
+
expect(items.map((i) => i.textContent)).toEqual([
|
|
72
|
+
"Chat",
|
|
73
|
+
"Files",
|
|
74
|
+
"Git",
|
|
75
|
+
"Artifacts",
|
|
76
|
+
"Log",
|
|
77
|
+
"Workers3",
|
|
78
|
+
]);
|
|
79
|
+
expect(items[3].getAttribute("aria-current")).toBe("true");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("selecting an item calls the view-change handler", async () => {
|
|
83
|
+
const { onChange } = await open("terminal");
|
|
84
|
+
const log = [...document.querySelectorAll('[role="menuitem"]')].find((i) =>
|
|
85
|
+
i.textContent?.startsWith("Log")
|
|
86
|
+
) as HTMLElement;
|
|
87
|
+
await act(async () => {
|
|
88
|
+
log.click();
|
|
89
|
+
});
|
|
90
|
+
expect(onChange).toHaveBeenCalledWith("log");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Check,
|
|
5
|
+
ChevronDown,
|
|
6
|
+
FolderOpen,
|
|
7
|
+
GitBranch,
|
|
8
|
+
Package,
|
|
9
|
+
ScrollText,
|
|
10
|
+
Terminal as TerminalIcon,
|
|
11
|
+
Users,
|
|
12
|
+
type LucideIcon,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import {
|
|
15
|
+
DropdownMenu,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
DropdownMenuItem,
|
|
18
|
+
DropdownMenuTrigger,
|
|
19
|
+
} from "@/components/ui/dropdown-menu";
|
|
20
|
+
import type { ViewMode } from "@/contexts/PaneContext";
|
|
21
|
+
|
|
22
|
+
export interface MobileView {
|
|
23
|
+
mode: ViewMode;
|
|
24
|
+
label: string;
|
|
25
|
+
icon: LucideIcon;
|
|
26
|
+
count?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MobileViewAvailability {
|
|
30
|
+
isLocalSession: boolean;
|
|
31
|
+
hasLog?: boolean;
|
|
32
|
+
isConductor: boolean;
|
|
33
|
+
workerCount: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The one list of views on a phone; the picker renders it.
|
|
37
|
+
export function mobileViews({
|
|
38
|
+
isLocalSession,
|
|
39
|
+
hasLog,
|
|
40
|
+
isConductor,
|
|
41
|
+
workerCount,
|
|
42
|
+
}: MobileViewAvailability): MobileView[] {
|
|
43
|
+
const views: (MobileView | false)[] = [
|
|
44
|
+
{ mode: "terminal", label: "Chat", icon: TerminalIcon },
|
|
45
|
+
isLocalSession && { mode: "files", label: "Files", icon: FolderOpen },
|
|
46
|
+
isLocalSession && { mode: "git", label: "Git", icon: GitBranch },
|
|
47
|
+
{ mode: "artifacts", label: "Artifacts", icon: Package },
|
|
48
|
+
!!hasLog && { mode: "log", label: "Log", icon: ScrollText },
|
|
49
|
+
isConductor && {
|
|
50
|
+
mode: "workers",
|
|
51
|
+
label: "Workers",
|
|
52
|
+
icon: Users,
|
|
53
|
+
count: workerCount,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
return views.filter((v): v is MobileView => !!v);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function MobileViewPicker({
|
|
60
|
+
viewMode,
|
|
61
|
+
onViewModeChange,
|
|
62
|
+
...availability
|
|
63
|
+
}: MobileViewAvailability & {
|
|
64
|
+
viewMode: ViewMode;
|
|
65
|
+
onViewModeChange: (mode: ViewMode) => void;
|
|
66
|
+
}) {
|
|
67
|
+
const views = mobileViews(availability);
|
|
68
|
+
const current = views.find((v) => v.mode === viewMode) ?? views[0];
|
|
69
|
+
const CurrentIcon = current.icon;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<DropdownMenu modal={false}>
|
|
73
|
+
<DropdownMenuTrigger asChild>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
aria-label={`View: ${current.label}`}
|
|
77
|
+
onClick={(e) => e.stopPropagation()}
|
|
78
|
+
className="bg-accent/50 relative flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-xs after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']"
|
|
79
|
+
>
|
|
80
|
+
<CurrentIcon className="h-4 w-4" />
|
|
81
|
+
<span className="max-[359px]:hidden">{current.label}</span>
|
|
82
|
+
<ChevronDown className="text-muted-foreground h-3 w-3" />
|
|
83
|
+
</button>
|
|
84
|
+
</DropdownMenuTrigger>
|
|
85
|
+
<DropdownMenuContent align="end" className="min-w-40">
|
|
86
|
+
{views.map(({ mode, label, icon: Icon, count }) => (
|
|
87
|
+
<DropdownMenuItem
|
|
88
|
+
key={mode}
|
|
89
|
+
aria-current={mode === current.mode}
|
|
90
|
+
onSelect={() => onViewModeChange(mode)}
|
|
91
|
+
className="h-11 gap-2"
|
|
92
|
+
>
|
|
93
|
+
<Icon className="h-4 w-4" />
|
|
94
|
+
<span className="flex-1">{label}</span>
|
|
95
|
+
{count !== undefined && (
|
|
96
|
+
<span className="bg-primary/20 text-primary rounded px-1 text-[10px]">
|
|
97
|
+
{count}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
{mode === current.mode && <Check className="h-4 w-4" />}
|
|
101
|
+
</DropdownMenuItem>
|
|
102
|
+
))}
|
|
103
|
+
</DropdownMenuContent>
|
|
104
|
+
</DropdownMenu>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -50,6 +50,10 @@ export function runningBrief(
|
|
|
50
50
|
lines.push(
|
|
51
51
|
`- \`${browse.map}\` maps every page to its URL: navigate by URL, don't explore.`
|
|
52
52
|
);
|
|
53
|
+
if (browse)
|
|
54
|
+
lines.push(
|
|
55
|
+
"- Check state with the browser's text snapshot, not screenshots. Screenshot only the final changed screen, and just the changed element (its uid) rather than the full page where the tool allows; every image stays in your context."
|
|
56
|
+
);
|
|
53
57
|
const sim = simulatorBrief(config.simulator);
|
|
54
58
|
if (sim) lines.push(sim);
|
|
55
59
|
for (const note of notesOf(config.notes))
|
package/lib/simulator/brief.ts
CHANGED
|
@@ -12,7 +12,8 @@ export function simulatorBrief(
|
|
|
12
12
|
return "- The iOS simulator tools are not available on this machine; skip simulator checks and say so in the PR.";
|
|
13
13
|
return [
|
|
14
14
|
"- iOS simulator (optional; it never gates a PR or a build, and a failure is reported, not a blocker): `aos sim open` makes your own simulator and installs this worktree's build (run it with run_in_background, a cold build takes minutes; `aos sim status` shows where it is).",
|
|
15
|
-
'
|
|
15
|
+
' Drive and check state with `aos sim snapshot -i` (accessibility tree with @refs, text, cheap), `aos sim click @e3`, `aos sim fill @e5 "text"`, `aos sim logs --last 2m`. Refs are only valid from the latest snapshot.',
|
|
16
|
+
" Every image you read stays in your context for every later turn, so screenshot only the final changed screens, and prefer `aos sim screenshot --region @e3` (one element from the latest snapshot) for a single row or banner. It prints two paths: read the `read:` one (reduced to 800px); attach the `full-size` file to the PR.",
|
|
16
17
|
" Screenshots are written outside the worktree: don't put them in commits or the PR body unless they belong there. `aos sim close` when you're done; it is closed for you when the task ends.",
|
|
17
18
|
].join("\n");
|
|
18
19
|
}
|
package/lib/simulator/index.ts
CHANGED
|
@@ -24,6 +24,13 @@ import {
|
|
|
24
24
|
simulatorDir,
|
|
25
25
|
type CallResult,
|
|
26
26
|
} from "./agent-device";
|
|
27
|
+
import {
|
|
28
|
+
RegionError,
|
|
29
|
+
cropToFrame,
|
|
30
|
+
frameOf,
|
|
31
|
+
reducedCopy,
|
|
32
|
+
takeRegion,
|
|
33
|
+
} from "./screenshot";
|
|
27
34
|
import {
|
|
28
35
|
allLeases,
|
|
29
36
|
bootedCount,
|
|
@@ -320,7 +327,7 @@ async function wake(sessionId: string, lease: Lease): Promise<void> {
|
|
|
320
327
|
export async function runSimulator(
|
|
321
328
|
sessionId: string,
|
|
322
329
|
args: string[]
|
|
323
|
-
): Promise<CallResult & { screenshot: string | null }> {
|
|
330
|
+
): Promise<CallResult & { screenshot: string | null; read: string | null }> {
|
|
324
331
|
const { config, cwd } = declared(sessionId);
|
|
325
332
|
let lease = getLease(sessionId);
|
|
326
333
|
if (!lease || lease.state === "failed")
|
|
@@ -333,23 +340,58 @@ export async function runSimulator(
|
|
|
333
340
|
await wake(sessionId, lease);
|
|
334
341
|
lease = getLease(sessionId)!;
|
|
335
342
|
}
|
|
343
|
+
let region: string | null = null;
|
|
344
|
+
if (args[0] === "screenshot") {
|
|
345
|
+
try {
|
|
346
|
+
({ args, region } = takeRegion(args));
|
|
347
|
+
} catch (error) {
|
|
348
|
+
throw new SimulatorError((error as Error).message, 400);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const target = { udid: lease.udid!, sessionId, app: config.app, cwd };
|
|
336
352
|
let argv: string[];
|
|
337
353
|
try {
|
|
338
|
-
argv = buildArgs(args,
|
|
339
|
-
udid: lease.udid!,
|
|
340
|
-
sessionId,
|
|
341
|
-
app: config.app,
|
|
342
|
-
cwd,
|
|
343
|
-
});
|
|
354
|
+
argv = buildArgs(args, target);
|
|
344
355
|
} catch (error) {
|
|
345
356
|
throw new SimulatorError((error as Error).message, 400);
|
|
346
357
|
}
|
|
347
358
|
const shot = screenshotPath(argv);
|
|
348
359
|
if (shot) fs.mkdirSync(path.dirname(shot), { recursive: true });
|
|
349
360
|
touchLease(sessionId);
|
|
361
|
+
// The element's frame comes from a fresh snapshot, before the shot, so a
|
|
362
|
+
// bad ref fails without writing anything.
|
|
363
|
+
let located: ReturnType<typeof frameOf> | null = null;
|
|
364
|
+
if (region) {
|
|
365
|
+
const snap = await callTool(
|
|
366
|
+
buildArgs(["snapshot", "-i", "--json"], target)
|
|
367
|
+
);
|
|
368
|
+
try {
|
|
369
|
+
if (snap.code) throw new RegionError(snap.stderr || "snapshot failed");
|
|
370
|
+
located = frameOf(snap.stdout, region);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error instanceof RegionError)
|
|
373
|
+
throw new SimulatorError(error.message, 400);
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
350
377
|
const result = await callTool(argv);
|
|
378
|
+
const taken = shot && fs.existsSync(shot) ? shot : null;
|
|
379
|
+
let note = "";
|
|
380
|
+
if (taken && located) {
|
|
381
|
+
try {
|
|
382
|
+
await cropToFrame(taken, located.frame, located.screen);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
note = `region crop failed (${(error as Error).message}); kept the full screen\n`;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const read = taken ? await reducedCopy(taken) : null;
|
|
351
388
|
touchLease(sessionId);
|
|
352
|
-
return {
|
|
389
|
+
return {
|
|
390
|
+
...result,
|
|
391
|
+
stderr: result.stderr + note,
|
|
392
|
+
screenshot: taken,
|
|
393
|
+
read,
|
|
394
|
+
};
|
|
353
395
|
}
|
|
354
396
|
|
|
355
397
|
export async function simulatorLogs(sessionId: string, last: string) {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheaper screenshots for the agent: every image it reads costs tokens on
|
|
3
|
+
* every later turn, so it reads a copy capped at READ_SIZE px (or just one
|
|
4
|
+
* element of the screen) while the full-size file stays for the PR.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from "child_process";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { promisify } from "util";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
export const READ_SIZE = 800;
|
|
15
|
+
// Points of context kept around a cropped element.
|
|
16
|
+
export const REGION_MARGIN = 8;
|
|
17
|
+
|
|
18
|
+
export type Sips = (args: string[]) => Promise<string>;
|
|
19
|
+
|
|
20
|
+
const realSips: Sips = async (args) =>
|
|
21
|
+
(await execFileAsync("sips", args, { timeout: 30_000 })).stdout;
|
|
22
|
+
let sips: Sips = realSips;
|
|
23
|
+
export const setSips = (fn: Sips | null) => {
|
|
24
|
+
sips = fn ?? realSips;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface Frame {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
export interface Crop {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class RegionError extends Error {}
|
|
41
|
+
|
|
42
|
+
// Where the agent reads from: a copy under shots/read, so the shots strip and
|
|
43
|
+
// the PR files stay the full-size ones.
|
|
44
|
+
export const readCopyPath = (full: string) =>
|
|
45
|
+
path.join(path.dirname(full), "read", path.basename(full));
|
|
46
|
+
|
|
47
|
+
async function pixelSize(file: string) {
|
|
48
|
+
const out = await sips(["-g", "pixelWidth", "-g", "pixelHeight", file]);
|
|
49
|
+
const w = Number(/pixelWidth:\s*(\d+)/.exec(out)?.[1]);
|
|
50
|
+
const h = Number(/pixelHeight:\s*(\d+)/.exec(out)?.[1]);
|
|
51
|
+
if (!w || !h) throw new Error("cannot read image size");
|
|
52
|
+
return { width: w, height: h };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The reduced copy, or the full file when resizing fails.
|
|
56
|
+
export async function reducedCopy(full: string): Promise<string> {
|
|
57
|
+
const out = readCopyPath(full);
|
|
58
|
+
try {
|
|
59
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
60
|
+
await sips(["-Z", String(READ_SIZE), full, "--out", out]);
|
|
61
|
+
return fs.existsSync(out) ? out : full;
|
|
62
|
+
} catch {
|
|
63
|
+
return full;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// An element's frame (points) to a pixel rectangle of the image, with a
|
|
68
|
+
// margin, clamped to the image. `screen` is the size of the frame's space.
|
|
69
|
+
export function cropRect(
|
|
70
|
+
frame: Frame,
|
|
71
|
+
screen: { width: number; height: number },
|
|
72
|
+
image: { width: number; height: number },
|
|
73
|
+
margin = REGION_MARGIN
|
|
74
|
+
): Crop {
|
|
75
|
+
const sx = image.width / screen.width;
|
|
76
|
+
const sy = image.height / screen.height;
|
|
77
|
+
const x0 = Math.max(0, Math.floor((frame.x - margin) * sx));
|
|
78
|
+
const y0 = Math.max(0, Math.floor((frame.y - margin) * sy));
|
|
79
|
+
const x1 = Math.min(
|
|
80
|
+
image.width,
|
|
81
|
+
Math.ceil((frame.x + frame.width + margin) * sx)
|
|
82
|
+
);
|
|
83
|
+
const y1 = Math.min(
|
|
84
|
+
image.height,
|
|
85
|
+
Math.ceil((frame.y + frame.height + margin) * sy)
|
|
86
|
+
);
|
|
87
|
+
if (x1 <= x0 || y1 <= y0)
|
|
88
|
+
throw new RegionError("that element is off the screen");
|
|
89
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Node = { ref?: unknown; rect?: Frame };
|
|
93
|
+
|
|
94
|
+
// Every node with a ref and a rect, wherever the JSON nests them.
|
|
95
|
+
function nodesOf(value: unknown, out: Node[] = []): Node[] {
|
|
96
|
+
if (Array.isArray(value)) value.forEach((v) => nodesOf(v, out));
|
|
97
|
+
else if (value && typeof value === "object") {
|
|
98
|
+
const n = value as Node;
|
|
99
|
+
if (typeof n.ref === "string" && n.rect) out.push(n);
|
|
100
|
+
for (const v of Object.values(value)) nodesOf(v, out);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isFrame = (r: unknown): r is Frame =>
|
|
106
|
+
!!r &&
|
|
107
|
+
["x", "y", "width", "height"].every(
|
|
108
|
+
(k) => typeof (r as Record<string, unknown>)[k] === "number"
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// The frame of `ref` in a `snapshot -i --json` output, and the screen size
|
|
112
|
+
// (the widest, tallest frame: the app's window).
|
|
113
|
+
export function frameOf(
|
|
114
|
+
json: string,
|
|
115
|
+
ref: string
|
|
116
|
+
): { frame: Frame; screen: { width: number; height: number } } {
|
|
117
|
+
let nodes: Node[];
|
|
118
|
+
try {
|
|
119
|
+
nodes = nodesOf(JSON.parse(json)).filter((n) => isFrame(n.rect));
|
|
120
|
+
} catch {
|
|
121
|
+
throw new RegionError(
|
|
122
|
+
"could not read the snapshot; run `aos sim snapshot -i`"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const want = ref.replace(/^@/, "");
|
|
126
|
+
const hit = nodes.find((n) => String(n.ref).replace(/^@/, "") === want);
|
|
127
|
+
if (!hit)
|
|
128
|
+
throw new RegionError(
|
|
129
|
+
`unknown or stale ref @${want}: run \`aos sim snapshot -i\` and use a ref from it`
|
|
130
|
+
);
|
|
131
|
+
const screen = nodes.reduce(
|
|
132
|
+
(s, n) => ({
|
|
133
|
+
width: Math.max(s.width, n.rect!.x + n.rect!.width),
|
|
134
|
+
height: Math.max(s.height, n.rect!.y + n.rect!.height),
|
|
135
|
+
}),
|
|
136
|
+
{ width: 0, height: 0 }
|
|
137
|
+
);
|
|
138
|
+
return { frame: hit.rect!, screen };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Crops `file` in place to the rectangle.
|
|
142
|
+
export async function cropInPlace(file: string, c: Crop): Promise<void> {
|
|
143
|
+
await sips([
|
|
144
|
+
"--cropToHeightWidth",
|
|
145
|
+
String(c.height),
|
|
146
|
+
String(c.width),
|
|
147
|
+
"--cropOffset",
|
|
148
|
+
String(c.y),
|
|
149
|
+
String(c.x),
|
|
150
|
+
file,
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function cropToFrame(
|
|
155
|
+
file: string,
|
|
156
|
+
frame: Frame,
|
|
157
|
+
screen: { width: number; height: number }
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
await cropInPlace(file, cropRect(frame, screen, await pixelSize(file)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// `--region <ref>` / `--region=<ref>` out of a screenshot's args.
|
|
163
|
+
export function takeRegion(args: string[]): {
|
|
164
|
+
args: string[];
|
|
165
|
+
region: string | null;
|
|
166
|
+
} {
|
|
167
|
+
const rest: string[] = [];
|
|
168
|
+
let region: string | null = null;
|
|
169
|
+
for (let i = 0; i < args.length; i++) {
|
|
170
|
+
const a = args[i];
|
|
171
|
+
if (a === "--region") {
|
|
172
|
+
region = args[++i] ?? "";
|
|
173
|
+
if (!region) throw new RegionError("--region needs a ref, like @e3");
|
|
174
|
+
} else if (a.startsWith("--region=")) region = a.slice(9);
|
|
175
|
+
else rest.push(a);
|
|
176
|
+
}
|
|
177
|
+
if (region !== null && !/^@?e?\d+$/.test(region) && !/^@\S+$/.test(region))
|
|
178
|
+
throw new RegionError("--region takes a ref from `aos sim snapshot -i`");
|
|
179
|
+
return { args: rest, region };
|
|
180
|
+
}
|