From 5ebdeff76d946bbb362d0811be7f8f9bd45c9400 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 27 Apr 2026 16:22:06 +0900 Subject: [PATCH] =?UTF-8?q?tui=E3=81=8B=E3=82=89Spawn=E3=81=99=E3=82=8B?= =?UTF-8?q?=E4=BB=AEUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 + TODO.md | 2 +- crates/pod/src/main.rs | 10 +- crates/tui/Cargo.toml | 4 +- crates/tui/src/main.rs | 115 ++++-- crates/tui/src/spawn.rs | 684 ++++++++++++++++++++++++++++++++++++ tickets/tui-pod-spawn-ui.md | 110 ++++-- 7 files changed, 866 insertions(+), 61 deletions(-) create mode 100644 crates/tui/src/spawn.rs diff --git a/Cargo.lock b/Cargo.lock index 141e5007..d0cf04fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3570,10 +3570,12 @@ name = "tui" version = "0.1.0" dependencies = [ "crossterm 0.28.1", + "manifest", "protocol", "ratatui", "serde_json", "tokio", + "toml", "unicode-width", "uuid", ] diff --git a/TODO.md b/TODO.md index 99141acb..c1f0731b 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) - [ ] TUI 拡充 - [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md) - - [ ] 新しい Pod を spawn する UI の設計 → [tickets/tui-pod-spawn-ui.md](tickets/tui-pod-spawn-ui.md) + - [ ] inline viewport で Pod を spawn する UX → [tickets/tui-pod-spawn-ui.md](tickets/tui-pod-spawn-ui.md) - [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md) - [ ] サブミット入力 - [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md) diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index 22428c03..359b5ce5 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -167,10 +167,16 @@ async fn main() -> ExitCode { } }; + let socket_path = handle.runtime_dir.socket_path(); + // Machine-readable ready line for parents that spawned this Pod + // (e.g. the TUI's interactive `spawn` flow). Tab-separated so a + // pod name with spaces still parses cleanly. Emit before the + // human line so a stderr-watching parent sees it first. eprintln!( - "pod: {pod_name} listening on {:?}", - handle.runtime_dir.socket_path() + "INSOMNIA-READY\t{pod_name}\t{}", + socket_path.display() ); + eprintln!("pod: {pod_name} listening on {:?}", socket_path); tokio::select! { _ = tokio::signal::ctrl_c() => { diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 285003c3..fa59712e 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -8,7 +8,9 @@ license.workspace = true protocol = { path = "../protocol" } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } crossterm = "0.28" -tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } +tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] } serde_json = "1.0" unicode-width = "0.2.2" uuid = "1.23" +toml = "1.1.2" +manifest = { version = "0.1.0", path = "../manifest" } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 356a4a2e..02650568 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4,11 +4,13 @@ mod cache; mod client; mod input; mod scroll; +mod spawn; mod tool; mod ui; use std::io; use std::path::PathBuf; +use std::time::Duration; use crossterm::event::{ self, DisableBracketedPaste, EnableBracketedPaste, Event as TermEvent, KeyCode, KeyEvent, @@ -24,6 +26,7 @@ use ratatui::backend::CrosstermBackend; use crate::app::App; use crate::client::PodClient; +use crate::spawn::{SpawnOutcome, SpawnReady}; fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { if let Some(p) = override_path { @@ -48,49 +51,109 @@ fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { } } -fn parse_args() -> (String, Option) { - let args: Vec = std::env::args().collect(); - if args.len() < 2 { - eprintln!("usage: tui [--socket ]"); - std::process::exit(1); +enum Mode { + Spawn, + Attach { + pod_name: String, + socket_override: Option, + }, +} + +fn parse_args() -> Mode { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + return Mode::Spawn; } - let pod_name = args[1].clone(); - let socket = args + let pod_name = args[0].clone(); + let socket_override = args .windows(2) .find(|w| w[0] == "--socket") .map(|w| PathBuf::from(&w[1])); - (pod_name, socket) + Mode::Attach { + pod_name, + socket_override, + } } #[tokio::main] async fn main() -> Result<(), Box> { - let (pod_name, socket_override) = parse_args(); - let socket_path = resolve_socket(&pod_name, socket_override); + let mode = parse_args(); enable_raw_mode()?; + execute!(io::stdout(), EnableBracketedPaste)?; + + let result = match mode { + Mode::Spawn => run_spawn().await, + Mode::Attach { + pod_name, + socket_override, + } => run_attach(pod_name, socket_override).await, + }; + + // Always restore the terminal, even on error or panic-after-result. let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = run(&mut terminal, pod_name, &socket_path).await; - - // Always restore the terminal, even on error. - let _ = execute!( - terminal.backend_mut(), - DisableBracketedPaste, - LeaveAlternateScreen - ); + let _ = execute!(stdout, LeaveAlternateScreen, DisableBracketedPaste); let _ = disable_raw_mode(); - terminal.show_cursor().ok(); + let _ = execute!(stdout, crossterm::cursor::Show); result } +async fn run_attach( + pod_name: String, + socket_override: Option, +) -> Result<(), Box> { + let socket_path = resolve_socket(&pod_name, socket_override); + let mut terminal = enter_fullscreen()?; + run(&mut terminal, pod_name, &socket_path, false).await +} + +async fn run_spawn() -> Result<(), Box> { + let ready = match spawn::run().await? { + SpawnOutcome::Ready(r) => r, + SpawnOutcome::Cancelled => return Ok(()), + }; + + let SpawnReady { + pod_name, + socket_path, + mut child, + stderr_drain, + } = ready; + + let mut terminal = enter_fullscreen()?; + let result = run(&mut terminal, pod_name, &socket_path, true).await; + + // Leave alt-screen before reaping the child so any final pod stderr + // (drained off-line by `stderr_drain`) cannot collide with the + // restored scrollback. + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); + + match tokio::time::timeout(Duration::from_secs(3), child.wait()).await { + Ok(Ok(_)) => {} + _ => { + let _ = child.start_kill(); + let _ = child.wait().await; + } + } + stderr_drain.abort(); + + result +} + +fn enter_fullscreen() -> Result>, Box> +{ + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + async fn run( terminal: &mut Terminal>, pod_name: String, socket_path: &std::path::Path, + shutdown_pod_on_exit: bool, ) -> Result<(), Box> { let mut app = App::new(pod_name); @@ -98,7 +161,7 @@ async fn run( Ok(mut client) => { app.connected = true; let _ = client.send(&Method::GetHistory).await; - run_loop(terminal, &mut app, client).await?; + run_loop(terminal, &mut app, client, shutdown_pod_on_exit).await?; } Err(e) => { app.push_error(format!("Failed to connect to {}: {e}", socket_path.display())); @@ -113,11 +176,15 @@ async fn run_loop( terminal: &mut Terminal>, app: &mut App, mut client: PodClient, + shutdown_pod_on_exit: bool, ) -> Result<(), Box> { terminal.draw(|f| ui::draw(f, app))?; loop { if app.quit { + if shutdown_pod_on_exit { + let _ = client.send(&Method::Shutdown).await; + } break; } diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs new file mode 100644 index 00000000..95a608e5 --- /dev/null +++ b/crates/tui/src/spawn.rs @@ -0,0 +1,684 @@ +//! Inline-viewport "spawn Pod and attach" UX. +//! +//! Rendered at the user's current cursor position when `tui` is invoked +//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml` +//! to seed defaults, prompts for the Pod's name, and on confirmation +//! launches the `pod` binary as a subprocess with a freshly built +//! overlay (name + cwd scope when no project manifest exists). Once +//! the child reports its socket via the `INSOMNIA-READY` stderr line, +//! the dialog hands control back so main can switch the terminal to +//! alternate-screen mode. +//! +//! The viewport's last frame stays in the terminal's scrollback so the +//! user has a record of what was spawned (or why a spawn failed). + +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use crossterm::event::{ + self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers, +}; +use manifest::PodManifestConfig; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::{Frame, TerminalOptions, Viewport}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::task::JoinHandle; + +const READY_PREFIX: &str = "INSOMNIA-READY\t"; +const VIEWPORT_LINES: u16 = 6; +const READY_TIMEOUT: Duration = Duration::from_secs(20); + +pub struct SpawnReady { + pub pod_name: String, + pub socket_path: PathBuf, + pub child: Child, + pub stderr_drain: JoinHandle<()>, +} + +pub enum SpawnOutcome { + Ready(SpawnReady), + Cancelled, +} + +#[derive(Debug)] +pub enum SpawnError { + Io(io::Error), + PodLaunchFailed(io::Error), + PodExitedEarly { stderr_tail: String }, + Timeout, +} + +impl std::fmt::Display for SpawnError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "io error: {e}"), + Self::PodLaunchFailed(e) => write!(f, "failed to launch pod: {e}"), + Self::PodExitedEarly { stderr_tail } => { + if stderr_tail.is_empty() { + write!(f, "pod exited before becoming ready") + } else { + write!(f, "pod exited before becoming ready: {stderr_tail}") + } + } + Self::Timeout => write!( + f, + "pod did not become ready within {}s", + READY_TIMEOUT.as_secs() + ), + } + } +} + +impl std::error::Error for SpawnError {} + +impl From for SpawnError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +type InlineTerminal = Terminal>; + +pub async fn run() -> Result { + let cwd = std::env::current_dir().map_err(SpawnError::Io)?; + + // Run the same merge pod itself uses, then read what's missing + // off the result. We only look at `scope.allow` here — `pod.name` + // is intentionally an instance-level identifier and is always + // taken from the dialog regardless of what (if anything) a layer + // declared. + let user_layer = user_manifest_path().and_then(load_layer); + let project_layer = find_project_manifest(&cwd).and_then(load_layer); + + let mut cascade = PodManifestConfig::builtin_defaults(); + for layer in [user_layer.as_ref(), project_layer.as_ref()].into_iter().flatten() { + cascade = cascade.merge(layer.clone()); + } + let cascade_has_scope = !cascade.scope.allow.is_empty(); + + let scope_origin = match ( + project_layer + .as_ref() + .is_some_and(|l| !l.scope.allow.is_empty()), + user_layer + .as_ref() + .is_some_and(|l| !l.scope.allow.is_empty()), + ) { + (true, _) => ScopeOrigin::FromProject, + (false, true) => ScopeOrigin::FromUser, + (false, false) => ScopeOrigin::CwdDefault, + }; + + let default_name = cwd + .file_name() + .and_then(|s| s.to_str()) + .map(sanitise_default_name) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "pod".to_string()); + + let mut form = Form { + cwd: cwd.clone(), + cascade_has_scope, + scope_origin, + name_cursor: default_name.chars().count(), + name: default_name, + message: None, + }; + + let mut terminal = make_inline_terminal()?; + + // Phase 1: confirm / cancel. + loop { + terminal.draw(|f| draw_form(f, &form))?; + match poll_event()? { + None => continue, + Some(Action::Submit) => { + if form.name.trim().is_empty() { + form.message = + Some(("name is required".to_string(), MessageKind::Error)); + continue; + } + break; + } + Some(Action::Cancel) => { + form.message = Some(("cancelled".to_string(), MessageKind::Info)); + terminal.draw(|f| draw_form(f, &form))?; + drop(terminal); + return Ok(SpawnOutcome::Cancelled); + } + Some(Action::Char(c)) => form.insert_char(c), + Some(Action::Backspace) => form.backspace(), + Some(Action::Delete) => form.delete_forward(), + Some(Action::Left) => form.move_left(), + Some(Action::Right) => form.move_right(), + Some(Action::Home) => form.name_cursor = 0, + Some(Action::End) => form.name_cursor = form.name.chars().count(), + } + } + + let overlay_toml = build_overlay_toml(&form); + + // Phase 2: launch pod and wait for ready line. + form.message = Some(("starting pod...".to_string(), MessageKind::Progress)); + terminal.draw(|f| draw_form(f, &form))?; + + match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await { + Ok(ready) => { + form.message = Some(( + format!("ready: {} attaching...", ready.pod_name), + MessageKind::Ok, + )); + terminal.draw(|f| draw_form(f, &form))?; + drop(terminal); + Ok(SpawnOutcome::Ready(ready)) + } + Err(e) => { + form.message = Some((e.to_string(), MessageKind::Error)); + let _ = terminal.draw(|f| draw_form(f, &form)); + drop(terminal); + Err(e) + } + } +} + +fn make_inline_terminal() -> io::Result { + let backend = CrosstermBackend::new(io::stdout()); + Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(VIEWPORT_LINES), + }, + ) +} + +enum Action { + Submit, + Cancel, + Char(char), + Backspace, + Delete, + Left, + Right, + Home, + End, +} + +fn poll_event() -> io::Result> { + if !event::poll(Duration::from_millis(100))? { + return Ok(None); + } + match event::read()? { + TermEvent::Key(k) if k.kind != KeyEventKind::Release => { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + Ok(match k.code { + KeyCode::Enter => Some(Action::Submit), + KeyCode::Esc => Some(Action::Cancel), + KeyCode::Char('c') if ctrl => Some(Action::Cancel), + KeyCode::Char('a') if ctrl => Some(Action::Home), + KeyCode::Char('e') if ctrl => Some(Action::End), + KeyCode::Char('u') if ctrl => Some(Action::Cancel), + KeyCode::Backspace => Some(Action::Backspace), + KeyCode::Delete => Some(Action::Delete), + KeyCode::Left => Some(Action::Left), + KeyCode::Right => Some(Action::Right), + KeyCode::Home => Some(Action::Home), + KeyCode::End => Some(Action::End), + KeyCode::Char(c) if !ctrl && is_safe_name_char(c) => Some(Action::Char(c)), + _ => None, + }) + } + _ => Ok(None), + } +} + +fn is_safe_name_char(c: char) -> bool { + // Filesystem-safe; pod.name becomes a runtime-dir name. + c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') +} + +fn sanitise_default_name(s: &str) -> String { + s.chars() + .map(|c| if is_safe_name_char(c) { c } else { '-' }) + .collect() +} + +async fn wait_for_ready( + terminal: &mut InlineTerminal, + form: &mut Form, + overlay_toml: &str, +) -> Result { + let pod_bin = resolve_pod_command(); + let cwd = std::env::current_dir().map_err(SpawnError::Io)?; + + let mut child = Command::new(&pod_bin) + .arg("--overlay") + .arg(overlay_toml) + .current_dir(&cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(SpawnError::PodLaunchFailed)?; + + let stderr = child + .stderr + .take() + .expect("stderr is piped; take() must succeed"); + let mut reader = BufReader::new(stderr).lines(); + let mut tail = StderrTail::new(); + + let timeout = tokio::time::sleep(READY_TIMEOUT); + tokio::pin!(timeout); + + loop { + tokio::select! { + line = reader.next_line() => { + match line { + Ok(Some(line)) => { + if let Some(rest) = line.strip_prefix(READY_PREFIX) { + let mut parts = rest.splitn(2, '\t'); + let pod_name = parts.next().unwrap_or("").to_string(); + let socket_str = parts.next().unwrap_or("").to_string(); + if pod_name.is_empty() || socket_str.is_empty() { + return Err(SpawnError::PodExitedEarly { + stderr_tail: format!("malformed ready line: {line}"), + }); + } + let socket_path = PathBuf::from(socket_str); + + let stderr_drain = tokio::spawn(async move { + while let Ok(Some(_)) = reader.next_line().await {} + }); + + return Ok(SpawnReady { + pod_name, + socket_path, + child, + stderr_drain, + }); + } + tail.push(&line); + form.message = Some((line, MessageKind::Progress)); + let _ = terminal.draw(|f| draw_form(f, form)); + } + Ok(None) => { + let _ = child.wait().await; + return Err(SpawnError::PodExitedEarly { + stderr_tail: tail.into_string(), + }); + } + Err(e) => return Err(SpawnError::Io(e)), + } + } + status = child.wait() => { + let _ = status; + return Err(SpawnError::PodExitedEarly { + stderr_tail: tail.into_string(), + }); + } + _ = &mut timeout => { + let _ = child.start_kill(); + return Err(SpawnError::Timeout); + } + } + } +} + +fn build_overlay_toml(form: &Form) -> String { + let mut root = toml::value::Table::new(); + + let mut pod = toml::value::Table::new(); + pod.insert("name".into(), toml::Value::String(form.name.clone())); + root.insert("pod".into(), toml::Value::Table(pod)); + + if !form.cascade_has_scope { + let mut rule = toml::value::Table::new(); + rule.insert( + "target".into(), + toml::Value::String(form.cwd.display().to_string()), + ); + rule.insert("permission".into(), toml::Value::String("write".into())); + let mut scope = toml::value::Table::new(); + scope.insert("allow".into(), toml::Value::Array(vec![toml::Value::Table(rule)])); + root.insert("scope".into(), toml::Value::Table(scope)); + } + + toml::to_string(&toml::Value::Table(root)).expect("overlay serialisation cannot fail") +} + +fn resolve_pod_command() -> PathBuf { + if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND") { + if !cmd.is_empty() { + return PathBuf::from(cmd); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("pod"); + if candidate.is_file() { + return candidate; + } + } + } + PathBuf::from("pod") +} + +fn find_project_manifest(start: &Path) -> Option { + let start = start + .canonicalize() + .ok() + .unwrap_or_else(|| start.to_path_buf()); + let mut cur: Option<&Path> = Some(start.as_path()); + while let Some(dir) = cur { + let candidate = dir.join(".insomnia").join("manifest.toml"); + if candidate.is_file() { + return Some(candidate); + } + cur = dir.parent(); + } + None +} + +fn load_layer(path: PathBuf) -> Option { + if !path.is_file() { + return None; + } + let s = std::fs::read_to_string(&path).ok()?; + PodManifestConfig::from_toml(&s).ok() +} + +fn user_manifest_path() -> Option { + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { + if !dir.is_empty() { + return Some(PathBuf::from(dir).join("insomnia").join("manifest.toml")); + } + } + let home = std::env::var("HOME").ok()?; + Some( + PathBuf::from(home) + .join(".config") + .join("insomnia") + .join("manifest.toml"), + ) +} + +struct StderrTail { + lines: std::collections::VecDeque, +} + +impl StderrTail { + fn new() -> Self { + Self { + lines: std::collections::VecDeque::with_capacity(8), + } + } + fn push(&mut self, line: &str) { + if self.lines.len() == 8 { + self.lines.pop_front(); + } + self.lines.push_back(line.to_string()); + } + fn into_string(self) -> String { + self.lines.into_iter().collect::>().join(" | ") + } +} + +enum MessageKind { + Info, + Ok, + Error, + Progress, +} + +enum ScopeOrigin { + FromUser, + FromProject, + CwdDefault, +} + +struct Form { + cwd: PathBuf, + /// True when at least one cascade layer (user or project manifest) + /// already declares `scope.allow`. Drives whether the overlay + /// should add a cwd-write rule. + cascade_has_scope: bool, + /// Display label for the scope row in the dialog. + scope_origin: ScopeOrigin, + name: String, + /// Cursor position counted in **chars**, not bytes — `name` + /// currently only accepts ASCII so the two coincide, but we keep + /// char-based bookkeeping in case we relax `is_safe_name_char`. + name_cursor: usize, + message: Option<(String, MessageKind)>, +} + +impl Form { + fn insert_char(&mut self, c: char) { + let byte = self.char_offset_to_byte(self.name_cursor); + self.name.insert(byte, c); + self.name_cursor += 1; + } + + fn backspace(&mut self) { + if self.name_cursor == 0 { + return; + } + let end = self.char_offset_to_byte(self.name_cursor); + let start = self.char_offset_to_byte(self.name_cursor - 1); + self.name.replace_range(start..end, ""); + self.name_cursor -= 1; + } + + fn delete_forward(&mut self) { + let total = self.name.chars().count(); + if self.name_cursor >= total { + return; + } + let start = self.char_offset_to_byte(self.name_cursor); + let end = self.char_offset_to_byte(self.name_cursor + 1); + self.name.replace_range(start..end, ""); + } + + fn move_left(&mut self) { + if self.name_cursor > 0 { + self.name_cursor -= 1; + } + } + + fn move_right(&mut self) { + let total = self.name.chars().count(); + if self.name_cursor < total { + self.name_cursor += 1; + } + } + + fn char_offset_to_byte(&self, char_off: usize) -> usize { + self.name + .char_indices() + .nth(char_off) + .map(|(b, _)| b) + .unwrap_or(self.name.len()) + } +} + +fn draw_form(f: &mut Frame<'_>, form: &Form) { + let area = f.area(); + let layout = Layout::vertical([ + Constraint::Length(1), // title + Constraint::Length(1), // name field + Constraint::Length(1), // context (manifest or scope default) + Constraint::Length(1), // hint + Constraint::Length(1), // message + Constraint::Length(1), // spacer + ]) + .split(area); + + let title = Paragraph::new(Line::from(vec![Span::styled( + "spawn pod", + Style::default().add_modifier(Modifier::BOLD), + )])); + f.render_widget(title, layout[0]); + + f.render_widget(Paragraph::new(name_line(form)), layout[1]); + f.render_widget(Paragraph::new(context_line(form)), layout[2]); + f.render_widget(Paragraph::new(hint_line()), layout[3]); + f.render_widget(Paragraph::new(message_line(form)), layout[4]); + + // Place the cursor inside the name field (col 8 = " name: ".len()). + let cursor_col = 2 + "name: ".len() + form.name_cursor; + f.set_cursor_position((layout[1].x + cursor_col as u16, layout[1].y)); +} + +fn name_line(form: &Form) -> Line<'_> { + Line::from(vec![ + Span::raw(" "), + Span::styled("name: ", Style::default().fg(Color::DarkGray)), + Span::styled( + form.name.as_str(), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + ]) +} + +fn context_line(form: &Form) -> Line<'_> { + match form.scope_origin { + ScopeOrigin::FromProject => Line::from(vec![ + Span::raw(" "), + Span::styled("scope: ", Style::default().fg(Color::DarkGray)), + Span::styled("from project manifest", Style::default().fg(Color::Green)), + ]), + ScopeOrigin::FromUser => Line::from(vec![ + Span::raw(" "), + Span::styled("scope: ", Style::default().fg(Color::DarkGray)), + Span::styled("from user manifest", Style::default().fg(Color::Green)), + ]), + ScopeOrigin::CwdDefault => Line::from(vec![ + Span::raw(" "), + Span::styled("scope: ", Style::default().fg(Color::DarkGray)), + Span::styled( + form.cwd.display().to_string(), + Style::default().fg(Color::Yellow), + ), + Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)), + ]), + } +} + +fn hint_line() -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + Span::styled("[enter]", Style::default().fg(Color::Green)), + Span::raw(" spawn "), + Span::styled("[esc]", Style::default().fg(Color::Yellow)), + Span::raw(" cancel"), + ]) +} + +fn message_line(form: &Form) -> Line<'_> { + let Some((text, kind)) = form.message.as_ref() else { + return Line::from(""); + }; + let style = match kind { + MessageKind::Info => Style::default().fg(Color::DarkGray), + MessageKind::Ok => Style::default().fg(Color::Green), + MessageKind::Error => Style::default().fg(Color::Red), + MessageKind::Progress => Style::default().fg(Color::Yellow), + }; + Line::from(vec![Span::raw(" "), Span::styled(text.as_str(), style)]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn form(name: &str, cascade_has_scope: bool) -> Form { + Form { + cwd: PathBuf::from("/work/example"), + cascade_has_scope, + scope_origin: if cascade_has_scope { + ScopeOrigin::FromProject + } else { + ScopeOrigin::CwdDefault + }, + name: name.to_string(), + name_cursor: name.chars().count(), + message: None, + } + } + + #[test] + fn overlay_adds_scope_default_when_cascade_lacks_scope() { + let f = form("agent-1", false); + let toml_str = build_overlay_toml(&f); + let parsed: toml::Value = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-1")); + let allow = parsed["scope"]["allow"].as_array().unwrap(); + assert_eq!(allow.len(), 1); + assert_eq!(allow[0]["target"].as_str(), Some("/work/example")); + assert_eq!(allow[0]["permission"].as_str(), Some("write")); + } + + #[test] + fn overlay_omits_scope_when_cascade_already_has_one() { + let f = form("agent-2", true); + let toml_str = build_overlay_toml(&f); + let parsed: toml::Value = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed["pod"]["name"].as_str(), Some("agent-2")); + assert!(parsed.get("scope").is_none()); + } + + #[test] + fn cascade_merge_detects_scope_from_any_layer() { + let user = PodManifestConfig::from_toml( + r#" +[[scope.allow]] +target = "/from-user" +permission = "write" +"#, + ) + .unwrap(); + let mut cascade = PodManifestConfig::builtin_defaults(); + cascade = cascade.merge(user); + assert!(!cascade.scope.allow.is_empty()); + + let empty_cascade = PodManifestConfig::builtin_defaults(); + assert!(empty_cascade.scope.allow.is_empty()); + } + + #[test] + fn name_input_handles_insert_backspace_and_cursor() { + let mut f = form("", false); + for c in "abc".chars() { + f.insert_char(c); + } + assert_eq!(f.name, "abc"); + assert_eq!(f.name_cursor, 3); + + f.move_left(); + f.move_left(); + f.insert_char('X'); + assert_eq!(f.name, "aXbc"); + + f.backspace(); + assert_eq!(f.name, "abc"); + assert_eq!(f.name_cursor, 1); + + f.delete_forward(); + assert_eq!(f.name, "ac"); + } + + #[test] + fn sanitise_default_name_replaces_unsafe_chars() { + assert_eq!(sanitise_default_name("my project!"), "my-project-"); + assert_eq!(sanitise_default_name("ok-name_2.0"), "ok-name_2.0"); + } +} diff --git a/tickets/tui-pod-spawn-ui.md b/tickets/tui-pod-spawn-ui.md index 623df468..cb7c495e 100644 --- a/tickets/tui-pod-spawn-ui.md +++ b/tickets/tui-pod-spawn-ui.md @@ -1,52 +1,96 @@ -# TUI: 新しい Pod を spawn する UI の設計 +# TUI: inline viewport で Pod を spawn する UX ## 背景 -Insomnia は複数 Pod を同時に走らせられるアーキテクチャを想定しているが、TUI は**現在単一 Pod のみを表示**する前提で作られている。Pod を増やすには TUI を起動し直すしかなく、作業文脈の並列性がユーザーに見えていない。 +現在の Pod 起動はシェル直叩きで、`pod` バイナリに manifest を渡して即フルスクリーン TUI に入る形になっている (`start_pod.local.fish` 参照)。ユーザーの自然なメンタルモデルは「ワークスペースに `cd` して、最上位エージェントを 1 個立ち上げ、そのセッションでオーケストレーションを進める」というもので、現状はこの「立ち上げる」操作の前段が貧弱。 -TUI から新しい Pod を生やす操作と、複数 Pod を扱う UI モデルを設計する。実装の前に**使い方の合意**が必要なため、本チケットは**設計フェーズの成果物**を完了条件に含む。 +複数 Pod の並列実行は別ターミナル / tmux で別プロセスとして並べる運用を想定するため、TUI 自体を multi-pod 化する方向には踏み込まない。**1 シェル起動 = 1 Pod に attach** の前提を維持したまま、起動の前段だけ対話的にする。 + +manifest カスケード (`crates/pod/src/factory.rs`) の前提: + +- **user manifest** (`~/.config/insomnia/manifest.toml`): `model` と `auth` 等、ユーザー横断で固定したい設定の置き場。spawn UI はここに `model` があることを前提にする +- **project manifest** (`/.insomnia/manifest.toml`): あれば `pod.name` / `scope.allow` を上書きする +- **overlay**: spawn UI が dialog 入力からその場で組み立てて pod に渡す最高優先度のレイヤ + +spawn UI の役割は、project manifest が無いワークスペースでも overlay を組んで起動できるようにし、`.insomnia/manifest.toml` を作る手間を省くこと。 + +## 方針 + +- 起動コマンドはまず **inline viewport** でダイアログを描く(ratatui の `Viewport::Inline`) +- ダイアログで manifest と起動パラメータを確定 → Pod を spawn → そのまま **fullscreen TUI** (alternate screen buffer, `tickets/tui-fullscreen-overhaul.md`) に attach する +- ダイアログのやり取りはシェルのスクロールバックに残す。「何を spawn したか」がログとして自然に残るのがこの方式の主眼 +- キャンセル経路では fullscreen に入らずシェルに戻る + +`tui-fullscreen-overhaul.md` は attach 後の本体描画モデル、本チケットはその**前段の対話 UI と attach までの遷移**を扱う。両者は viewport の使い分け(inline → alternate screen)で繋がる。 ## 要件 -### 成果物(設計フェーズ) +### エントリポイント -- **ユースケース記述**: どういう場面で Pod を増やしたくなるか、複数 Pod 間で何をするか、現行 Pod をどう区別・切替するか -- **UI モデル**: タブ / 分割ペイン / リスト + 単一表示 のどれにするか、根拠つきで -- **状態遷移図**: Pod の spawn / active / background / shutdown のライフサイクル -- **spawn 時の入力**: manifest をどう指定するか(ファイル選択 / テンプレート / 新規編集) -- **既存チケットとの整合**: `tui-pod-shutdown.md` の shutdown 操作が「単一 Pod を閉じる」意味にちゃんとなるか、`tui-notification-channel.md` の通知が「どの Pod の通知か」を表現できるか -- 上記を `docs/tui-pod-spawn.md` 等にまとめる +- ワークスペース直下で叩いて Pod を立ち上げるコマンドを 1 本提供する(既存 `pod` バイナリの起動経路を流用するか別サブコマンドにするかは設計で決める) +- 引数なしで叩いた場合は inline ダイアログに入る +- manifest を引数で渡された場合はダイアログをスキップして直接 fullscreen に入る(既存の動作を保つ) -### 実装フェーズ(設計合意後) +### inline ダイアログ -- spawn UI のエントリポイントを TUI に実装 -- 複数 Pod を扱うための内部データモデル(Pod リスト、アクティブ Pod 管理) -- Pod 間の切替操作 -- 各 Pod の shutdown 操作との統合 +- ratatui の inline viewport で、シェル直下の数行に描く +- 入力項目: + - **pod.name** (1 行テキスト、編集可): デフォルト値は project manifest の `pod.name`、無ければ cwd の basename + - その他の必要設定(model、scope.allow)はカスケード or デフォルトで補う(後述) +- 確定 / キャンセルが明示的なキー操作で、誤爆しないこと +- 確定すると Pod 起動が始まる。起動中の進捗を inline 領域に流して、attach 完了の瞬間に fullscreen へ切り替える +- キャンセル時は inline 領域を畳んでシェルに戻り、Pod は spawn しない + +### 必須フィールドの埋め方 + +pod は `pod.name` / `model` / `scope.allow` がそろわないと起動しない。spawn UI はそれぞれを次のように埋める: + +- **`pod.name`**: ダイアログ入力。未入力で Enter は不可。デフォルト値は user / project manifest に既に書かれていればそれ、無ければ cwd の basename +- **`model`**: user / project どちらかのレイヤから cascade 経由で取得。どこにも無ければ pod 側の resolve が失敗するので、その stderr エラー文を inline ダイアログに表示してキャンセル相当に倒す(user manifest の編集 UI までは本チケット外) +- **`scope.allow`**: user / project どちらかのレイヤに既にあればそれをそのまま使う(overlay には追加しない)。両方とも無ければ `target = , permission = "write"` をデフォルトとして overlay に追加する + +tui は `manifest` クレートの `PodManifestConfig::from_toml` / `merge` / `resolve_paths` を使って user + project の cascade を実際にマージし、その結果から「`scope.allow` が空かどうか」「`pod.name` のデフォルトは何か」を読み取る。実際の最終マージ + バリデーションは pod の `PodFactory::resolve()` 側で行われるので、tui は dialog 用の事前情報を取るためだけに同じ仕組みを再実行する形になる(重い `pod` クレート全体には依存しない)。 + +確定時、ダイアログ入力 + デフォルト埋めから overlay TOML を組み、pod に `--overlay` で渡す。pod 側の cascade は user → project → overlay の順で merge されるので、project manifest に値があれば overlay の同名フィールドだけが上書きする形になる。 + +### inline → fullscreen の遷移 + +- inline viewport を畳む → alternate screen に切り替える、を**ちらつかず**に実行する +- 切り替え後、Pod の `Event::History` で履歴を組み直して通常の TUI 状態に入る(ここから先は fullscreen overhaul の責務) +- 切り替え失敗(Pod 起動失敗等)の場合は inline 領域にエラーを出して終了する。alternate screen には入らない + +### スクロールバックに残るもの + +- inline ダイアログで確定した spawn の要約(manifest path、override の要点) +- 起動失敗時のエラー +- 正常 attach した場合の「attach 開始」ログ 1 行程度 + +fullscreen TUI で描いた内容は alternate screen buffer なのでスクロールバックには残らない(既存方針通り)。 ## 設計で決めること -- **並列実行の扱い**: 全 Pod を常に走らせるか、フォーカスしている Pod のみ走らせて他は suspend か -- **リソース共有**: scope 排他制御(`tickets/scope-exclusion.md`)との関係。同一パスを同時 write できない制約を UI でどう表現するか -- **shutdown との関係**: 「この Pod を閉じる」と「TUI を閉じる」を区別するキーバインド -- **通知の帰属**: 通知チャネル(`tickets/tui-notification-channel.md`)が Pod 別に表示されるべきか、TUI 全体で集約するか +- **`pod.name` の文字種制約**: runtime dir 名に使われるのでファイルシステム安全な範囲に絞る(英数 + `-` + `_` + `.` 等) +- **scope デフォルトの permission**: `write` で良いか、対話的に `read` / `write` を切り替えさせるか +- **キーバインド**: 確定 / キャンセル / 項目移動。fullscreen 側のキーマップと衝突しないこと +- **進捗表示の粒度**: Pod 側の起動シーケンスのどのフェーズを inline に出すか +- **再 attach の入り口**: 既存 Pod に後から attach するユースケースを今回扱うか、扱うなら inline ダイアログの中に「新規 spawn / 既存 attach」の分岐を置くか別コマンドに分けるか +- **user manifest 不在時の扱い**: 「先に `~/.config/insomnia/manifest.toml` を作ってください」とエラーするか、user manifest 編集 UI までこのチケットで踏み込むか ## 完了条件 -### 設計フェーズ - -- `docs/tui-pod-spawn.md`(または同等の設計ドキュメント)がレビュー可能な形で存在する -- 上記の「設計で決めること」すべてに結論が出ている -- 他の TUI チケット(通知・shutdown)との整合が確認できている - -### 実装フェーズ - -- TUI から新しい Pod を spawn できる -- 複数 Pod が spawn された状態で、ユーザーがそれらを切り替えられる -- 各 Pod は独立して実行・通知・shutdown できる +- ワークスペースで該当コマンドを引数なしで叩くと inline ダイアログが立ち上がる +- ダイアログで `pod.name` を確定すると、必要なら scope.allow デフォルトを埋めた overlay が組まれて Pod が spawn され、そのまま fullscreen TUI に attach する +- project manifest が無いワークスペースでも、user manifest に model があれば spawn できる +- user manifest に model が無いと、ダイアログ内で何が足りないかが分かるエラーが出る +- ダイアログでキャンセルするとシェルに戻り、Pod は起動していない +- manifest を引数で直接渡した(または既存 Pod 名で attach した)場合はダイアログを経由せず従来通り fullscreen に入る +- 確定した spawn の要約と、起動失敗時のエラーがスクロールバックに残る +- inline → fullscreen の遷移でターミナル表示が破綻しない ## 範囲外 -- Pod 間のメッセージパッシングや依存関係(Pod A の出力を Pod B の入力に繋ぐ等) -- Pod の保存済みテンプレートからの呼び出し UI(設計に含めるかは検討) -- 分散実行・リモート Pod(ローカル TUI の中での複数 Pod のみ扱う) +- TUI の中で複数 Pod を tab / split / list で切り替える UI +- Pod 間メッセージパッシング、依存関係 +- 既存 Pod への再 attach(扱うかは「設計で決めること」で判断、扱わないと決まれば別チケット) +- Pod テンプレートの管理 UI(保存・編集・共有) +- リモート Pod / 分散実行