6 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 23a4e2ebc0 Widen run and new-run cards to max-w-5xl with a multi-column form layout
The narrower max-w-2xl card left a lot of unused width on scripts with
several variables, forcing a long single-column scroll. Widening the
card and laying out variable inputs in a responsive grid (up to 3
columns) uses that space; textareas and checkbox groups still span the
full width since they don't shrink well into a column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:37:11 +02:00
valknar b77041cfa8 Size the run terminal by aspect ratio instead of viewport height
h-[60vh] made the terminal's height track the viewport regardless of
its actual width, so it read as too tall on narrower layouts.
aspect-video keeps it at 16/9 relative to its own width instead.
2026-08-16 18:15:17 +02:00
valknar e482810328 Show env-passed variables in the displayed run command line
Release / release (push) Successful in 1m6s
redactedCommandLine only ever included argv (script.command + args),
so a passAs:env variable like a scene name was invisible in run
history even though it's the main thing that varied between runs.
Secrets still redact to *** instead of being omitted outright.
2026-08-16 17:31:31 +02:00
valknar 86aa0b7539 Fix prettier formatting on the new combobox control
Release / release (push) Successful in 1m5s
2026-08-16 17:23:30 +02:00
valknar c7bc4421c5 Add a searchable combobox control for enum variables
select/radio don't scale to enums with dozens of choices. Reuses the
same cmdk Command/Popover primitives multi-select already uses, just
single-valued instead of an array.
2026-08-16 17:21:11 +02:00
valknarandClaude Sonnet 5 a496dc4865 Drop the redundant explicit build step from the release workflow
`pnpm run build` and pnpm publish's automatic prepack hook
(rm -rf .next && next build && rm -rf .next/cache) both ran a full
next build - the explicit step's output got thrown away and rebuilt
from scratch seconds later inside publish anyway. Kept only prepack's
build, since it's the one that actually has to succeed for a
publishable package to exist; a deterministic build that just passed
isn't going to fail differently a few steps later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:24:11 +02:00
10 changed files with 147 additions and 10 deletions
-1
View File
@@ -25,7 +25,6 @@ jobs:
- run: pnpm run typecheck - run: pnpm run typecheck
- run: pnpm run format:check - run: pnpm run format:check
- run: pnpm run test - run: pnpm run test
- run: pnpm run build
- name: Set package version from the tag - name: Set package version from the tag
run: npm pkg set version="${GITHUB_REF_NAME#v}" run: npm pkg set version="${GITHUB_REF_NAME#v}"
+1 -1
View File
@@ -101,7 +101,7 @@ Type-specific fields:
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` | | `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
| `number` | `number` | `slider` (requires both `min` and `max`) | | `number` | `number` | `slider` (requires both `min` and `max`) |
| `boolean` | `checkbox` | `switch` | | `boolean` | `checkbox` | `switch` |
| `enum` | `select` | `radio` | | `enum` | `select` | `radio`, `combobox` (searchable, single-select) |
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` | | `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
### `passAs` semantics ### `passAs` semantics
+2 -2
View File
@@ -66,7 +66,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
})); }));
return ( return (
<div className="mx-auto flex max-w-2xl flex-col gap-4"> <div className="mx-auto flex max-w-5xl flex-col gap-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{run.scriptName}</CardTitle> <CardTitle>{run.scriptName}</CardTitle>
@@ -119,7 +119,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase"> <span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Variables Variables
</span> </span>
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2"> <dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
{variableEntries.map(({ key, label, value }) => ( {variableEntries.map(({ key, label, value }) => (
<div key={key} className="flex flex-col gap-0.5"> <div key={key} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{label}</dt> <dt className="text-muted-foreground">{label}</dt>
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function ScriptPage({
} }
return ( return (
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-5xl">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{script.name}</CardTitle> <CardTitle>{script.name}</CardTitle>
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ComboboxProps {
choices: string[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function Combobox({
choices,
value,
onChange,
placeholder = "Select...",
}: ComboboxProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto min-h-8 w-full justify-between font-normal",
)}
>
<span
className={cn("flex-1 text-left", !value && "text-muted-foreground")}
>
{value || placeholder}
</span>
<ChevronsUpDown className="text-muted-foreground size-4 shrink-0" />
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No matches.</CommandEmpty>
<CommandGroup>
{choices.map((choice) => (
<CommandItem
key={choice}
onSelect={() => {
onChange(choice);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 size-4",
value === choice ? "opacity-100" : "opacity-0",
)}
/>
{choice}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+19 -1
View File
@@ -14,6 +14,11 @@ import { defaultValuesForScript } from "@/lib/config/defaults";
import type { ClientScript } from "@/lib/config/serialize"; import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer"; import { FieldRenderer } from "./field-renderer";
/** Controls whose content doesn't shrink well into a narrow grid column - long-form text,
* or a group of checkboxes that reads better as a single wide list - so they span the full
* grid width instead of sharing a row with other fields. */
const WIDE_CONTROLS = new Set(["textarea", "checkboxGroup"]);
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running - /** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
* secret fields are deliberately excluded there (their stored value is just "***", not the real * secret fields are deliberately excluded there (their stored value is just "***", not the real
* one), so those always fall through to the normal default/empty state and have to be re-entered. */ * one), so those always fall through to the normal default/empty state and have to be re-entered. */
@@ -93,9 +98,22 @@ export function DynamicForm({
This script takes no parameters. This script takes no parameters.
</p> </p>
)} )}
{script.variables.length > 0 && (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
{script.variables.map((variable) => ( {script.variables.map((variable) => (
<FieldRenderer key={variable.name} variable={variable} /> <div
key={variable.name}
className={
WIDE_CONTROLS.has(variable.control)
? "sm:col-span-2 lg:col-span-3"
: undefined
}
>
<FieldRenderer variable={variable} />
</div>
))} ))}
</div>
)}
<Button <Button
type="submit" type="submit"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
+24
View File
@@ -24,6 +24,7 @@ import {
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MultiSelect } from "./controls/multi-select"; import { MultiSelect } from "./controls/multi-select";
import { Combobox } from "./controls/combobox";
import type { ClientVariable } from "@/lib/config/serialize"; import type { ClientVariable } from "@/lib/config/serialize";
export function FieldRenderer({ variable }: { variable: ClientVariable }) { export function FieldRenderer({ variable }: { variable: ClientVariable }) {
@@ -211,6 +212,29 @@ export function FieldRenderer({ variable }: { variable: ClientVariable }) {
</FormItem> </FormItem>
); );
case "combobox":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Combobox
choices={variable.type === "enum" ? variable.choices : []}
value={field.value ?? ""}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "radio": case "radio":
return ( return (
<FormItem> <FormItem>
+1 -1
View File
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="h-[60vh] overflow-hidden bg-[#101215] p-2" className="aspect-video w-full overflow-hidden bg-[#101215] p-2"
/> />
); );
}, },
+8
View File
@@ -18,6 +18,7 @@ const controlSchema = z.enum([
"switch", "switch",
"select", "select",
"radio", "radio",
"combobox",
"multiselect", "multiselect",
"checkboxGroup", "checkboxGroup",
]); ]);
@@ -115,6 +116,13 @@ const variableWithChecks = variableSchema.superRefine((variable, ctx) => {
path: ["control"], path: ["control"],
}); });
} }
if (variable.control === "combobox" && variable.type !== "enum") {
ctx.addIssue({
code: "custom",
message: "control 'combobox' requires type 'enum'",
path: ["control"],
});
}
if ( if (
variable.control === "slider" && variable.control === "slider" &&
variable.type === "number" && variable.type === "number" &&
+9 -1
View File
@@ -26,6 +26,7 @@ export function buildInvocation(
const argv = [...script.args]; const argv = [...script.args];
const env: Record<string, string> = {}; const env: Record<string, string> = {};
const redactedArgv = [...script.args]; const redactedArgv = [...script.args];
const redactedEnvAssignments: string[] = [];
const redactedVariables: Record<string, unknown> = {}; const redactedVariables: Record<string, unknown> = {};
for (const variable of script.variables) { for (const variable of script.variables) {
@@ -55,6 +56,9 @@ export function buildInvocation(
case "env": { case "env": {
const value = stringifyValue(raw, variable.joinWith); const value = stringifyValue(raw, variable.joinWith);
env[variable.envName!] = value; env[variable.envName!] = value;
redactedEnvAssignments.push(
`${variable.envName}=${variable.secret ? REDACTED : value}`,
);
break; break;
} }
case "stdin": { case "stdin": {
@@ -74,7 +78,11 @@ export function buildInvocation(
) )
: undefined; : undefined;
const redactedCommandLine = [script.command, ...redactedArgv].join(" "); const redactedCommandLine = [
...redactedEnvAssignments,
script.command,
...redactedArgv,
].join(" ");
return { argv, env, stdin, redactedVariables, redactedCommandLine }; return { argv, env, stdin, redactedVariables, redactedCommandLine };
} }