fix(tui): hide internal IDs from normal UI — stable labels for turns and agents (#3030)

Three changes to replace raw UUIDs/hex-ids with stable user-facing labels:

1. Turn label: Add turn_counter to App, display "Turn N" instead of the
   raw runtime_turn_id UUID prefix.  Full UUID preserved in hover text.

2. Agent labels: Add agent_counter + agent_label_map to App.  Populated
   on AgentSpawned; sidebar rows use "Agent 1", "Agent 2" etc. instead
   of agent_<hex>.  Nicknames and user-assigned names still take priority.

3. Step counter: Add format_step_counter() helper.  When max_steps is
   u32::MAX (the unbounded sentinel), renders "step 16" instead of the
   meaningless "step 16/4294967295".  Concrete step budgets still show
   the denominator.
This commit is contained in:
Hunter Bown
2026-06-10 15:52:34 -07:00
parent b23067bacd
commit ec0789daf4
4 changed files with 83 additions and 34 deletions
+26 -13
View File
@@ -70,6 +70,18 @@ fn release_resident_leases_for(agent_id: &str) {
/// the `SubAgentManager`.
const DEFAULT_MAX_STEPS: u32 = u32::MAX;
const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
/// Format a step counter for sub-agent progress messages.
///
/// When `max_steps == u32::MAX` (the default), the denominator is a sentinel
/// meaning "unbounded" — render just `step N` instead of `step N/4294967295`.
fn format_step_counter(steps: u32, max_steps: u32) -> String {
if max_steps == u32::MAX {
format!("step {steps}")
} else {
format!("step {steps}/{max_steps}")
}
}
// Non-streaming sub-agents need enough response budget to carry large tool-call
// arguments, especially write_file content. The API bills generated tokens, not
// the requested ceiling.
@@ -4158,7 +4170,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: cancelled"),
format!("{}: cancelled", format_step_counter(steps, max_steps)),
);
if let Some(mb) = runtime.mailbox.as_ref() {
let _ = mb.send(MailboxMessage::Cancelled {
@@ -4210,7 +4222,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: requesting model response"),
format!("{}: requesting model response", format_step_counter(steps, max_steps)),
);
while let Ok(input) = input_rx.try_recv() {
@@ -4267,7 +4279,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: cancelled mid-request"),
format!("{}: cancelled mid-request", format_step_counter(steps, max_steps)),
);
if let Some(mb) = runtime.mailbox.as_ref() {
let _ = mb.send(MailboxMessage::Cancelled {
@@ -4330,7 +4342,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: interrupted; {reason}"),
format!("{}: interrupted; {reason}", format_step_counter(steps, max_steps)),
);
let status = SubAgentStatus::Interrupted(reason.clone());
let duration_ms =
@@ -4364,7 +4376,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: cancelled while interrupted"),
format!("{}: cancelled while interrupted", format_step_counter(steps, max_steps)),
);
if let Some(mb) = runtime.mailbox.as_ref() {
let _ = mb.send(MailboxMessage::Cancelled {
@@ -4488,7 +4500,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: {progress}"),
format!("{}: {progress}", format_step_counter(steps, max_steps)),
);
messages.push(Message {
role: "user".to_string(),
@@ -4524,7 +4536,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: complete"),
format!("{}: complete", format_step_counter(steps, max_steps)),
);
break;
}
@@ -4534,17 +4546,18 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!(
"step {steps}/{max_steps}: executing {} tool call(s)",
tool_uses.len()
),
format!(
"{}: executing {} tool call(s)",
format_step_counter(steps, max_steps),
tool_uses.len()
),
);
let mut tool_results: Vec<ContentBlock> = Vec::new();
for (tool_id, tool_name, tool_input) in tool_uses {
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: running tool '{tool_name}'"),
format!("{}: running tool '{tool_name}'", format_step_counter(steps, max_steps)),
);
if let Some(mb) = runtime.mailbox.as_ref() {
let _ = mb.send(MailboxMessage::ToolCallStarted {
@@ -4568,7 +4581,7 @@ async fn run_subagent(
record_agent_progress(
runtime,
&agent_id,
format!("step {steps}/{max_steps}: finished tool '{tool_name}'"),
format!("{}: finished tool '{tool_name}'", format_step_counter(steps, max_steps)),
);
if let Some(mb) = runtime.mailbox.as_ref() {
let _ = mb.send(MailboxMessage::ToolCallCompleted {
+13
View File
@@ -1445,6 +1445,13 @@ pub struct App {
pub pending_subagent_dispatch: Option<String>,
/// Animation anchor for status-strip active sub-agent spinner.
pub agent_activity_started_at: Option<Instant>,
/// Monotonic counter for stable agent labels (#3030).
/// Incremented each time a sub-agent is spawned; used to generate
/// "Agent 1", "Agent 2", etc.
pub agent_counter: u64,
/// Maps raw agent_id to a stable user-facing label (#3030).
/// Populated when `AgentSpawned` fires; read by sidebar rendering.
pub agent_label_map: HashMap<String, String>,
pub ui_theme: UiTheme,
/// Active named theme. Drives the cell-level color remap in
/// `tui::color_compat::ColorCompatBackend` so community presets
@@ -1628,6 +1635,9 @@ pub struct App {
pub runtime_turn_id: Option<String>,
/// Current runtime turn status (if known).
pub runtime_turn_status: Option<String>,
/// Monotonic turn counter for stable user-facing labels (#3030).
/// Incremented each time a new turn starts; displayed as "Turn N".
pub turn_counter: u64,
/// When the UI accepted a user message but has not observed `TurnStarted` yet.
pub dispatch_started_at: Option<Instant>,
@@ -2174,6 +2184,8 @@ impl App {
last_fanout_card_index: None,
pending_subagent_dispatch: None,
agent_activity_started_at: None,
agent_counter: 0,
agent_label_map: HashMap::new(),
ui_theme,
theme_id,
onboarding,
@@ -2262,6 +2274,7 @@ impl App {
last_balance_fetch: None,
runtime_turn_id: None,
runtime_turn_status: None,
turn_counter: 0,
dispatch_started_at: None,
workspace_context: None,
workspace_context_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
+37 -21
View File
@@ -767,21 +767,22 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec<Lin
let theme = &app.ui_theme;
let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_rows.max(4));
if let Some(turn_id) = app.runtime_turn_id.as_ref() {
if app.runtime_turn_id.is_some() {
let status = app
.runtime_turn_status
.as_deref()
.unwrap_or("unknown")
.to_string();
// Show enough of the turn id prefix to identify it for
// task_read / task_cancel. A UUID needs ~13 chars before the
// first hyphen; 16 chars gives a safe prefix for disambiguation.
let turn_prefix = truncate_line_to_width(turn_id, 16);
// #3030: Use a stable turn number ("Turn 1") instead of the raw
// UUID prefix. The full UUID is preserved in the hover text
// (task_panel_hover_texts) for inspection.
let turn_label = if app.turn_counter > 0 {
format!("Turn {} ({status})", app.turn_counter)
} else {
format!("Current turn ({status})")
};
lines.push(Line::from(Span::styled(
truncate_line_to_width(
&format!("turn {turn_prefix} ({status})",),
content_width.max(1),
),
truncate_line_to_width(&turn_label, content_width.max(1)),
Style::default().fg(theme.accent_primary),
)));
}
@@ -1834,9 +1835,16 @@ fn sidebar_agent_rows(app: &App) -> Vec<SidebarAgentRow> {
.map(summarize_tool_output)
.filter(|summary| !summary.trim().is_empty())
});
// #3030: Prefer stable label ("Agent 1") > nickname > raw name.
let display_name = app
.agent_label_map
.get(&agent.agent_id)
.cloned()
.or_else(|| agent.nickname.clone())
.unwrap_or_else(|| agent.name.clone());
SidebarAgentRow {
id: agent.agent_id.clone(),
name: agent.nickname.clone().unwrap_or_else(|| agent.name.clone()),
name: display_name,
role: agent.agent_type.as_str().to_string(),
status: subagent_status_text(&agent.status).to_string(),
git_branch: agent.git_branch.clone(),
@@ -1856,17 +1864,25 @@ fn sidebar_agent_rows(app: &App) -> Vec<SidebarAgentRow> {
app.agent_progress
.iter()
.filter(|(id, _)| !cached_ids.contains(id.as_str()))
.map(|(id, progress)| SidebarAgentRow {
id: id.clone(),
name: id.clone(),
role: "agent".to_string(),
status: "running".to_string(),
git_branch: None,
progress: Some(progress.clone()),
steps_taken: 0,
duration_ms: app.agent_activity_started_at.map(|started| {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}),
.map(|(id, progress)| {
// #3030: Prefer stable label for progress-only agents too.
let display_name = app
.agent_label_map
.get(id.as_str())
.cloned()
.unwrap_or_else(|| id.clone());
SidebarAgentRow {
id: id.clone(),
name: display_name,
role: "agent".to_string(),
status: "running".to_string(),
git_branch: None,
progress: Some(progress.clone()),
steps_taken: 0,
duration_ms: app.agent_activity_started_at.map(|started| {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}),
}
}),
);
+7
View File
@@ -1791,6 +1791,7 @@ async fn run_event_loop(
}
app.runtime_turn_id = Some(turn_id);
app.runtime_turn_status = Some("in_progress".to_string());
app.turn_counter = app.turn_counter.saturating_add(1);
app.reasoning_buffer.clear();
app.reasoning_header = None;
app.last_reasoning = None;
@@ -2301,6 +2302,12 @@ async fn run_event_loop(
if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
// #3030: Assign a stable user-facing label for this agent.
if !app.agent_label_map.contains_key(&id) {
app.agent_counter = app.agent_counter.saturating_add(1);
app.agent_label_map
.insert(id.clone(), format!("Agent {}", app.agent_counter));
}
app.status_message =
Some(format!("Sub-agent {id} starting: {prompt_summary}"));
let _ = engine_handle.send(Op::ListSubAgents).await;