Skip to main content

kitchen_shell/
scripts.rs

1//! The Scripts workspace: one space to write the app's custom commands and pipeline steps, in
2//! Python or Rust. Scripts tree on the left, the editor (kitchen-ui's `CodeView`: highlighting,
3//! errors on their lines) in the middle, the run panel (output, the result, errors) under it.
4//!
5//! A script runs against what is in front of the user: it reads one JSON object on its stdin,
6//! `{"args": …, "context": …}` (the context is what the app says is shown: the image, the
7//! selection, the table), and may print its result as JSON on its last line of output.
8//!
9//! A few comment lines at the top say what a script is ([`ScriptMeta`]):
10//!
11//! ```text
12//! # kitchen: command = Count cells
13//! # kitchen: hint = Counts the cells of the shown image
14//! # kitchen: args = {"type": "object", "properties": {"min_area": {"type": "number"}}}
15//! # kitchen: node = in image:image; out counts:table
16//! ```
17//!
18//! (`//` in Rust.) With `command`, the script becomes a command ([`ScriptsWorkspace::register`]):
19//! in the menus, the search and an agent's tools, its arguments the schema given. With `node`, it
20//! becomes a pipeline node ([`ScriptsWorkspace::nodes`]) with typed inputs and outputs, whose
21//! types are the app's ([`ScriptsWorkspace::new`] takes them). Running a script is a job with a
22//! cancel button; what it ran is reported ([`ScriptsEvent::Finished`]) for the app to record in
23//! its session.
24
25use std::path::{Path, PathBuf};
26use std::process::{Command, Stdio};
27use std::rc::Rc;
28use std::sync::Arc;
29
30use gpui_kit::assets::IconName;
31use gpui_kit::component::button::{Button, ButtonVariants as _};
32use gpui_kit::component::{Disableable as _, Selectable as _, Sizable as _, h_flex, v_flex};
33use gpui_kit::prelude::FluentBuilder as _;
34use gpui_kit::{
35    App, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
36    StatefulInteractiveElement as _, Styled as _, Subscription, WeakEntity, Window, div, px,
37};
38use kitchen_ui::theme::{self, color};
39use kitchen_ui::widgets::{CodeChanged, CodeLanguage, CodeView, EmptyState};
40use serde_json::{Value, json};
41
42use crate::command::{CommandSpec, Registry};
43use crate::jobs;
44
45/// A typed port of a script node.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct Port {
48    /// Its name (`image`).
49    pub name: String,
50    /// Its type, one the app declares (`image`, `mask`, `labels`, `table`; `signal`, `events`).
51    pub ty: String,
52}
53
54/// What a script's header says it is.
55#[derive(Clone, Debug, Default, PartialEq)]
56pub struct ScriptMeta {
57    /// As a command: its title.
58    pub command: Option<String>,
59    /// What it does.
60    pub hint: Option<String>,
61    /// The JSON Schema of its arguments (as a command).
62    pub args: Option<Value>,
63    /// As a pipeline node: its inputs and outputs.
64    pub node: Option<(Vec<Port>, Vec<Port>)>,
65    /// What in the header did not read (line, why).
66    pub problems: Vec<(usize, String)>,
67}
68
69impl ScriptMeta {
70    /// Reads the `kitchen:` lines at the top of `text` (`#` or `//` comments, up to the first
71    /// line that is neither a comment nor blank).
72    pub fn parse(text: &str) -> ScriptMeta {
73        let mut m = ScriptMeta::default();
74        for (i, line) in text.lines().enumerate() {
75            let t = line.trim();
76            if t.is_empty() || t.starts_with("#!") {
77                continue;
78            }
79            let Some(comment) = t.strip_prefix('#').or_else(|| t.strip_prefix("//")) else { break };
80            let Some(rest) = comment.trim().strip_prefix("kitchen:") else { continue };
81            let Some((key, value)) = rest.split_once('=') else {
82                m.problems.push((i + 1, "expected `kitchen: key = value`".into()));
83                continue;
84            };
85            let value = value.trim();
86            match key.trim() {
87                "command" => m.command = Some(value.to_string()),
88                "hint" => m.hint = Some(value.to_string()),
89                "args" => match serde_json::from_str::<Value>(value) {
90                    Ok(v) if v.is_object() => m.args = Some(v),
91                    Ok(_) => m.problems.push((i + 1, "args: a JSON Schema object".into())),
92                    Err(e) => m.problems.push((i + 1, format!("args: {e}"))),
93                },
94                "node" => match parse_node(value) {
95                    Ok(n) => m.node = Some(n),
96                    Err(e) => m.problems.push((i + 1, format!("node: {e}"))),
97                },
98                other => m.problems.push((i + 1, format!("unknown key {other:?} (command, hint, args, node)"))),
99            }
100        }
101        m
102    }
103}
104
105/// `in image:image, mask:mask; out counts:table`.
106fn parse_node(text: &str) -> Result<(Vec<Port>, Vec<Port>), String> {
107    let ports = |list: &str| -> Result<Vec<Port>, String> {
108        list.split(',').map(str::trim).filter(|p| !p.is_empty()).map(|p| match p.split_once(':') {
109            Some((n, t)) if !n.trim().is_empty() && !t.trim().is_empty() => Ok(Port { name: n.trim().into(), ty: t.trim().into() }),
110            _ => Err(format!("{p:?}: expected name:type")),
111        }).collect()
112    };
113    let (mut inputs, mut outputs) = (Vec::new(), Vec::new());
114    for part in text.split(';').map(str::trim).filter(|p| !p.is_empty()) {
115        if let Some(l) = part.strip_prefix("in ") {
116            inputs = ports(l)?;
117        } else if let Some(l) = part.strip_prefix("out ") {
118            outputs = ports(l)?;
119        } else {
120            return Err(format!("{part:?}: expected `in …` or `out …`"));
121        }
122    }
123    if outputs.is_empty() {
124        return Err("a node needs at least one output".into());
125    }
126    Ok((inputs, outputs))
127}
128
129/// A script file.
130#[derive(Clone, Debug, PartialEq)]
131pub struct Script {
132    /// Its file.
133    pub path: PathBuf,
134    /// Python or Rust.
135    pub language: CodeLanguage,
136    /// What its header says.
137    pub meta: ScriptMeta,
138}
139
140impl Script {
141    /// The script at `path` (`.py` or `.rs`), its header read.
142    pub fn load(path: &Path) -> Option<Script> {
143        let language = CodeLanguage::from_name(path.extension()?.to_str()?);
144        if !matches!(language, CodeLanguage::Python | CodeLanguage::Rust) {
145            return None;
146        }
147        let text = std::fs::read_to_string(path).ok()?;
148        Some(Script { path: path.to_path_buf(), language, meta: ScriptMeta::parse(&text) })
149    }
150
151    /// Its name (the file's stem).
152    pub fn name(&self) -> String {
153        self.path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
154    }
155
156    /// Its command id, when it is a command (`script.count_cells`).
157    pub fn command_id(&self) -> Option<String> {
158        self.meta.command.as_ref().map(|_| format!("script.{}", self.name()))
159    }
160}
161
162/// What a run of a script produced.
163#[derive(Clone, Debug, Default, PartialEq)]
164pub struct RunOutput {
165    /// Whether it exited well.
166    pub ok: bool,
167    /// What it printed (stdout, then stderr).
168    pub stdout: String,
169    /// Its error output.
170    pub stderr: String,
171    /// Its result: the last line of its output, when that is JSON.
172    pub result: Option<Value>,
173    /// Errors on lines of the script (Python tracebacks, Rust compiler messages): line, message.
174    pub errors: Vec<(usize, String)>,
175}
176
177/// Runs a script with its input (blocking; called off the UI thread).
178pub type Runner = Arc<dyn Fn(&Script, &Value) -> Result<RunOutput, String> + Send + Sync>;
179
180/// The runner used by default: Python with `python3`; Rust with `rust-script` (install it with
181/// `cargo install rust-script`). The input goes to the script's stdin.
182pub fn default_runner() -> Runner {
183    Arc::new(|script: &Script, input: &Value| {
184        let mut cmd = match script.language {
185            CodeLanguage::Python => {
186                let mut c = Command::new(if cfg!(windows) { "python" } else { "python3" });
187                c.arg("-u").arg(&script.path);
188                c
189            }
190            CodeLanguage::Rust => {
191                let mut c = Command::new("rust-script");
192                c.arg(&script.path);
193                c
194            }
195            other => return Err(format!("{} scripts do not run", other.name())),
196        };
197        let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| match script.language {
198            CodeLanguage::Rust => format!("Rust scripts run with rust-script ({e}): cargo install rust-script"),
199            _ => format!("python3: {e}"),
200        })?;
201        if let Some(mut stdin) = child.stdin.take() {
202            use std::io::Write as _;
203            let _ = writeln!(stdin, "{input}");
204        }
205        let out = child.wait_with_output().map_err(|e| e.to_string())?;
206        let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned());
207        let result = stdout.lines().rev().find(|l| !l.trim().is_empty()).and_then(|l| serde_json::from_str::<Value>(l.trim()).ok()).filter(|v| v.is_object() || v.is_array());
208        let errors = script_errors(script, &stderr);
209        Ok(RunOutput { ok: out.status.success(), stdout, stderr, result, errors })
210    })
211}
212
213/// Errors on the script's own lines in `stderr`: a Python traceback's last frame in the script
214/// (`File "…/count.py", line 7`) with its last line, or a Rust message (`--> count.rs:7:5`).
215pub fn script_errors(script: &Script, stderr: &str) -> Vec<(usize, String)> {
216    let file = script.path.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
217    let last = stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
218    let mut out = Vec::new();
219    match script.language {
220        CodeLanguage::Python => {
221            let line = stderr
222                .lines()
223                .filter(|l| l.contains("File \"") && l.contains(&file))
224                .filter_map(|l| l.split(", line ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::<usize>().ok())
225                .last();
226            if let Some(n) = line {
227                out.push((n, last));
228            }
229        }
230        CodeLanguage::Rust => {
231            let mut message = String::new();
232            for l in stderr.lines() {
233                if l.starts_with("error") {
234                    message = l.trim().to_string();
235                } else if let Some(at) = l.trim().strip_prefix("--> ")
236                    && at.contains(&file)
237                    && let Some(n) = at.split(':').nth(1).and_then(|n| n.parse::<usize>().ok())
238                {
239                    out.push((n, message.clone()));
240                }
241            }
242        }
243        _ => {}
244    }
245    out
246}
247
248/// A script as a pipeline node: typed ports, run by its script.
249#[derive(Clone, Debug, PartialEq)]
250pub struct NodeSpec {
251    /// Its id (`script.<name>`).
252    pub id: String,
253    /// Its title (the command title, else the file name).
254    pub title: String,
255    /// Its inputs.
256    pub inputs: Vec<Port>,
257    /// Its outputs.
258    pub outputs: Vec<Port>,
259    /// The script that runs it.
260    pub script: PathBuf,
261}
262
263/// What the workspace tells its owner.
264#[derive(Clone, Debug, PartialEq)]
265pub enum ScriptsEvent {
266    /// A script ran (record it in the session).
267    Finished {
268        /// The script.
269        script: PathBuf,
270        /// Its input.
271        input: Value,
272        /// What it produced.
273        output: RunOutput,
274    },
275    /// Scripts were added, saved or removed: commands and nodes may have changed.
276    ScriptsChanged,
277}
278
279/// Gives the script the context: what is shown now.
280pub type ContextFn = Rc<dyn Fn(&App) -> Value>;
281
282/// The Scripts workspace.
283pub struct ScriptsWorkspace {
284    dir: PathBuf,
285    port_types: Vec<String>,
286    runner: Runner,
287    context: ContextFn,
288    scripts: Vec<Script>,
289    selected: Option<usize>,
290    editor: Option<(Entity<CodeView>, Subscription)>,
291    /// The editor's text differs from the file.
292    dirty: bool,
293    running: Option<u64>,
294    output: Option<RunOutput>,
295    message: Option<(bool, String)>,
296    registered: Vec<String>,
297}
298
299impl EventEmitter<ScriptsEvent> for ScriptsWorkspace {}
300
301impl ScriptsWorkspace {
302    /// The scripts in `dir` (created when missing); `port_types` are the types a node may use,
303    /// `context` what a script sees as shown.
304    pub fn new(dir: impl Into<PathBuf>, port_types: Vec<String>, context: impl Fn(&App) -> Value + 'static, window: &mut Window, cx: &mut Context<Self>) -> Self {
305        let dir = dir.into();
306        let _ = std::fs::create_dir_all(&dir);
307        let mut ws = Self {
308            dir,
309            port_types,
310            runner: default_runner(),
311            context: Rc::new(context),
312            scripts: Vec::new(),
313            selected: None,
314            editor: None,
315            dirty: false,
316            running: None,
317            output: None,
318            message: None,
319            registered: Vec::new(),
320        };
321        ws.rescan(cx);
322        if !ws.scripts.is_empty() {
323            ws.select(0, window, cx);
324        }
325        ws
326    }
327
328    /// Runs scripts with `runner` instead of the default.
329    pub fn set_runner(&mut self, runner: Runner) {
330        self.runner = runner;
331    }
332
333    /// The scripts found.
334    pub fn scripts(&self) -> &[Script] {
335        &self.scripts
336    }
337
338    /// The selected script.
339    pub fn selected(&self) -> Option<&Script> {
340        self.selected.and_then(|i| self.scripts.get(i))
341    }
342
343    /// The last run's output.
344    pub fn output(&self) -> Option<&RunOutput> {
345        self.output.as_ref()
346    }
347
348    /// Whether a script runs now.
349    pub fn is_running(&self) -> bool {
350        self.running.is_some()
351    }
352
353    /// The editor, when a script is open.
354    pub fn editor(&self) -> Option<Entity<CodeView>> {
355        self.editor.as_ref().map(|(e, _)| e.clone())
356    }
357
358    /// Reads the folder again.
359    pub fn rescan(&mut self, cx: &mut Context<Self>) {
360        let selected = self.selected().map(|s| s.path.clone());
361        let mut scripts: Vec<Script> = std::fs::read_dir(&self.dir).into_iter().flatten().flatten().filter_map(|e| Script::load(&e.path())).collect();
362        scripts.sort_by(|a, b| a.path.cmp(&b.path));
363        self.selected = selected.and_then(|p| scripts.iter().position(|s| s.path == p));
364        self.scripts = scripts;
365        cx.emit(ScriptsEvent::ScriptsChanged);
366        cx.notify();
367    }
368
369    /// Opens script `i` in the editor.
370    pub fn select(&mut self, i: usize, window: &mut Window, cx: &mut Context<Self>) {
371        let Some(s) = self.scripts.get(i) else { return };
372        let text = std::fs::read_to_string(&s.path).unwrap_or_default();
373        let language = s.language;
374        let problems = s.meta.problems.clone();
375        let editor = cx.new(|cx| CodeView::new(language, text, window, cx).editable(true).errors(problems));
376        let sub = cx.subscribe(&editor, |this, _, _: &CodeChanged, cx| {
377            this.dirty = true;
378            cx.notify();
379        });
380        self.editor = Some((editor, sub));
381        self.selected = Some(i);
382        self.dirty = false;
383        self.output = None;
384        self.message = None;
385        cx.notify();
386    }
387
388    /// Writes the editor's text to the selected script, and reads its header again.
389    pub fn save(&mut self, cx: &mut Context<Self>) -> Result<(), String> {
390        let (Some(i), Some(editor)) = (self.selected, self.editor()) else { return Err("no script open".into()) };
391        let text = editor.read(cx).text(cx);
392        let path = self.scripts[i].path.clone();
393        std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
394        self.dirty = false;
395        self.rescan(cx);
396        let problems = self.selected().map(|s| s.meta.problems.clone()).unwrap_or_default();
397        editor.update(cx, |e, cx| e.set_errors(problems, cx));
398        self.message = Some((false, format!("Saved {}", path.display())));
399        Ok(())
400    }
401
402    /// A new script in `language`, from a template, opened.
403    pub fn new_script(&mut self, language: CodeLanguage, window: &mut Window, cx: &mut Context<Self>) -> Result<PathBuf, String> {
404        let ext = if language == CodeLanguage::Rust { "rs" } else { "py" };
405        let path = (1..1000).map(|n| self.dir.join(format!("script_{n}.{ext}"))).find(|p| !p.exists()).ok_or("too many scripts")?;
406        let text = match language {
407            CodeLanguage::Rust => "// kitchen: command = My command\n// kitchen: hint = What it does\n\nuse std::io::Read;\n\nfn main() {\n    let mut input = String::new();\n    std::io::stdin().read_to_string(&mut input).unwrap();\n    println!(\"{{\\\"ok\\\": true}}\");\n}\n",
408            _ => "# kitchen: command = My command\n# kitchen: hint = What it does\nimport json, sys\n\ninput = json.load(sys.stdin)\ncontext = input[\"context\"]\nprint(json.dumps({\"ok\": True}))\n",
409        };
410        std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
411        self.rescan(cx);
412        if let Some(i) = self.scripts.iter().position(|s| s.path == path) {
413            self.select(i, window, cx);
414        }
415        Ok(path)
416    }
417
418    /// Runs the selected script (saved first) with `args`, off the UI thread, as a job.
419    pub fn run_selected(&mut self, args: Value, cx: &mut Context<Self>) -> Result<(), String> {
420        if self.dirty {
421            self.save(cx)?;
422        }
423        let script = self.selected().cloned().ok_or("no script open")?;
424        self.run(script, args, cx)
425    }
426
427    /// Runs `script` with `args`, off the UI thread, as a job; the output shows in the run panel.
428    pub fn run(&mut self, script: Script, args: Value, cx: &mut Context<Self>) -> Result<(), String> {
429        if self.running.is_some() {
430            return Err("a script is running".into());
431        }
432        let job = jobs::start("script", &format!("Running {}", script.name()), false)?;
433        self.running = Some(job.id);
434        self.message = None;
435        let input = json!({ "args": args, "context": (self.context)(cx) });
436        let runner = self.runner.clone();
437        let (s2, i2) = (script.clone(), input.clone());
438        cx.spawn(async move |this, cx| {
439            let out = cx.background_executor().spawn(async move { runner(&s2, &i2) }).await;
440            let cancelled = job.cancelled();
441            drop(job);
442            let _ = this.update(cx, |w, cx| {
443                w.running = None;
444                match out {
445                    _ if cancelled => w.message = Some((true, "Stopped".into())),
446                    Ok(output) => {
447                        if let Some((editor, _)) = &w.editor
448                            && w.selected().is_some_and(|s| s.path == script.path)
449                        {
450                            let errors = output.errors.clone();
451                            editor.clone().update(cx, |e, cx| e.set_errors(errors, cx));
452                        }
453                        cx.emit(ScriptsEvent::Finished { script: script.path.clone(), input, output: output.clone() });
454                        w.output = Some(output);
455                    }
456                    Err(e) => w.message = Some((true, e)),
457                }
458                cx.notify();
459            });
460        })
461        .detach();
462        cx.notify();
463        Ok(())
464    }
465
466    /// The scripts that are pipeline nodes, checked against the app's port types (a node naming
467    /// another type is left out, with the reason in the second list).
468    pub fn nodes(&self) -> (Vec<NodeSpec>, Vec<String>) {
469        let (mut ok, mut bad) = (Vec::new(), Vec::new());
470        for s in &self.scripts {
471            let Some((inputs, outputs)) = &s.meta.node else { continue };
472            let unknown: Vec<&str> = inputs.iter().chain(outputs).map(|p| p.ty.as_str()).filter(|t| !self.port_types.iter().any(|k| k == t)).collect();
473            if unknown.is_empty() {
474                ok.push(NodeSpec { id: format!("script.{}", s.name()), title: s.meta.command.clone().unwrap_or_else(|| s.name()), inputs: inputs.clone(), outputs: outputs.clone(), script: s.path.clone() });
475            } else {
476                bad.push(format!("{}: unknown port type {} (this app has {})", s.name(), unknown.join(", "), self.port_types.join(", ")));
477            }
478        }
479        (ok, bad)
480    }
481
482    /// Registers each command script in `registry` (its arguments the schema its header gives;
483    /// running it starts it as a job and answers at once), replacing the ones registered before.
484    pub fn register(&mut self, registry: &Registry, me: WeakEntity<Self>) {
485        for id in self.registered.drain(..) {
486            registry.remove(&id);
487        }
488        for s in &self.scripts {
489            let (Some(id), Some(title)) = (s.command_id(), s.meta.command.clone()) else { continue };
490            let (me1, script) = (me.clone(), s.clone());
491            let mut c = CommandSpec::raw(
492                id.clone(),
493                title,
494                if s.language == CodeLanguage::Rust { IconName::FileCode } else { IconName::FileText },
495                s.meta.args.clone(),
496                Rc::new(move |args, _, cx| {
497                    let started = id_of(&script);
498                    let script = script.clone();
499                    me1.update(cx, |w, cx| w.run(script, args, cx)).map_err(|e| e.to_string())??;
500                    Ok(json!({ "started": started }))
501                }),
502            )
503            .group("Scripts")
504            .hint(s.meta.hint.clone().unwrap_or_else(|| format!("Runs {}", s.path.display())));
505            let me2 = me.clone();
506            c = c.enabled(move |cx| match me2.upgrade().map(|w| w.read(cx).is_running()) {
507                Some(false) => Ok(()),
508                Some(true) => Err("A script is running".into()),
509                None => Err("The Scripts workspace is closed".into()),
510            });
511            registry.add(c);
512            self.registered.push(id);
513        }
514    }
515}
516
517fn id_of(s: &Script) -> String {
518    s.command_id().unwrap_or_else(|| s.name())
519}
520
521impl Render for ScriptsWorkspace {
522    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
523        let tree = v_flex()
524            .id("scripts-tree")
525            .w(px(240.))
526            .flex_none()
527            .h_full()
528            .overflow_y_scroll()
529            .p(px(theme::SM))
530            .gap(px(2.))
531            .border_r_1()
532            .border_color(color::border())
533            .child(
534                h_flex()
535                    .gap(px(theme::XS))
536                    .pb(px(theme::SM))
537                    .child(Button::new("scripts-new-python").small().outline().icon(IconName::Plus).label("Python").tooltip("A new Python script").on_click(cx.listener(|w, _, window, cx| {
538                        if let Err(e) = w.new_script(CodeLanguage::Python, window, cx) {
539                            w.message = Some((true, e));
540                        }
541                    })))
542                    .child(Button::new("scripts-new-rust").small().outline().icon(IconName::Plus).label("Rust").tooltip("A new Rust script (runs with rust-script)").on_click(cx.listener(|w, _, window, cx| {
543                        if let Err(e) = w.new_script(CodeLanguage::Rust, window, cx) {
544                            w.message = Some((true, e));
545                        }
546                    }))),
547            )
548            .children(self.scripts.iter().enumerate().map(|(i, s)| {
549                let kind = match (&s.meta.command, &s.meta.node) {
550                    (Some(_), Some(_)) => "command · node",
551                    (Some(_), None) => "command",
552                    (None, Some(_)) => "node",
553                    (None, None) => "script",
554                };
555                Button::new(SharedString::from(format!("script-{}", s.name())))
556                    .ghost()
557                    .small()
558                    .w_full()
559                    .justify_start()
560                    .icon(if s.language == CodeLanguage::Rust { IconName::FileCode } else { IconName::FileText })
561                    .child(h_flex().flex_1().min_w_0().gap(px(theme::SM)).child(div().flex_1().min_w_0().truncate().child(s.name())).child(div().text_xs().text_color(color::text_muted()).child(kind)))
562                    .selected(self.selected == Some(i))
563                    .on_click(cx.listener(move |w, _, window, cx| w.select(i, window, cx)))
564            }));
565        let running = self.running.is_some();
566        let toolbar = self.selected().map(|s| {
567            let what = match (&s.meta.command, &s.meta.node) {
568                (Some(c), Some((i, o))) => format!("Command \"{c}\" · node {} → {}", ports(i), ports(o)),
569                (Some(c), None) => format!("Command \"{c}\""),
570                (None, Some((i, o))) => format!("Node {} → {}", ports(i), ports(o)),
571                (None, None) => "A plain script (add `kitchen: command = …` to make it a command)".into(),
572            };
573            h_flex()
574                .flex_none()
575                .gap(px(theme::SM))
576                .px(px(theme::MD))
577                .py(px(theme::SM))
578                .items_center()
579                .border_b_1()
580                .border_color(color::border())
581                .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(format!("{}{}", s.name(), if self.dirty { " •" } else { "" })))
582                .child(div().flex_1().min_w_0().truncate().text_xs().text_color(color::text_muted()).child(what))
583                .child(Button::new("scripts-save").small().outline().icon(IconName::Save).label("Save").disabled(!self.dirty).on_click(cx.listener(|w, _, _, cx| {
584                    if let Err(e) = w.save(cx) {
585                        w.message = Some((true, e));
586                    }
587                })))
588                .child(Button::new("scripts-run").small().primary().icon(IconName::Play).label(if running { "Running…" } else { "Run" }).disabled(running).on_click(cx.listener(|w, _, _, cx| {
589                    if let Err(e) = w.run_selected(Value::Null, cx) {
590                        w.message = Some((true, e));
591                    }
592                })))
593        });
594        let run_panel = v_flex()
595            .id("scripts-output")
596            .flex_none()
597            .h(px(200.))
598            .overflow_y_scroll()
599            .p(px(theme::MD))
600            .gap(px(theme::XS))
601            .border_t_1()
602            .border_color(color::border())
603            .bg(color::raised())
604            .child(div().text_xs().font_weight(FontWeight::SEMIBOLD).text_color(color::text_muted()).child("RUN"))
605            .children(self.message.clone().map(|(err, m)| div().text_sm().text_color(if err { color::danger() } else { color::text_muted() }).child(m)))
606            .when(running, |d| d.child(div().text_sm().text_color(color::accent_mark()).child("Running…")))
607            .children(self.output.clone().map(|o| {
608                v_flex()
609                    .gap(px(theme::XS))
610                    .child(div().text_sm().text_color(if o.ok { color::success() } else { color::danger() }).child(if o.ok { "Finished" } else { "Failed" }))
611                    .children(o.result.map(|r| div().text_sm().font_family("monospace").child(format!("Result: {r}"))))
612                    .children(o.stdout.lines().map(|l| div().text_sm().font_family("monospace").child(l.to_string())).collect::<Vec<_>>())
613                    .children(o.stderr.lines().map(|l| div().text_sm().font_family("monospace").text_color(color::danger()).child(l.to_string())).collect::<Vec<_>>())
614            }));
615        let main = match &self.editor {
616            Some((editor, _)) => v_flex().flex_1().min_w_0().h_full().children(toolbar).child(div().flex_1().min_h_0().child(editor.clone())).child(run_panel).into_any_element(),
617            None => div()
618                .flex_1()
619                .child(EmptyState::new(IconName::SquareTerminal, "No script", format!("Scripts in {} become commands and pipeline nodes. Start one with Python or Rust.", self.dir.display())))
620                .into_any_element(),
621        };
622        h_flex().size_full().items_start().child(tree).child(main)
623    }
624}
625
626fn ports(p: &[Port]) -> String {
627    if p.is_empty() { "nothing".into() } else { p.iter().map(|p| format!("{}:{}", p.name, p.ty)).collect::<Vec<_>>().join(", ") }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn headers_say_what_a_script_is() {
636        let m = ScriptMeta::parse(
637            "#!/usr/bin/env python3\n# kitchen: command = Count cells\n# kitchen: hint = Counts them\n# kitchen: args = {\"type\": \"object\"}\n# kitchen: node = in image:image, mask:mask; out counts:table\nimport json\n# kitchen: command = ignored after code\n",
638        );
639        assert_eq!(m.command.as_deref(), Some("Count cells"));
640        assert_eq!(m.hint.as_deref(), Some("Counts them"));
641        assert_eq!(m.args, Some(json!({"type": "object"})));
642        let (i, o) = m.node.unwrap();
643        assert_eq!(i, vec![Port { name: "image".into(), ty: "image".into() }, Port { name: "mask".into(), ty: "mask".into() }]);
644        assert_eq!(o, vec![Port { name: "counts".into(), ty: "table".into() }]);
645        assert!(m.problems.is_empty());
646
647        let bad = ScriptMeta::parse("// kitchen: args = [1]\n// kitchen: node = in a:image\n// kitchen: colour = red\n// kitchen: oops\nfn main() {}\n");
648        let lines: Vec<usize> = bad.problems.iter().map(|p| p.0).collect();
649        assert_eq!(lines, vec![1, 2, 3, 4]);
650    }
651
652    #[test]
653    fn errors_point_at_the_scripts_lines() {
654        let py = Script { path: "/x/count.py".into(), language: CodeLanguage::Python, meta: ScriptMeta::default() };
655        let tb = "Traceback (most recent call last):\n  File \"/x/count.py\", line 7, in <module>\n    main()\n  File \"/x/count.py\", line 4, in main\n    1/0\nZeroDivisionError: division by zero\n";
656        assert_eq!(script_errors(&py, tb), vec![(4, "ZeroDivisionError: division by zero".to_string())]);
657        let rs = Script { path: "/x/count.rs".into(), language: CodeLanguage::Rust, meta: ScriptMeta::default() };
658        let msg = "error[E0308]: mismatched types\n --> /x/count.rs:6:13\n";
659        assert_eq!(script_errors(&rs, msg), vec![(6, "error[E0308]: mismatched types".to_string())]);
660    }
661}