use super::super::*;
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum RepoAction {
Deploy,
Run,
Review,
}
pub(crate) struct RepoPanel {
pub(crate) root: std::path::PathBuf,
pub(crate) projects: Vec<String>,
pub(crate) sel: usize,
pub(crate) action: RepoAction,
}
pub(crate) fn list_projects(root: &std::path::Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
(!n.starts_with('.')).then_some(n)
})
.collect();
v.sort();
v
}
pub(crate) fn gateway_access_url(gateway_origin: &str, project: &str) -> String {
format!(
"{}/apps/{}",
gateway_origin.trim_end_matches('/'),
slug(project)
)
}
pub(crate) fn dev_access_url(gateway_origin: &str, project: &str) -> String {
format!(
"{}/apps/{}-dev",
gateway_origin.trim_end_matches('/'),
slug(project)
)
}
pub(crate) fn slug(name: &str) -> String {
let mut out = String::new();
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let s = out.trim_matches('-').to_string();
if s.is_empty() {
"app".to_string()
} else {
s
}
}
pub(crate) fn deploy_prompt(project_dir: &str, project: &str, access_url: &str) -> String {
format!(
"Run Agentic CI/CD for the project at {project_dir} and deploy it to OS, then \
make it reachable at its gateway access address.\n\
IMPORTANT: {project_dir} is OUTSIDE this session's workspace, so the \
path-scoped file tools (read/ls/glob/grep/edit) will reject it — use the \
`bash` tool for everything under it (e.g. `ls`, `cat`, `find`, `sed -n`, and \
all build/test/deploy commands run with `cd {project_dir}`).\n\
1. Inspect the project (Dockerfile, chart/, package.json, manifests) with bash \
to learn how it builds, tests, and runs.\n\
2. Build it and run its tests/linters. If a step fails, diagnose and fix the \
minimal cause, then re-run — don't proceed on a red build.\n\
3. Containerize/deploy it to the OS runtime substrate (use the project's own \
Dockerfile/Helm chart where present; otherwise the simplest correct path).\n\
4. Configure the OS gateway so the deployed service is reachable at EXACTLY \
this access address (the rule is `<gateway-origin>/apps/<project-slug>`): \
{access_url}\n\
Use your OS platform capabilities (the signed-in session) for the deploy \
and gateway steps. When done, report: build/test result, what was deployed, and \
confirm the service responds at {access_url} (curl it). If any step can't be \
completed, say which and why — don't claim success you didn't verify.\n\
Project: {project}"
)
}
pub(crate) fn run_prompt(project_dir: &str, project: &str, access_url: &str) -> String {
format!(
"Start the project at {project_dir} in DEVELOPMENT mode on the A3S Runtime for \
quick debugging, and make it reachable at its dev access address.\n\
IMPORTANT: {project_dir} is OUTSIDE this session's workspace, so the \
path-scoped file tools (read/ls/glob/grep/edit) will reject it — use the \
`bash` tool for everything under it (with `cd {project_dir}`).\n\
1. Quickly inspect the project with bash (README, package.json, Cargo.toml, \
Dockerfile…) to learn its dev entrypoint (`npm run dev`, `cargo run`, …) and \
port. Prioritize SPEED: skip full builds, tests, and linters — this is a \
debug run, not CI/CD.\n\
2. Start it in development mode on the A3S Runtime via your OS platform \
capabilities (the signed-in session), with dev settings on (debug logging, \
hot reload where the project supports it).\n\
3. Expose it so it responds at EXACTLY this dev access address (the rule is \
`<gateway-origin>/apps/<project-slug>-dev`, never the production `/apps/\
<project-slug>` route): {access_url}\n\
4. Verify with curl, then report on its own line `Access: {access_url}`, plus \
how it was started and where its logs are. If it cannot start, report the \
failing command and its error — don't claim success you didn't verify.\n\
Project: {project}"
)
}
impl App {
pub(crate) fn open_repo_picker(&mut self, action: RepoAction) {
let root = repo_dir();
let projects = list_projects(&root);
if projects.is_empty() {
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" no projects in {} — clone one with `& <git-url>` first",
root.display()
)));
return;
}
self.repo_picker = Some(RepoPanel {
root,
projects,
sel: 0,
action,
});
}
pub(crate) fn handle_repo_picker_key(&mut self, key: &KeyEvent) -> Option<Cmd<Msg>> {
let p = self.repo_picker.as_mut()?;
let last = p.projects.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => p.sel = p.sel.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => p.sel = (p.sel + 1).min(last),
KeyCode::Esc => self.repo_picker = None,
KeyCode::Enter => {
let panel = self.repo_picker.take()?;
let project = panel.projects.get(panel.sel.min(last))?.clone();
let dir = panel.root.join(&project);
if panel.action == RepoAction::Review {
if self.state != State::Idle {
self.push_line(&Style::new().fg(TN_YELLOW).render(
" a code review can't start while a turn is running — press Esc to stop first",
));
return None;
}
let prompt =
super::review::local_review_prompt(&dir.to_string_lossy(), &project);
let label = format!("🔎 review: {project}");
self.messages
.push(gutter(TN_PURPLE, &Style::new().bold().render(&label)));
self.push_line(&Style::new().fg(TN_GRAY).render(
" deep read-only inspection → issue checklist to pick fixes (no auto-fix · Esc stops)",
));
self.review_pending = true;
self.engage_autonomy(8);
return self.start_stream_inner(prompt, label, true, true, false);
}
let gateway = self
.os_session
.as_ref()
.map(|s| crate::a3s_os::os_origin(&s.address))
.unwrap_or_default();
let (prompt, label, hint) = match panel.action {
RepoAction::Deploy => {
let access = gateway_access_url(&gateway, &project);
let prompt = deploy_prompt(&dir.to_string_lossy(), &project, &access);
let label = format!("🚀 Agentic CI/CD: {project} → {access}");
let hint = format!(" build → test → deploy → gateway access {access}");
(prompt, label, hint)
}
RepoAction::Run => {
let access = dev_access_url(&gateway, &project);
let prompt = run_prompt(&dir.to_string_lossy(), &project, &access);
let label = format!("▶ dev run: {project} → {access}");
let hint =
format!(" dev mode on A3S Runtime · debug run → access {access}");
(prompt, label, hint)
}
RepoAction::Review => unreachable!("handled above"),
};
self.messages
.push(gutter(ACCENT, &Style::new().bold().render(&label)));
self.push_line(&Style::new().fg(TN_GRAY).render(&hint));
self.engage_autonomy(8);
if self.state == State::Idle {
return self.start_stream_inner(prompt, label, true, false, false);
}
self.seq += 1;
self.queue.push(Queued {
prio: 1,
seq: self.seq,
text: prompt,
});
self.push_line(&Style::new().fg(TN_GRAY).render(" ⋯ queued"));
}
_ => {}
}
None
}
pub(crate) fn overlay_repo_picker(&self, composed: String) -> String {
let Some(p) = self.repo_picker.as_ref() else {
return composed;
};
let width = self.width as usize;
let total = p.projects.len();
let (icon_title, enter_hint) = match p.action {
RepoAction::Deploy => ("🚀 deploy", "Enter run Agentic CI/CD"),
RepoAction::Run => ("▶ run", "Enter start dev mode on A3S Runtime"),
RepoAction::Review => ("🔎 review", "Enter review (read-only)"),
};
let mut menu = vec![
pad_to(
&Style::new().fg(ACCENT).bold().render(&format!(
" {icon_title} — pick a project ({} in {})",
total,
truncate(&p.root.to_string_lossy(), width.saturating_sub(28))
)),
width,
),
pad_to(
&Style::new()
.fg(TN_GRAY)
.render(&format!(" ↑/↓ select · {enter_hint} · Esc cancel")),
width,
),
];
let sel = p.sel.min(total.saturating_sub(1));
let max_rows = (self.height as usize).saturating_sub(8).clamp(3, 12);
let start = if sel < max_rows {
0
} else {
sel + 1 - max_rows
};
let end = (start + max_rows).min(total);
for (row, name) in p.projects.iter().enumerate().take(end).skip(start) {
let raw = pad_to(&format!(" {name}"), width);
menu.push(if row == sel {
Style::new().fg(Color::Black).bg(ACCENT).render(&raw)
} else {
Style::new().fg(TN_FG).render(&raw)
});
}
if total > max_rows {
menu.push(pad_to(
&Style::new()
.fg(TN_GRAY)
.render(&format!(" {}/{total}", sel + 1)),
width,
));
}
self.overlay_list(composed, &menu)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_project_dirs_sorted_skipping_dotfiles() {
let root = std::env::temp_dir().join(format!("a3s-deploy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("zeta")).unwrap();
std::fs::create_dir_all(root.join("alpha")).unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join("readme.txt"), "x").unwrap(); let ps = list_projects(&root);
assert_eq!(ps, vec!["alpha", "zeta"]); let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn gateway_access_url_follows_the_rule() {
assert_eq!(
gateway_access_url("http://180.163.156.38:49164", "risk-reporter"),
"http://180.163.156.38:49164/apps/risk-reporter"
);
assert_eq!(
gateway_access_url("http://host/", "My App 2"),
"http://host/apps/my-app-2"
);
}
#[test]
fn dev_access_url_follows_the_dev_rule() {
assert_eq!(
dev_access_url("http://180.163.156.38:49164", "risk-reporter"),
"http://180.163.156.38:49164/apps/risk-reporter-dev"
);
assert_eq!(
dev_access_url("http://host/", "My App 2"),
"http://host/apps/my-app-2-dev"
);
}
#[test]
fn run_prompt_is_dev_mode_speed_first_and_reports_access() {
let url = dev_access_url("http://os", "svc");
let p = run_prompt("/repos/svc", "svc", &url);
assert!(p.contains("/repos/svc"));
assert!(p.contains("http://os/apps/svc-dev")); assert!(p.contains("<gateway-origin>/apps/<project-slug>-dev")); assert!(p.contains("DEVELOPMENT mode") && p.contains("A3S Runtime"));
assert!(p.contains("skip full builds, tests, and linters"));
assert!(p.contains("Access: http://os/apps/svc-dev"));
assert!(p.contains("OUTSIDE this session's workspace") && p.contains("bash"));
}
#[test]
fn deploy_prompt_carries_dir_gateway_and_rule() {
let url = gateway_access_url("http://os", "svc");
let p = deploy_prompt("/repos/svc", "svc", &url);
assert!(p.contains("/repos/svc"));
assert!(p.contains("http://os/apps/svc")); assert!(p.contains("<gateway-origin>/apps/<project-slug>")); assert!(p.contains("run its tests") && p.contains("gateway"));
assert!(p.contains("OUTSIDE this session's workspace") && p.contains("bash"));
}
}