add task input form and improve script runner output

- fetch task input definitions from API and render configurable inputs
- boolean inputs render as No/Yes toggle (e.g. delete_source)
- auto-filled inputs (file_path) are hidden from the form
- wrap script output in dark pre/code block with copy button
- fix ffmpeg -nostdin for batch directory processing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:35:48 +00:00
co-authored by Claude Opus 4.6
parent d4530a9667
commit 729ffb930b
3 changed files with 161 additions and 34 deletions
+4
View File
@@ -26,6 +26,10 @@ inputs:
file_path:
type: string
description: Path to an audio file or directory to convert.
delete_source:
type: boolean
description: Delete source files after successful conversion.
default: false
args: [file_path]
---
+6 -1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
# ── Task: Convert To MP3 ──
EXTENSIONS="flac|wav|ogg|wma|aac|m4a|opus|aiff|aif|ape|wv|alac|dsf|dff"
DELETE_SOURCE="${INPUT_DELETE_SOURCE:-false}"
process_file() {
local input="$1"
@@ -25,8 +26,12 @@ process_file() {
fi
echo "Converting: $input$output"
if ffmpeg -i "$input" -codec:a libmp3lame -b:a 320k -map_metadata 0 -id3v2_version 3 -y "$output" 2>/dev/null; then
if ffmpeg -nostdin -i "$input" -codec:a libmp3lame -b:a 320k -map_metadata 0 -id3v2_version 3 -y "$output" 2>/dev/null; then
echo " Done"
if [[ "$DELETE_SOURCE" == "true" ]]; then
rm "$input"
echo " Deleted source"
fi
else
echo " Failed" >&2
return 1
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { X, Play, Square, CircleCheck, CircleX } from 'lucide-react';
import { X, Play, Square, CircleCheck, CircleX, Copy, Check } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
@@ -7,6 +7,7 @@ import type { TaskInfo, ChatMessage } from '../../../Chat';
import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat';
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels } from 'state/useModels';
import { useClient } from 'hooks/useClient';
import type { TaskSummary } from '../../useTasks';
import { useTaskRunner } from './useTaskRunner';
@@ -191,17 +192,104 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
);
};
// ── Task input definitions ──
type TaskInputDef = {
type: string;
description?: string;
default?: string;
};
type TaskInputFormProps = {
inputDefs: Record<string, TaskInputDef>;
values: Record<string, string>;
onChange: (key: string, value: string) => void;
autoFilledKeys: Set<string>;
};
const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys }: TaskInputFormProps) => {
const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
if (configurableInputs.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
{configurableInputs.map(([key, def]) => {
if (def.type === 'boolean') {
const isTrue = values[key] === 'true';
return (
<div key={key} className="flex items-center justify-between gap-4">
<div className="min-w-0">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
</div>
<div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
<button
onClick={() => onChange(key, 'false')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${!isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
>
No
</button>
<button
onClick={() => onChange(key, 'true')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
>
Yes
</button>
</div>
</div>
);
}
// Default: text input for string/number
return (
<div key={key} className="flex flex-col gap-1">
<label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">{def.description ?? key}</label>
<input
type={def.type === 'number' ? 'number' : 'text'}
value={values[key] ?? ''}
onChange={(e) => onChange(key, e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal"
/>
</div>
);
})}
</div>
);
};
// ── Script-mode runner ──
type ScriptRunnerProps = {
taskDirName: string;
inputs: Record<string, string>;
autoInputs: Record<string, string>;
cwd?: string;
};
const ScriptRunner = ({ taskDirName, inputs, cwd }: ScriptRunnerProps) => {
const ScriptRunner = ({ taskDirName, autoInputs, cwd }: ScriptRunnerProps) => {
const runner = useTaskRunner();
const client = useClient();
const bottomRef = useRef<HTMLDivElement | null>(null);
const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
const [formValues, setFormValues] = useState<Record<string, string>>({});
// Fetch task detail to get input definitions
useEffect(() => {
client.get<{ inputs?: Record<string, TaskInputDef> }>(`/tasks/${taskDirName}`).then((task) => {
const defs = task.inputs ?? {};
setInputDefs(defs);
// Initialize form values from defaults
const defaults: Record<string, string> = {};
for (const [key, def] of Object.entries(defs)) {
if (def.default !== undefined) defaults[key] = def.default;
}
setFormValues(defaults);
});
}, [taskDirName]);
const autoFilledKeys = new Set(Object.keys(autoInputs));
const handleInputChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
};
// Auto-scroll
useEffect(() => {
@@ -217,21 +305,40 @@ const ScriptRunner = ({ taskDirName, inputs, cwd }: ScriptRunnerProps) => {
prevPhaseRef.current = runner.phase;
}, [runner.phase]);
const [copied, setCopied] = useState(false);
const handleCopy = () => {
const text = runner.output.map((line) => line.text).join('');
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleRun = () => {
runner.run(taskDirName, inputs, cwd);
const allInputs = { ...formValues, ...autoInputs };
runner.run(taskDirName, allInputs, cwd);
};
if (runner.phase === 'ready') {
return (
<div className="flex-1 flex items-center justify-center">
<button
onClick={handleRun}
disabled={!runner.isConnected}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Play className="h-4 w-4" />
Run
</button>
<div className="flex-1 flex flex-col">
{inputDefs && (
<TaskInputForm
inputDefs={inputDefs}
values={formValues}
onChange={handleInputChange}
autoFilledKeys={autoFilledKeys}
/>
)}
<div className="flex-1 flex items-center justify-center">
<button
onClick={handleRun}
disabled={!runner.isConnected || !inputDefs}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Play className="h-4 w-4" />
Run
</button>
</div>
</div>
);
}
@@ -239,22 +346,33 @@ const ScriptRunner = ({ taskDirName, inputs, cwd }: ScriptRunnerProps) => {
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{runner.output.map((line, i) => (
<span
key={i}
className={
line.stream === 'stderr'
? 'text-red-400'
: line.stream === 'system'
? 'text-duck-teal/70'
: 'text-foreground'
}
>
{line.text}
</span>
))}
</pre>
<div className="relative group">
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 text-white/40 hover:text-white hover:bg-white/20 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer z-10"
title="Copy output"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-[#0d1117] text-gray-200 rounded-lg p-4 pr-10">
<code>
{runner.output.map((line, i) => (
<span
key={i}
className={
line.stream === 'stderr'
? 'text-red-400'
: line.stream === 'system'
? 'text-duck-teal/70'
: 'text-foreground'
}
>
{line.text}
</span>
))}
</code>
</pre>
</div>
<div ref={bottomRef} />
</div>
<div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
@@ -308,9 +426,9 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
: `Execute the task "${task.name}" (${task.dirName})`);
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
// Script mode inputs — for now, map the entry path to file_path
const scriptInputs: Record<string, string> = {};
if (entryFullPath) scriptInputs.file_path = entryFullPath;
// Script mode: auto-filled inputs from context (e.g. file_path from file browser)
const autoInputs: Record<string, string> = {};
if (entryFullPath) autoInputs.file_path = entryFullPath;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -342,7 +460,7 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
<ScriptRunner
key="script"
taskDirName={task.dirName}
inputs={scriptInputs}
autoInputs={autoInputs}
cwd={cwd.path || undefined}
/>
) : (