mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui,core): add /agents; smoother animations; safe branch names
tui: - Replace verbose “...ing” labels with concise past-tense labels - Schedule animation frames with ScheduleFrameIn to avoid debounce drops - Add /agents output to list active/configured agents and PATH status - Update bottom-pane status text for search core/agent_tool: - Sanitize/shorten model/agent parts for branch IDs (git-safe) - Pass --skip-git-repo-check to code/codex exec invocations docs: - Prompt coder wording tweak Build: ./build-fast.sh passed locally with no warnings
This commit is contained in:
Generated
+1
@@ -1130,6 +1130,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"vt100",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -34,7 +34,7 @@ The browser will either be an internal headless browser, or a CPD connection to
|
||||
|
||||
Two native web utilities are available for quick research and retrieval.
|
||||
|
||||
- Website fetch: Retrieve a URL and return readable Markdown/HTML for quoting and synthesis.
|
||||
- Web fetch: Retrieve a URL and return readable Markdown for quoting and synthesis.
|
||||
|
||||
web_fetch {
|
||||
"url": "https://example.com/some-article",
|
||||
@@ -42,6 +42,8 @@ web_fetch {
|
||||
"timeout_ms": 20000 // Optional; defaults to tool standard
|
||||
}
|
||||
|
||||
**NOTICE:** web_fetch is very fast and token efficient. It should be used by default to read webpages over using full browser tools.
|
||||
|
||||
- Web search: Perform a general web search (optionally scoped to domains) and surface results in the UI with live status.
|
||||
|
||||
web_search {
|
||||
|
||||
@@ -366,15 +366,37 @@ async fn get_git_root() -> Result<PathBuf, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_ref_component(s: &str) -> String {
|
||||
// Git refname safe component: [a-z0-9-]+, collapse invalid runs to '-'
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut last_dash = false;
|
||||
for ch in s.chars() {
|
||||
let c = ch.to_ascii_lowercase();
|
||||
let valid = c.is_ascii_alphanumeric() || c == '-';
|
||||
if valid {
|
||||
out.push(c);
|
||||
last_dash = c == '-';
|
||||
} else if !last_dash {
|
||||
out.push('-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
// Trim leading/trailing '-'
|
||||
let out = out.trim_matches('-').to_string();
|
||||
// Avoid empty component
|
||||
if out.is_empty() { "agent".to_string() } else { out }
|
||||
}
|
||||
|
||||
fn generate_branch_id(model: &str, agent: &str) -> String {
|
||||
// Extract first few meaningful words from agent for the branch name
|
||||
let stop = ["the", "and", "for", "with", "from", "into", "goal"]; // skip boilerplate
|
||||
let words: Vec<&str> = agent
|
||||
.split_whitespace()
|
||||
.filter(|w| w.len() > 2 && !["the", "and", "for", "with", "from", "into"].contains(w))
|
||||
.filter(|w| w.len() > 2 && !stop.contains(&w.to_ascii_lowercase().as_str()))
|
||||
.take(3)
|
||||
.collect();
|
||||
|
||||
let agent_suffix = if words.is_empty() {
|
||||
let raw_suffix = if words.is_empty() {
|
||||
Uuid::new_v4()
|
||||
.to_string()
|
||||
.split('-')
|
||||
@@ -382,10 +404,23 @@ fn generate_branch_id(model: &str, agent: &str) -> String {
|
||||
.unwrap_or("agent")
|
||||
.to_string()
|
||||
} else {
|
||||
words.join("-").to_lowercase()
|
||||
words.join("-")
|
||||
};
|
||||
|
||||
format!("code-{}-{}", model, agent_suffix)
|
||||
// Sanitize both model and suffix for safety
|
||||
let model_s = sanitize_ref_component(model);
|
||||
let mut suffix_s = sanitize_ref_component(&raw_suffix);
|
||||
|
||||
// Constrain length to keep branch names readable
|
||||
if suffix_s.len() > 40 {
|
||||
suffix_s.truncate(40);
|
||||
suffix_s = suffix_s.trim_matches('-').to_string();
|
||||
if suffix_s.is_empty() {
|
||||
suffix_s = "agent".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
format!("code-{}-{}", model_s, suffix_s)
|
||||
}
|
||||
|
||||
async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<PathBuf, String> {
|
||||
@@ -638,9 +673,9 @@ async fn execute_model_with_permissions(
|
||||
}
|
||||
"codex" | "code" => {
|
||||
if read_only {
|
||||
cmd.args(&["-s", "read-only", "-a", "never", "exec", prompt]);
|
||||
cmd.args(&["-s", "read-only", "-a", "never", "exec", "--skip-git-repo-check", prompt]);
|
||||
} else {
|
||||
cmd.args(&["-s", "workspace-write", "-a", "never", "exec", prompt]);
|
||||
cmd.args(&["-s", "workspace-write", "-a", "never", "exec", "--skip-git-repo-check", prompt]);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -673,9 +708,9 @@ async fn execute_model_with_permissions(
|
||||
)),
|
||||
};
|
||||
if read_only {
|
||||
fb.args(["-s", "read-only", "-a", "never", "exec", prompt]);
|
||||
fb.args(["-s", "read-only", "-a", "never", "exec", "--skip-git-repo-check", prompt]);
|
||||
} else {
|
||||
fb.args(["-s", "workspace-write", "-a", "never", "exec", prompt]);
|
||||
fb.args(["-s", "workspace-write", "-a", "never", "exec", "--skip-git-repo-check", prompt]);
|
||||
}
|
||||
fb.output().await.map_err(|e2| {
|
||||
format!(
|
||||
|
||||
@@ -83,6 +83,7 @@ unicode-width = "0.1"
|
||||
url = "2"
|
||||
uuid = "1"
|
||||
syntect = { version = "5", features = ["yaml-load", "plist-load", "parsing", "default-syntaxes"] }
|
||||
which = "6"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -587,6 +587,11 @@ impl App<'_> {
|
||||
widget.add_status_output();
|
||||
}
|
||||
}
|
||||
SlashCommand::Agents => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_agents_output();
|
||||
}
|
||||
}
|
||||
SlashCommand::Reasoning => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_reasoning_command(command_args);
|
||||
|
||||
@@ -993,7 +993,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Update status: show that a command is running
|
||||
if !self.running_web_search.is_empty() {
|
||||
self.bottom_pane.update_status_text("Searching...".to_string());
|
||||
self.bottom_pane.update_status_text("Searched".to_string());
|
||||
} else {
|
||||
let preview = self
|
||||
.running_commands
|
||||
@@ -1124,18 +1124,18 @@ impl ChatWidget<'_> {
|
||||
let _exec_label = |e: &history_cell::ExecCell| -> &'static str {
|
||||
let action = history_cell::action_from_parsed(&e.parsed);
|
||||
match (&e.output, action) {
|
||||
(None, "read") => "Reading...",
|
||||
(None, "search") => "Searching...",
|
||||
(None, "list") => "Listing...",
|
||||
(None, "read") => "Read",
|
||||
(None, "search") => "Searched",
|
||||
(None, "list") => "List Files",
|
||||
(None, _) => "Running...",
|
||||
(Some(o), "read") if o.exit_code == 0 => "Read",
|
||||
(Some(o), "search") if o.exit_code == 0 => "Searched",
|
||||
(Some(o), "list") if o.exit_code == 0 => "Listed",
|
||||
(Some(o), "list") if o.exit_code == 0 => "List Files",
|
||||
(Some(o), _) if o.exit_code == 0 => "Ran",
|
||||
_ => "",
|
||||
}
|
||||
};
|
||||
let _is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "Listed" | "Ran" | "Reading..." | "Searching..." | "Listing..." | "Running...");
|
||||
let _is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "List Files" | "Ran" | "Running...");
|
||||
|
||||
// New cell must be an ExecCell with completed output
|
||||
let new_exec = match self.history_cells[idx]
|
||||
@@ -1846,8 +1846,10 @@ impl ChatWidget<'_> {
|
||||
/// Check if there are any animations and trigger redraw if needed
|
||||
pub fn check_for_initial_animations(&mut self) {
|
||||
if self.history_cells.iter().any(|cell| cell.is_animating()) {
|
||||
tracing::info!("Initial animation detected, triggering redraw");
|
||||
self.mark_needs_redraw();
|
||||
tracing::info!("Initial animation detected, scheduling frame");
|
||||
// Schedule initial frame for animations to ensure they start properly.
|
||||
// Use ScheduleFrameIn to avoid debounce issues with immediate RequestRedraw.
|
||||
self.app_event_tx.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(20)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2259,20 +2261,20 @@ impl ChatWidget<'_> {
|
||||
let exec_label = |e: &history_cell::ExecCell| -> &'static str {
|
||||
let action = history_cell::action_from_parsed(&e.parsed);
|
||||
match (&e.output, action) {
|
||||
(None, "read") => "Reading...",
|
||||
(None, "search") => "Searching...",
|
||||
(None, "list") => "Listing...",
|
||||
(None, "read") => "Read",
|
||||
(None, "search") => "Searched",
|
||||
(None, "list") => "List Files",
|
||||
(None, _) => "Running...",
|
||||
(Some(o), "read") if o.exit_code == 0 => "Read",
|
||||
(Some(o), "search") if o.exit_code == 0 => "Searched",
|
||||
(Some(o), "list") if o.exit_code == 0 => "Listed",
|
||||
(Some(o), "list") if o.exit_code == 0 => "List Files",
|
||||
(Some(o), _) if o.exit_code == 0 => "Ran",
|
||||
_ => "",
|
||||
}
|
||||
};
|
||||
let last_label = exec_label(last_exec);
|
||||
let new_label = exec_label(new_exec);
|
||||
let is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "Listed" | "Ran" | "Reading..." | "Searching..." | "Listing..." | "Running...");
|
||||
let is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "List Files" | "Ran" | "Running...");
|
||||
if !last_label.is_empty() && last_label == new_label && is_joinable_label(last_label) {
|
||||
// Merge as a styled MergedExecCell to preserve per-exec formatting
|
||||
let mut merged = history_cell::MergedExecCell::from_exec(last_exec);
|
||||
@@ -2699,7 +2701,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
// Reflect status in the input border
|
||||
self.bottom_pane.update_status_text("Searching...".to_string());
|
||||
self.bottom_pane.update_status_text("Searched".to_string());
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
@@ -3379,6 +3381,78 @@ impl ChatWidget<'_> {
|
||||
self.add_to_history(history_cell::new_prompts_output());
|
||||
}
|
||||
|
||||
pub(crate) fn add_agents_output(&mut self) {
|
||||
use ratatui::text::Line;
|
||||
|
||||
// Gather active agents from current UI state
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("/agents".magenta()));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Section: Active agents
|
||||
lines.push(Line::from(vec!["🤖 ".into(), "Active Agents".bold()]));
|
||||
if self.active_agents.is_empty() {
|
||||
if self.agents_ready_to_start {
|
||||
lines.push(Line::from(" • preparing agents…"));
|
||||
} else {
|
||||
lines.push(Line::from(" • No active agents"));
|
||||
}
|
||||
} else {
|
||||
for a in &self.active_agents {
|
||||
let status = match a.status {
|
||||
AgentStatus::Pending => "pending",
|
||||
AgentStatus::Running => "running",
|
||||
AgentStatus::Completed => "completed",
|
||||
AgentStatus::Failed => "failed",
|
||||
};
|
||||
lines.push(Line::from(format!(" • {} — {}", a.name, status)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Section: Availability
|
||||
lines.push(Line::from(vec!["🧭 ".into(), "Availability".bold()]));
|
||||
|
||||
// Determine which agents to check: configured (enabled) or defaults
|
||||
let mut to_check: Vec<(String, String, bool)> = Vec::new();
|
||||
if !self.config.agents.is_empty() {
|
||||
for a in &self.config.agents {
|
||||
if !a.enabled { continue; }
|
||||
let name = a.name.clone();
|
||||
let cmd = a.command.clone();
|
||||
let builtin = matches!(cmd.as_str(), "code" | "codex");
|
||||
to_check.push((name, cmd, builtin));
|
||||
}
|
||||
} else {
|
||||
to_check.push(("claude".to_string(), "claude".to_string(), false));
|
||||
to_check.push(("gemini".to_string(), "gemini".to_string(), false));
|
||||
to_check.push(("code".to_string(), "code".to_string(), true));
|
||||
}
|
||||
|
||||
// Helper: PATH presence
|
||||
let command_exists = |cmd: &str| -> bool { which::which(cmd).map(|p| p.is_file()).unwrap_or(false) };
|
||||
|
||||
for (name, cmd, builtin) in to_check {
|
||||
if builtin {
|
||||
lines.push(Line::from(format!(" • {} — available (built-in)", name)));
|
||||
} else if command_exists(&cmd) {
|
||||
lines.push(Line::from(format!(" • {} — available ({} in PATH)", name, cmd)));
|
||||
} else {
|
||||
lines.push(Line::from(format!(" • {} — not found (command: {})", name, cmd)));
|
||||
// Short cross-platform hint
|
||||
lines.push(Line::from(" Debug: ensure the CLI is installed and on PATH"));
|
||||
lines.push(Line::from(" Windows: run `where <cmd>`; macOS/Linux: `which <cmd>`"));
|
||||
}
|
||||
}
|
||||
|
||||
self.add_to_history(crate::history_cell::PlainHistoryCell {
|
||||
lines,
|
||||
kind: crate::history_cell::HistoryCellType::Notice,
|
||||
});
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_diffs_popup(&mut self) {
|
||||
use crate::diff_render::create_diff_details_only;
|
||||
// Build a latest-first unique file list
|
||||
@@ -6694,9 +6768,10 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
let has_active_animation = self.history_cells.iter().any(|cell| cell.is_animating());
|
||||
|
||||
if has_active_animation {
|
||||
tracing::debug!("Active animation detected, requesting redraw");
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
} else {
|
||||
tracing::debug!("Active animation detected, scheduling next frame");
|
||||
// Schedule next frame after debounce window to avoid dropped requests.
|
||||
// Using 20ms ensures we're past the 16ms debounce and maintain smooth animation.
|
||||
self.app_event_tx.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(20)));
|
||||
}
|
||||
|
||||
// Calculate scroll position and vertical alignment
|
||||
|
||||
@@ -1815,9 +1815,9 @@ fn exec_render_parts_parsed(
|
||||
String::new()
|
||||
};
|
||||
let header = match action {
|
||||
"read" => "Reading...".to_string(),
|
||||
"search" => "Searching...".to_string(),
|
||||
"list" => "Listing files...".to_string(),
|
||||
"read" => "Read".to_string(),
|
||||
"search" => "Searched".to_string(),
|
||||
"list" => "List Files".to_string(),
|
||||
_ => match &ctx_path {
|
||||
Some(p) => format!("Running... in {}", p),
|
||||
None => "Running...".to_string(),
|
||||
@@ -1896,7 +1896,7 @@ fn exec_render_parts_parsed(
|
||||
let expected_label: Option<&'static str> = match action {
|
||||
"read" => Some("Read"),
|
||||
"search" => Some("Search"),
|
||||
"list" => Some("List"),
|
||||
"list" => Some("List Files"),
|
||||
_ => None, // run: allow a set of labels
|
||||
};
|
||||
for parsed in parsed_commands.iter() {
|
||||
@@ -1918,10 +1918,10 @@ fn exec_render_parts_parsed(
|
||||
} else {
|
||||
format!("{}/", p)
|
||||
};
|
||||
("List".to_string(), display_p)
|
||||
("List Files".to_string(), display_p)
|
||||
}
|
||||
}
|
||||
None => ("List".to_string(), cmd.clone()),
|
||||
None => ("List Files".to_string(), cmd.clone()),
|
||||
},
|
||||
ParsedCommand::Search { query, path, cmd } => {
|
||||
// Make search terms human-readable:
|
||||
@@ -4277,9 +4277,9 @@ fn new_parsed_command(
|
||||
};
|
||||
// Running state per action
|
||||
let header = match action {
|
||||
"read" => "Reading...".to_string(),
|
||||
"search" => "Searching...".to_string(),
|
||||
"list" => "Listing files...".to_string(),
|
||||
"read" => "Read".to_string(),
|
||||
"search" => "Searched".to_string(),
|
||||
"list" => "List Files".to_string(),
|
||||
_ => match &ctx_path {
|
||||
Some(p) => format!("Running... in {p}"),
|
||||
None => "Running...".to_string(),
|
||||
@@ -4366,7 +4366,7 @@ fn new_parsed_command(
|
||||
let expected_label: Option<&'static str> = match action {
|
||||
"read" => Some("Read"),
|
||||
"search" => Some("Search"),
|
||||
"list" => Some("List"),
|
||||
"list" => Some("List Files"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -4390,10 +4390,10 @@ fn new_parsed_command(
|
||||
} else {
|
||||
format!("{p}/")
|
||||
};
|
||||
("List".to_string(), display_p)
|
||||
("List Files".to_string(), display_p)
|
||||
}
|
||||
}
|
||||
None => ("List".to_string(), cmd.clone()),
|
||||
None => ("List Files".to_string(), cmd.clone()),
|
||||
},
|
||||
ParsedCommand::Search { query, path, cmd } => {
|
||||
// Format query for display: unescape backslash-escapes and close common unbalanced delimiters
|
||||
@@ -4604,7 +4604,7 @@ fn new_parsed_command(
|
||||
}
|
||||
}
|
||||
// List Files: highlight directory names
|
||||
"List" => {
|
||||
"List Files" => {
|
||||
spans.push(Span::styled(
|
||||
line_text.to_string(),
|
||||
Style::default().fg(crate::colors::text()),
|
||||
|
||||
@@ -25,6 +25,7 @@ pub enum SlashCommand {
|
||||
Verbosity,
|
||||
Prompts,
|
||||
Perf,
|
||||
Agents,
|
||||
Resume,
|
||||
// Prompt-expanding commands
|
||||
Plan,
|
||||
@@ -57,6 +58,7 @@ impl SlashCommand {
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Theme => "switch between color themes",
|
||||
SlashCommand::Prompts => "show example prompts",
|
||||
SlashCommand::Agents => "list agents (running and availability)",
|
||||
SlashCommand::Perf => "performance tracing (on/off/show/reset)",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
#[cfg(debug_assertions)]
|
||||
|
||||
Reference in New Issue
Block a user