tuiからSpawnする仮UI

This commit is contained in:
2026-04-27 16:22:06 +09:00
parent cd8b620e6a
commit c4ca9e1d89
7 changed files with 866 additions and 61 deletions
+3 -1
View File
@@ -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" }
+91 -24
View File
@@ -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>) -> PathBuf {
if let Some(p) = override_path {
@@ -48,49 +51,109 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
}
}
fn parse_args() -> (String, Option<PathBuf>) {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("usage: tui <pod_name> [--socket <path>]");
std::process::exit(1);
enum Mode {
Spawn,
Attach {
pod_name: String,
socket_override: Option<PathBuf>,
},
}
fn parse_args() -> Mode {
let args: Vec<String> = 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<dyn std::error::Error>> {
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<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Terminal<CrosstermBackend<io::Stdout>>, Box<dyn std::error::Error>>
{
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
pod_name: String,
socket_path: &std::path::Path,
shutdown_pod_on_exit: bool,
) -> Result<(), Box<dyn std::error::Error>> {
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<CrosstermBackend<io::Stdout>>,
app: &mut App,
mut client: PodClient,
shutdown_pod_on_exit: bool,
) -> Result<(), Box<dyn std::error::Error>> {
terminal.draw(|f| ui::draw(f, app))?;
loop {
if app.quit {
if shutdown_pod_on_exit {
let _ = client.send(&Method::Shutdown).await;
}
break;
}
+684
View File
@@ -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<io::Error> for SpawnError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub async fn run() -> Result<SpawnOutcome, SpawnError> {
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<InlineTerminal> {
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<Option<Action>> {
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<SpawnReady, SpawnError> {
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<PathBuf> {
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<PodManifestConfig> {
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<PathBuf> {
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<String>,
}
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::<Vec<_>>().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");
}
}