feat: use builtin profile by default

This commit is contained in:
2026-05-30 03:57:40 +09:00
parent ffe1785262
commit 625730cb0a
8 changed files with 315 additions and 283 deletions
+40 -155
View File
@@ -1,29 +1,23 @@
//! Inline-viewport "spawn Pod and attach" UX.
//!
//! Rendered at the user's current cursor position when `insomnia` is invoked
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
//! to seed manifest defaults and `.insomnia/profiles.toml` to discover profile
//! choices, prompts for the Pod's name, and on confirmation
//! launches the `insomnia-pod` binary as an independent process with a freshly built
//! overlay (name + cwd scope when no project manifest exists). Once
//! the process reports its socket via the `INSOMNIA-READY` stderr line,
//! the dialog hands control back so main can switch the terminal to
//! alternate-screen mode.
//! with no positional argument. Discovers `.insomnia/profiles.toml` profile
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
//! the Pod's name, and on confirmation launches the `insomnia-pod` binary as an
//! independent process. Once the process 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::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::{SpawnConfig, spawn_pod};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::{
PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
user_manifest_path, user_manifest_path_from_env,
};
use manifest::{ProfileDiscovery, ScopeConfig};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@@ -97,7 +91,11 @@ pub async fn run(
profile: Option<String>,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut profile_choices = defaults.profile_choices;
let mut profile_choices = if resume_from.is_some() {
Vec::new()
} else {
defaults.profile_choices
};
let profile_index = initial_profile_index(
&mut profile_choices,
profile.as_deref(),
@@ -232,42 +230,6 @@ struct ProfileChoice {
fn load_spawn_defaults() -> Result<SpawnDefaults, 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 an
// instance-level identifier and is supplied by the dialog or `--pod`.
// TUI must pre-read the same user manifest path that the pod CLI will use,
// including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall
// back to the auto-discovered path.
let user_layer = user_manifest_path_for_spawn(
std::env::var_os(manifest::paths::USER_MANIFEST_ENV),
user_manifest_path(),
)
.filter(|p| p.is_file())
.and_then(|p| load_layer(&p).ok());
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
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())
@@ -279,8 +241,8 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
Ok(SpawnDefaults {
cwd,
cascade_has_scope,
scope_origin,
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProfile,
default_name,
default_profile_index,
profile_choices,
@@ -288,16 +250,11 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
}
fn profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
let mut choices = vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}];
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
return (choices, 0);
return (Vec::new(), 0);
};
let mut choices = Vec::new();
for entry in registry.entries() {
let mut label = entry.qualified_name();
if entry.is_default {
@@ -343,13 +300,6 @@ fn initial_profile_index(
choices.len() - 1
}
fn user_manifest_path_for_spawn(
env_value: Option<OsString>,
default_user_manifest: Option<PathBuf>,
) -> Option<PathBuf> {
user_manifest_path_from_env(env_value).or(default_user_manifest)
}
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
Form {
cwd: defaults.cwd,
@@ -362,11 +312,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
resume_from: None,
resume_by_pod_name: true,
resume_scope: None,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_choices: Vec::new(),
profile_index: 0,
}
}
@@ -519,16 +465,14 @@ enum MessageKind {
}
enum ScopeOrigin {
FromUser,
FromProject,
FromProfile,
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.
/// True when the launch source already supplies `scope.allow`.
/// Drives whether the compatibility overlay should add a cwd-write rule.
cascade_has_scope: bool,
/// Display label for the scope row in the dialog.
scope_origin: ScopeOrigin,
@@ -648,7 +592,7 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
let layout = Layout::vertical([
Constraint::Length(1), // title
Constraint::Length(1), // name field
Constraint::Length(1), // context (manifest or scope default)
Constraint::Length(1), // context (profile or scope default)
Constraint::Length(1), // hint
Constraint::Length(1), // message
Constraint::Length(1), // spacer
@@ -716,15 +660,10 @@ fn context_line(form: &Form) -> Line<'_> {
}
match form.scope_origin {
ScopeOrigin::FromProject => Line::from(vec![
ScopeOrigin::FromProfile => 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)),
Span::styled("from default profile", Style::default().fg(Color::Green)),
]),
ScopeOrigin::CwdDefault => Line::from(vec![
Span::raw(" "),
@@ -767,7 +706,7 @@ mod tests {
cwd: PathBuf::from("/work/example"),
cascade_has_scope,
scope_origin: if cascade_has_scope {
ScopeOrigin::FromProject
ScopeOrigin::FromProfile
} else {
ScopeOrigin::CwdDefault
},
@@ -778,11 +717,7 @@ mod tests {
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_choices: Vec::new(),
profile_index: 0,
}
}
@@ -792,14 +727,10 @@ mod tests {
let defaults = SpawnDefaults {
cwd: PathBuf::from("/work/example"),
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProject,
scope_origin: ScopeOrigin::FromProfile,
default_name: "ignored".to_string(),
default_profile_index: 0,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_choices: Vec::new(),
};
let f = form_for_pod_name("agent".to_string(), defaults);
@@ -860,46 +791,6 @@ mod tests {
assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child"));
}
#[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 user_manifest_path_for_spawn_prefers_non_empty_env_override() {
assert_eq!(
user_manifest_path_for_spawn(
Some(OsString::from("/tmp/override.toml")),
Some(PathBuf::from("/default/manifest.toml")),
),
Some(PathBuf::from("/tmp/override.toml")),
);
}
#[test]
fn user_manifest_path_for_spawn_treats_empty_env_as_unset() {
assert_eq!(
user_manifest_path_for_spawn(
Some(OsString::from("")),
Some(PathBuf::from("/default/manifest.toml")),
),
Some(PathBuf::from("/default/manifest.toml")),
);
}
#[test]
fn profile_choices_use_project_registry_default() {
let temp = tempfile::tempdir().unwrap();
@@ -925,7 +816,7 @@ coder = "profiles/coder.nix"
}
#[test]
fn profile_choices_include_no_profile_source_labels_and_default_marker() {
fn profile_choices_include_builtin_and_project_default_marker() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let insomnia = project.join(".insomnia");
@@ -942,22 +833,17 @@ description = "Project coder"
.unwrap();
let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(choices[0].selector, None);
assert_eq!(choices[0].label, "manifest cascade");
assert_eq!(choices[0].selector.as_deref(), Some("builtin:default"));
assert_eq!(choices[0].label, "builtin:default");
assert_eq!(default_index, 1);
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
assert_eq!(choices[1].label, "project:coder (default) — Project coder");
}
#[test]
fn profile_cycle_selects_profiles_and_can_opt_out_of_default() {
fn profile_cycle_selects_profiles_without_manifest_cascade_opt_out() {
let mut form = form("coder", true);
form.profile_choices = vec![
ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
},
ProfileChoice {
selector: Some("project:coder".to_string()),
label: "project:coder (default)".to_string(),
@@ -969,7 +855,7 @@ description = "Project coder"
is_default: false,
},
];
form.profile_index = 1;
form.profile_index = 0;
assert_eq!(
form.selected_profile_selector().as_deref(),
@@ -981,7 +867,10 @@ description = "Project coder"
Some("user:reviewer")
);
form.cycle_profile_next();
assert_eq!(form.selected_profile_selector(), None);
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("project:coder")
);
form.cycle_profile_prev();
assert_eq!(
form.selected_profile_selector().as_deref(),
@@ -991,15 +880,11 @@ description = "Project coder"
#[test]
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
let mut choices = vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}];
let mut choices = Vec::new();
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
assert_eq!(selected, 1);
assert_eq!(choices[1].selector.as_deref(), Some("coder"));
assert_eq!(choices[1].label, "coder");
assert_eq!(selected, 0);
assert_eq!(choices[0].selector.as_deref(), Some("coder"));
assert_eq!(choices[0].label, "coder");
}
#[test]