feat: support agent view replies and row controls
This commit is contained in:
parent
0e2214aeff
commit
c7d2c74437
|
|
@ -161,7 +161,7 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
|
||||||
- `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself.
|
- `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself.
|
||||||
- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime.
|
- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime.
|
||||||
- `RemoteTrigger` performs direct HTTP requests, but it has not been validated against every upstream host-trigger edge case.
|
- `RemoteTrigger` performs direct HTTP requests, but it has not been validated against every upstream host-trigger edge case.
|
||||||
- Agent view has a local supervisor, official state files, shell lifecycle commands, raw `agents --json` arrays, background worker spawning, a basic full-screen TUI, and terminal attach into resumable prompt sessions once workers record their managed session id. Remaining gaps are peek-panel replies, row pin/rename/reorder/collapse shortcuts, generated row summaries/PR status, `/bg` or left-arrow handoff from an already-running foreground session, and a real supervisor socket/pre-warmed worker pool.
|
- Agent view has a local supervisor, official state files, shell lifecycle commands, raw `agents --json` arrays, background worker spawning, a basic full-screen TUI, queued peek replies into resumable prompt sessions without live workers, row pin/rename/reorder/collapse shortcuts, directory/state grouping, and terminal attach once workers record their managed session id. Remaining gaps are live in-process peek reply routing, generated row summaries/PR status, notification hooks for background completion/needs-input, `/bg` or left-arrow handoff from an already-running foreground session, and a real supervisor socket/pre-warmed worker pool.
|
||||||
- `TestingPermission` remains test-only.
|
- `TestingPermission` remains test-only.
|
||||||
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.
|
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.
|
||||||
- Bash deep validation remains branch-only until `36dac6c` is merged.
|
- Bash deep validation remains branch-only until `36dac6c` is merged.
|
||||||
|
|
|
||||||
2
USAGE.md
2
USAGE.md
|
|
@ -570,7 +570,7 @@ Agent view follows the Claude Code background-session layout. `CLAUDE_CONFIG_DIR
|
||||||
./target/debug/claw daemon status
|
./target/debug/claw daemon status
|
||||||
```
|
```
|
||||||
|
|
||||||
When stdout/stdin are attached to a terminal, `claw agents` opens a full-screen view. Type a prompt and press Enter to dispatch a new background session, use Up/Down to select rows, Space to peek at captured output, Enter or Right to attach, `s`/`k` to stop or kill, `r` to respawn, and `d` twice to remove a row. In JSON mode or non-TTY automation it keeps the structured list behavior.
|
When stdout/stdin are attached to a terminal, `claw agents` opens a full-screen view. Type a prompt and press Enter to dispatch a new background session, use Up/Down to select rows, Space to peek at captured output, type while peek is open and press Enter to send a reply to a resumable session without a live worker, Enter or Right on an empty input to attach or fold a group header, `Ctrl+S` to group by directory/state, `Ctrl+T` to pin a row, `Ctrl+R` to rename it, `Shift+Up`/`Shift+Down` to reorder within a group, `Ctrl+X` to stop and press `Ctrl+X` again to remove, `s`/`k` to stop or kill, `r` to respawn, and `d` twice to remove a row. In JSON mode or non-TTY automation it keeps the structured list behavior.
|
||||||
|
|
||||||
## Session management
|
## Session management
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
|
||||||
| **WebSearch** | `tools` | search query execution — **moderate parity** |
|
| **WebSearch** | `tools` | search query execution — **moderate parity** |
|
||||||
| **TodoWrite** | `tools` | todo/note persistence — **moderate parity** |
|
| **TodoWrite** | `tools` | todo/note persistence — **moderate parity** |
|
||||||
| **Skill** | `tools` | skill discovery/install — **moderate parity** |
|
| **Skill** | `tools` | skill discovery/install — **moderate parity** |
|
||||||
| **Agent** | `tools` + `runtime::agent_supervisor` + `commands` | agent delegation plus Claude Code agent-view state under `~/.claude/jobs/<id>/state.json`, `daemon/roster.json`, `claw agents --json`, `--bg`, shell lifecycle commands, basic full-screen TUI, and attach into resumable prompt sessions — **moderate parity**; peek replies, row organization shortcuts, `/bg` foreground handoff, and real supervisor socket/worker-pool semantics remain partial |
|
| **Agent** | `tools` + `runtime::agent_supervisor` + `commands` | agent delegation plus Claude Code agent-view state under `~/.claude/jobs/<id>/state.json`, `daemon/roster.json`, `claw agents --json`, `--bg`, shell lifecycle commands, basic full-screen TUI, queued peek replies into resumable prompt sessions without live workers, row pin/rename/reorder/collapse/grouping shortcuts, and attach into resumable prompt sessions — **moderate parity**; live in-process reply routing, generated row summaries/PR status, notifications, `/bg` foreground handoff, and real supervisor socket/worker-pool semantics remain partial |
|
||||||
| **TaskCreate** | `runtime::task_registry` + `tools` | in-memory task creation wired into tool dispatch — **good parity** |
|
| **TaskCreate** | `runtime::task_registry` + `tools` | in-memory task creation wired into tool dispatch — **good parity** |
|
||||||
| **TaskGet** | `runtime::task_registry` + `tools` | task lookup + metadata payload — **good parity** |
|
| **TaskGet** | `runtime::task_registry` + `tools` | task lookup + metadata payload — **good parity** |
|
||||||
| **TaskList** | `runtime::task_registry` + `tools` | registry-backed task listing — **good parity** |
|
| **TaskList** | `runtime::task_registry` + `tools` | registry-backed task listing — **good parity** |
|
||||||
|
|
|
||||||
|
|
@ -2241,6 +2241,9 @@ struct AgentRunView {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
name: String,
|
name: String,
|
||||||
description: String,
|
description: String,
|
||||||
|
pending_prompt: Option<String>,
|
||||||
|
pinned: bool,
|
||||||
|
display_order: Option<i64>,
|
||||||
subagent_type: Option<String>,
|
subagent_type: Option<String>,
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
status: String,
|
status: String,
|
||||||
|
|
@ -4059,6 +4062,9 @@ fn load_agent_views(options: &AgentViewOptions) -> std::io::Result<AgentViewColl
|
||||||
.clone()
|
.clone()
|
||||||
.or(job.command.clone())
|
.or(job.command.clone())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
pending_prompt: job.pending_prompt.clone(),
|
||||||
|
pinned: job.pinned,
|
||||||
|
display_order: job.display_order,
|
||||||
subagent_type: job.agent.clone(),
|
subagent_type: job.agent.clone(),
|
||||||
model: job.model.clone(),
|
model: job.model.clone(),
|
||||||
status: job.status.unwrap_or_else(|| "unknown".to_string()),
|
status: job.status.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
|
@ -4712,6 +4718,15 @@ fn render_agent_views_report(collection: &AgentViewCollection, target: Option<&s
|
||||||
if let Some(pid) = view.pid {
|
if let Some(pid) = view.pid {
|
||||||
lines.push(format!(" pid {pid}"));
|
lines.push(format!(" pid {pid}"));
|
||||||
}
|
}
|
||||||
|
if view.pinned {
|
||||||
|
lines.push(" pinned true".to_string());
|
||||||
|
}
|
||||||
|
if let Some(display_order) = view.display_order {
|
||||||
|
lines.push(format!(" order {display_order}"));
|
||||||
|
}
|
||||||
|
if let Some(pending_prompt) = &view.pending_prompt {
|
||||||
|
lines.push(format!(" pending {pending_prompt}"));
|
||||||
|
}
|
||||||
if let Some(waiting_for) = &view.waiting_for {
|
if let Some(waiting_for) = &view.waiting_for {
|
||||||
lines.push(format!(" waiting_for {waiting_for}"));
|
lines.push(format!(" waiting_for {waiting_for}"));
|
||||||
}
|
}
|
||||||
|
|
@ -4812,9 +4827,16 @@ fn agent_view_json(view: &AgentRunView) -> Value {
|
||||||
value.insert("sessionId".to_string(), json!(session_id));
|
value.insert("sessionId".to_string(), json!(session_id));
|
||||||
}
|
}
|
||||||
value.insert("name".to_string(), json!(&view.name));
|
value.insert("name".to_string(), json!(&view.name));
|
||||||
|
value.insert("pinned".to_string(), json!(view.pinned));
|
||||||
|
if let Some(display_order) = view.display_order {
|
||||||
|
value.insert("displayOrder".to_string(), json!(display_order));
|
||||||
|
}
|
||||||
if !view.description.is_empty() {
|
if !view.description.is_empty() {
|
||||||
value.insert("prompt".to_string(), json!(&view.description));
|
value.insert("prompt".to_string(), json!(&view.description));
|
||||||
}
|
}
|
||||||
|
if let Some(pending_prompt) = &view.pending_prompt {
|
||||||
|
value.insert("pendingPrompt".to_string(), json!(pending_prompt));
|
||||||
|
}
|
||||||
if let Some(model) = &view.model {
|
if let Some(model) = &view.model {
|
||||||
value.insert("model".to_string(), json!(model));
|
value.insert("model".to_string(), json!(model));
|
||||||
}
|
}
|
||||||
|
|
@ -6945,6 +6967,16 @@ mod tests {
|
||||||
reasoning_effort: Some("medium".to_string()),
|
reasoning_effort: Some("medium".to_string()),
|
||||||
})
|
})
|
||||||
.expect("new job");
|
.expect("new job");
|
||||||
|
supervisor
|
||||||
|
.set_session_id(&new.id, "session-new")
|
||||||
|
.expect("session id");
|
||||||
|
supervisor
|
||||||
|
.queue_reply(&new.id, "follow up")
|
||||||
|
.expect("reply queued");
|
||||||
|
supervisor.set_pinned(&new.id, true).expect("pin");
|
||||||
|
supervisor
|
||||||
|
.set_display_order(&new.id, 10)
|
||||||
|
.expect("display order");
|
||||||
supervisor.set_process(&new.id, 4242).expect("pid");
|
supervisor.set_process(&new.id, 4242).expect("pid");
|
||||||
|
|
||||||
let report = handle_agents_slash_command_json(Some("--all"), &workspace)
|
let report = handle_agents_slash_command_json(Some("--all"), &workspace)
|
||||||
|
|
@ -6961,6 +6993,9 @@ mod tests {
|
||||||
.find(|session| session["id"] == new.id)
|
.find(|session| session["id"] == new.id)
|
||||||
.expect("new session");
|
.expect("new session");
|
||||||
assert_eq!(new_session["state"], "working");
|
assert_eq!(new_session["state"], "working");
|
||||||
|
assert_eq!(new_session["pendingPrompt"], "follow up");
|
||||||
|
assert_eq!(new_session["pinned"], true);
|
||||||
|
assert_eq!(new_session["displayOrder"], 10);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
new_session["cwd"],
|
new_session["cwd"],
|
||||||
canonical_workspace.display().to_string()
|
canonical_workspace.display().to_string()
|
||||||
|
|
@ -6970,12 +7005,18 @@ mod tests {
|
||||||
let raw = raw.as_array().expect("array");
|
let raw = raw.as_array().expect("array");
|
||||||
assert_eq!(raw.len(), 1);
|
assert_eq!(raw.len(), 1);
|
||||||
assert_eq!(raw[0]["id"], new.id);
|
assert_eq!(raw[0]["id"], new.id);
|
||||||
|
assert_eq!(raw[0]["pendingPrompt"], "follow up");
|
||||||
|
assert_eq!(raw[0]["pinned"], true);
|
||||||
|
assert_eq!(raw[0]["displayOrder"], 10);
|
||||||
|
|
||||||
let text = super::handle_agents_slash_command(Some("--all"), &workspace)
|
let text = super::handle_agents_slash_command(Some("--all"), &workspace)
|
||||||
.expect("agent view text should render");
|
.expect("agent view text should render");
|
||||||
assert!(text.contains("Agent view"));
|
assert!(text.contains("Agent view"));
|
||||||
assert!(text.contains("explore-new"));
|
assert!(text.contains("explore-new"));
|
||||||
assert!(text.contains("state=working"));
|
assert!(text.contains("state=working"));
|
||||||
|
assert!(text.contains("pinned true"));
|
||||||
|
assert!(text.contains("order 10"));
|
||||||
|
assert!(text.contains("pending follow up"));
|
||||||
|
|
||||||
restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir);
|
restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir);
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,15 @@ pub struct AgentJobRecord {
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
pub pinned: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_order: Option<i64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt: Option<String>,
|
pub prompt: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pending_prompt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub command: Option<String>,
|
pub command: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
|
@ -153,6 +159,10 @@ pub struct AgentRosterEntry {
|
||||||
pub pid: Option<u32>,
|
pub pid: Option<u32>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
pub pinned: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_order: Option<i64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub started_at: Option<String>,
|
pub started_at: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -249,7 +259,10 @@ impl AgentSupervisor {
|
||||||
waiting_for: None,
|
waiting_for: None,
|
||||||
session_id: None,
|
session_id: None,
|
||||||
name: request.name,
|
name: request.name,
|
||||||
|
pinned: false,
|
||||||
|
display_order: None,
|
||||||
prompt: request.prompt,
|
prompt: request.prompt,
|
||||||
|
pending_prompt: None,
|
||||||
command: request.command,
|
command: request.command,
|
||||||
model: request.model,
|
model: request.model,
|
||||||
agent: request.agent,
|
agent: request.agent,
|
||||||
|
|
@ -347,6 +360,59 @@ impl AgentSupervisor {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn queue_reply(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
prompt: impl Into<String>,
|
||||||
|
) -> Result<AgentJobRecord, AgentSupervisorError> {
|
||||||
|
self.update_job(id, |record| {
|
||||||
|
record.pending_prompt = Some(prompt.into());
|
||||||
|
record.pid = None;
|
||||||
|
record.state = AgentJobState::Working;
|
||||||
|
record.status = Some("reply_queued".to_string());
|
||||||
|
record.waiting_for = None;
|
||||||
|
record.stopped_at = None;
|
||||||
|
record.completed_at = None;
|
||||||
|
record.exit_code = None;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pinned(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
pinned: bool,
|
||||||
|
) -> Result<AgentJobRecord, AgentSupervisorError> {
|
||||||
|
self.update_job(id, |record| {
|
||||||
|
record.pinned = pinned;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_job(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
name: impl Into<String>,
|
||||||
|
) -> Result<AgentJobRecord, AgentSupervisorError> {
|
||||||
|
let name = name.into();
|
||||||
|
self.update_job(id, |record| {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
record.name = if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_display_order(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
display_order: i64,
|
||||||
|
) -> Result<AgentJobRecord, AgentSupervisorError> {
|
||||||
|
self.update_job(id, |record| {
|
||||||
|
record.display_order = Some(display_order);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn append_log(&self, id: &str, text: &str) -> Result<AgentJobRecord, AgentSupervisorError> {
|
pub fn append_log(&self, id: &str, text: &str) -> Result<AgentJobRecord, AgentSupervisorError> {
|
||||||
let log_path = self.job_dir(id).join("output.log");
|
let log_path = self.job_dir(id).join("output.log");
|
||||||
if let Some(parent) = log_path.parent() {
|
if let Some(parent) = log_path.parent() {
|
||||||
|
|
@ -371,6 +437,7 @@ impl AgentSupervisor {
|
||||||
record.exit_code = Some(exit_code);
|
record.exit_code = Some(exit_code);
|
||||||
record.completed_at = Some(timestamp());
|
record.completed_at = Some(timestamp());
|
||||||
record.status = Some("exited".to_string());
|
record.status = Some("exited".to_string());
|
||||||
|
record.pending_prompt = None;
|
||||||
record.state = if exit_code == 0 {
|
record.state = if exit_code == 0 {
|
||||||
AgentJobState::Done
|
AgentJobState::Done
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -387,6 +454,7 @@ impl AgentSupervisor {
|
||||||
record.pid = None;
|
record.pid = None;
|
||||||
record.state = AgentJobState::Stopped;
|
record.state = AgentJobState::Stopped;
|
||||||
record.status = Some("stopped".to_string());
|
record.status = Some("stopped".to_string());
|
||||||
|
record.pending_prompt = None;
|
||||||
record.stopped_at = Some(timestamp());
|
record.stopped_at = Some(timestamp());
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -461,6 +529,8 @@ impl AgentSupervisor {
|
||||||
cwd: job.cwd.clone(),
|
cwd: job.cwd.clone(),
|
||||||
pid: job.pid,
|
pid: job.pid,
|
||||||
name: job.name.clone(),
|
name: job.name.clone(),
|
||||||
|
pinned: job.pinned,
|
||||||
|
display_order: job.display_order,
|
||||||
started_at: Some(job.started_at.clone()),
|
started_at: Some(job.started_at.clone()),
|
||||||
updated_at: Some(job.updated_at.clone()),
|
updated_at: Some(job.updated_at.clone()),
|
||||||
extra: BTreeMap::new(),
|
extra: BTreeMap::new(),
|
||||||
|
|
@ -530,6 +600,10 @@ fn tail(value: &str, max_chars: usize) -> String {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_false(value: &bool) -> bool {
|
||||||
|
!*value
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -573,6 +647,21 @@ mod tests {
|
||||||
.expect("list");
|
.expect("list");
|
||||||
assert_eq!(listed.len(), 1);
|
assert_eq!(listed.len(), 1);
|
||||||
assert_eq!(listed[0].name.as_deref(), Some("parser-fix"));
|
assert_eq!(listed[0].name.as_deref(), Some("parser-fix"));
|
||||||
|
assert!(!listed[0].pinned);
|
||||||
|
|
||||||
|
let pinned = supervisor.set_pinned(&job.id, true).expect("pin");
|
||||||
|
assert!(pinned.pinned);
|
||||||
|
let renamed = supervisor
|
||||||
|
.rename_job(&job.id, "parser review")
|
||||||
|
.expect("rename");
|
||||||
|
assert_eq!(renamed.name.as_deref(), Some("parser review"));
|
||||||
|
let ordered = supervisor
|
||||||
|
.set_display_order(&job.id, 20)
|
||||||
|
.expect("display order");
|
||||||
|
assert_eq!(ordered.display_order, Some(20));
|
||||||
|
let roster = supervisor.rewrite_roster().expect("roster");
|
||||||
|
assert!(roster.sessions[0].pinned);
|
||||||
|
assert_eq!(roster.sessions[0].display_order, Some(20));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(config);
|
let _ = fs::remove_dir_all(config);
|
||||||
}
|
}
|
||||||
|
|
@ -598,11 +687,18 @@ mod tests {
|
||||||
.expect("job");
|
.expect("job");
|
||||||
|
|
||||||
supervisor.set_process(&job.id, 1234).expect("pid");
|
supervisor.set_process(&job.id, 1234).expect("pid");
|
||||||
|
let queued = supervisor
|
||||||
|
.queue_reply(&job.id, "follow up")
|
||||||
|
.expect("reply queued");
|
||||||
|
assert_eq!(queued.pending_prompt.as_deref(), Some("follow up"));
|
||||||
|
assert_eq!(queued.status.as_deref(), Some("reply_queued"));
|
||||||
|
assert_eq!(queued.state, AgentJobState::Working);
|
||||||
supervisor.append_log(&job.id, "hello\n").expect("log");
|
supervisor.append_log(&job.id, "hello\n").expect("log");
|
||||||
let done = supervisor
|
let done = supervisor
|
||||||
.finish_job(&job.id, 0, Some("hello\n"))
|
.finish_job(&job.id, 0, Some("hello\n"))
|
||||||
.expect("done");
|
.expect("done");
|
||||||
assert_eq!(done.state, AgentJobState::Done);
|
assert_eq!(done.state, AgentJobState::Done);
|
||||||
|
assert_eq!(done.pending_prompt, None);
|
||||||
assert_eq!(supervisor.read_logs(&job.id).expect("logs"), "hello\n");
|
assert_eq!(supervisor.read_logs(&job.id).expect("logs"), "hello\n");
|
||||||
assert!(supervisor
|
assert!(supervisor
|
||||||
.list_jobs(&AgentListFilter::default())
|
.list_jobs(&AgentListFilter::default())
|
||||||
|
|
|
||||||
|
|
@ -7683,6 +7683,9 @@ struct AgentViewTuiState {
|
||||||
input: String,
|
input: String,
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
confirm_remove_id: Option<String>,
|
confirm_remove_id: Option<String>,
|
||||||
|
group_by_directory: bool,
|
||||||
|
collapsed_groups: BTreeSet<String>,
|
||||||
|
rename_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -7691,6 +7694,19 @@ enum AgentViewTuiExit {
|
||||||
Attach(String),
|
Attach(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum AgentViewRow {
|
||||||
|
Header {
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
collapsed: bool,
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
Job {
|
||||||
|
job_index: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
struct AgentViewTerminalGuard {
|
struct AgentViewTerminalGuard {
|
||||||
active: bool,
|
active: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -7772,6 +7788,9 @@ fn run_agent_view_tui(
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
status: None,
|
status: None,
|
||||||
confirm_remove_id: None,
|
confirm_remove_id: None,
|
||||||
|
group_by_directory: false,
|
||||||
|
collapsed_groups: BTreeSet::new(),
|
||||||
|
rename_mode: false,
|
||||||
};
|
};
|
||||||
let attach = {
|
let attach = {
|
||||||
let _terminal = AgentViewTerminalGuard::enter()?;
|
let _terminal = AgentViewTerminalGuard::enter()?;
|
||||||
|
|
@ -7790,9 +7809,10 @@ fn run_agent_view_tui_loop(
|
||||||
) -> Result<AgentViewTuiExit, Box<dyn std::error::Error>> {
|
) -> Result<AgentViewTuiExit, Box<dyn std::error::Error>> {
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
loop {
|
loop {
|
||||||
let jobs = load_agent_view_tui_jobs(supervisor, &state.options)?;
|
let jobs = load_agent_view_tui_jobs(supervisor, state)?;
|
||||||
if state.selected >= jobs.len() {
|
let rows = build_agent_view_rows(state, &jobs);
|
||||||
state.selected = jobs.len().saturating_sub(1);
|
if state.selected >= rows.len() {
|
||||||
|
state.selected = rows.len().saturating_sub(1);
|
||||||
}
|
}
|
||||||
render_agent_view_tui(&mut stdout, state, &jobs)?;
|
render_agent_view_tui(&mut stdout, state, &jobs)?;
|
||||||
if !event::poll(Duration::from_millis(500))? {
|
if !event::poll(Duration::from_millis(500))? {
|
||||||
|
|
@ -7812,15 +7832,16 @@ fn run_agent_view_tui_loop(
|
||||||
|
|
||||||
fn load_agent_view_tui_jobs(
|
fn load_agent_view_tui_jobs(
|
||||||
supervisor: &runtime::AgentSupervisor,
|
supervisor: &runtime::AgentSupervisor,
|
||||||
options: &AgentViewTuiOptions,
|
state: &AgentViewTuiState,
|
||||||
) -> Result<Vec<runtime::AgentJobRecord>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<runtime::AgentJobRecord>, Box<dyn std::error::Error>> {
|
||||||
let mut jobs = supervisor.list_jobs(&runtime::AgentListFilter {
|
let mut jobs = supervisor.list_jobs(&runtime::AgentListFilter {
|
||||||
cwd: options.cwd.clone(),
|
cwd: state.options.cwd.clone(),
|
||||||
include_all: options.include_all,
|
include_all: true,
|
||||||
})?;
|
})?;
|
||||||
jobs.sort_by(|left, right| {
|
jobs.sort_by(|left, right| {
|
||||||
agent_view_group_rank(left)
|
agent_view_group_sort_key(left, state.group_by_directory)
|
||||||
.cmp(&agent_view_group_rank(right))
|
.cmp(&agent_view_group_sort_key(right, state.group_by_directory))
|
||||||
|
.then_with(|| agent_view_order_key(left).cmp(&agent_view_order_key(right)))
|
||||||
.then(right.updated_at.cmp(&left.updated_at))
|
.then(right.updated_at.cmp(&left.updated_at))
|
||||||
.then(left.id.cmp(&right.id))
|
.then(left.id.cmp(&right.id))
|
||||||
});
|
});
|
||||||
|
|
@ -7834,50 +7855,161 @@ fn handle_agent_view_key(
|
||||||
jobs: &[runtime::AgentJobRecord],
|
jobs: &[runtime::AgentJobRecord],
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
) -> Result<Option<AgentViewTuiExit>, Box<dyn std::error::Error>> {
|
) -> Result<Option<AgentViewTuiExit>, Box<dyn std::error::Error>> {
|
||||||
|
let rows = build_agent_view_rows(state, jobs);
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') if state.input.is_empty() => {
|
KeyCode::Esc if state.rename_mode => {
|
||||||
|
state.rename_mode = false;
|
||||||
|
state.input.clear();
|
||||||
|
state.status = Some("rename canceled".to_string());
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') if state.input.is_empty() && !state.rename_mode => {
|
||||||
return Ok(Some(AgentViewTuiExit::Quit));
|
return Ok(Some(AgentViewTuiExit::Quit));
|
||||||
}
|
}
|
||||||
KeyCode::Up if state.input.is_empty() => {
|
KeyCode::Up if state.input.is_empty() && key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||||
|
reorder_agent_view_job(supervisor, state, jobs, &rows, -1)?;
|
||||||
|
}
|
||||||
|
KeyCode::Down if state.input.is_empty() && key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||||
|
reorder_agent_view_job(supervisor, state, jobs, &rows, 1)?;
|
||||||
|
}
|
||||||
|
KeyCode::Char('s')
|
||||||
|
if state.input.is_empty() && key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||||
|
{
|
||||||
|
state.group_by_directory = !state.group_by_directory;
|
||||||
|
state.collapsed_groups.clear();
|
||||||
|
state.selected = 0;
|
||||||
|
state.status = Some(if state.group_by_directory {
|
||||||
|
"grouping by directory".to_string()
|
||||||
|
} else {
|
||||||
|
"grouping by state".to_string()
|
||||||
|
});
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
}
|
||||||
|
KeyCode::Char('t')
|
||||||
|
if state.input.is_empty() && key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||||
|
{
|
||||||
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
|
let updated = supervisor.set_pinned(&job.id, !job.pinned)?;
|
||||||
|
state.status = Some(format!(
|
||||||
|
"{} {}",
|
||||||
|
if updated.pinned { "pinned" } else { "unpinned" },
|
||||||
|
updated.id
|
||||||
|
));
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('r')
|
||||||
|
if state.input.is_empty() && key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||||
|
{
|
||||||
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
|
state.rename_mode = true;
|
||||||
|
state.peek_open = false;
|
||||||
|
state.input = job.name.clone().unwrap_or_else(|| job.id.clone());
|
||||||
|
state.status = Some(format!("renaming {}; press Enter to save", job.id));
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('x')
|
||||||
|
if state.input.is_empty() && key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||||
|
{
|
||||||
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
|
if state.confirm_remove_id.as_deref() == Some(job.id.as_str()) {
|
||||||
|
supervisor.remove_job(&job.id)?;
|
||||||
|
state.status = Some(format!("removed background session {}", job.id));
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
state.peek_open = false;
|
||||||
|
} else {
|
||||||
|
if let Some(pid) = job.pid {
|
||||||
|
let _ = signal_process(pid, AgentStopSignal::Term);
|
||||||
|
}
|
||||||
|
let stopped = supervisor.stop_job(&job.id)?;
|
||||||
|
state.status = Some(format!(
|
||||||
|
"stopped {}; press Ctrl+X again to remove",
|
||||||
|
stopped.id
|
||||||
|
));
|
||||||
|
state.confirm_remove_id = Some(job.id.clone());
|
||||||
|
state.peek_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up if state.input.is_empty() && !state.rename_mode => {
|
||||||
state.selected = state.selected.saturating_sub(1);
|
state.selected = state.selected.saturating_sub(1);
|
||||||
state.confirm_remove_id = None;
|
state.confirm_remove_id = None;
|
||||||
}
|
}
|
||||||
KeyCode::Down if state.input.is_empty() => {
|
KeyCode::Down if state.input.is_empty() && !state.rename_mode => {
|
||||||
if !jobs.is_empty() {
|
if !rows.is_empty() {
|
||||||
state.selected = (state.selected + 1).min(jobs.len() - 1);
|
state.selected = (state.selected + 1).min(rows.len() - 1);
|
||||||
}
|
}
|
||||||
state.confirm_remove_id = None;
|
state.confirm_remove_id = None;
|
||||||
}
|
}
|
||||||
KeyCode::Char(' ') if state.input.is_empty() => {
|
KeyCode::Char(' ') if state.input.is_empty() && !state.rename_mode => {
|
||||||
state.peek_open = !state.peek_open;
|
if let Some(row) = rows.get(state.selected) {
|
||||||
|
match row {
|
||||||
|
AgentViewRow::Header { key, .. } => {
|
||||||
|
toggle_agent_view_group(state, key);
|
||||||
|
state.peek_open = false;
|
||||||
|
}
|
||||||
|
AgentViewRow::Job { .. } => {
|
||||||
|
state.peek_open = !state.peek_open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
state.confirm_remove_id = None;
|
state.confirm_remove_id = None;
|
||||||
}
|
}
|
||||||
KeyCode::Left if state.input.is_empty() => {
|
KeyCode::Left if state.input.is_empty() && !state.rename_mode => {
|
||||||
state.peek_open = false;
|
state.peek_open = false;
|
||||||
state.confirm_remove_id = None;
|
state.confirm_remove_id = None;
|
||||||
}
|
}
|
||||||
KeyCode::Right | KeyCode::Enter if state.input.trim().is_empty() => {
|
KeyCode::Right if state.input.trim().is_empty() && !state.rename_mode => {
|
||||||
if let Some(job) = jobs.get(state.selected) {
|
if let Some(row) = rows.get(state.selected) {
|
||||||
return Ok(Some(AgentViewTuiExit::Attach(job.id.clone())));
|
match row {
|
||||||
|
AgentViewRow::Header { key, .. } => toggle_agent_view_group(state, key),
|
||||||
|
AgentViewRow::Job { .. } => {
|
||||||
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
|
return Ok(Some(AgentViewTuiExit::Attach(job.id.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter if state.rename_mode => {
|
||||||
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
|
let renamed = supervisor.rename_job(&job.id, state.input.clone())?;
|
||||||
|
state.status = Some(format!(
|
||||||
|
"renamed {} to {}",
|
||||||
|
renamed.id,
|
||||||
|
renamed.name.as_deref().unwrap_or(&renamed.id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.rename_mode = false;
|
||||||
|
state.input.clear();
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter if state.input.trim().is_empty() => {
|
||||||
|
if let Some(row) = rows.get(state.selected) {
|
||||||
|
match row {
|
||||||
|
AgentViewRow::Header { key, .. } => toggle_agent_view_group(state, key),
|
||||||
|
AgentViewRow::Job { .. } => {
|
||||||
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
|
return Ok(Some(AgentViewTuiExit::Attach(job.id.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let prompt = state.input.trim().to_string();
|
let prompt = state.input.trim().to_string();
|
||||||
if !prompt.is_empty() {
|
if !prompt.is_empty() {
|
||||||
let job = start_background_agent_job(BackgroundAgentRequest {
|
if state.peek_open {
|
||||||
prompt: Some(&prompt),
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
exec: None,
|
queue_agent_view_reply(supervisor, job, &prompt, state)?;
|
||||||
name: None,
|
} else {
|
||||||
agent: request.agent,
|
start_agent_view_dispatch(request, state, &prompt)?;
|
||||||
model: request.model,
|
}
|
||||||
output_format: request.output_format,
|
} else {
|
||||||
permission_mode: request.permission_mode,
|
start_agent_view_dispatch(request, state, &prompt)?;
|
||||||
reasoning_effort: request.reasoning_effort,
|
}
|
||||||
})?;
|
|
||||||
state.status = Some(format!("started background session {}", job.id));
|
|
||||||
state.input.clear();
|
state.input.clear();
|
||||||
state.selected = 0;
|
|
||||||
state.peek_open = false;
|
|
||||||
state.confirm_remove_id = None;
|
state.confirm_remove_id = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7885,18 +8017,18 @@ fn handle_agent_view_key(
|
||||||
state.input.pop();
|
state.input.pop();
|
||||||
state.confirm_remove_id = None;
|
state.confirm_remove_id = None;
|
||||||
}
|
}
|
||||||
KeyCode::Char('s') if state.input.is_empty() => {
|
KeyCode::Char('s') if state.input.is_empty() && !state.rename_mode => {
|
||||||
if let Some(job) = jobs.get(state.selected) {
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
stop_agent_view_job(supervisor, job, AgentStopSignal::Term, state)?;
|
stop_agent_view_job(supervisor, job, AgentStopSignal::Term, state)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('k') if state.input.is_empty() => {
|
KeyCode::Char('k') if state.input.is_empty() && !state.rename_mode => {
|
||||||
if let Some(job) = jobs.get(state.selected) {
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
stop_agent_view_job(supervisor, job, AgentStopSignal::Kill, state)?;
|
stop_agent_view_job(supervisor, job, AgentStopSignal::Kill, state)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') if state.input.is_empty() => {
|
KeyCode::Char('r') if state.input.is_empty() && !state.rename_mode => {
|
||||||
if let Some(job) = jobs.get(state.selected) {
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
let queued = supervisor.respawn_job(&job.id)?;
|
let queued = supervisor.respawn_job(&job.id)?;
|
||||||
let child = spawn_agent_worker(&queued.id, Path::new(&queued.cwd))?;
|
let child = spawn_agent_worker(&queued.id, Path::new(&queued.cwd))?;
|
||||||
let job = supervisor.set_process(&queued.id, child.id())?;
|
let job = supervisor.set_process(&queued.id, child.id())?;
|
||||||
|
|
@ -7910,8 +8042,8 @@ fn handle_agent_view_key(
|
||||||
state.confirm_remove_id = None;
|
state.confirm_remove_id = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') if state.input.is_empty() => {
|
KeyCode::Char('d') if state.input.is_empty() && !state.rename_mode => {
|
||||||
if let Some(job) = jobs.get(state.selected) {
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
if state.confirm_remove_id.as_deref() == Some(job.id.as_str()) {
|
if state.confirm_remove_id.as_deref() == Some(job.id.as_str()) {
|
||||||
supervisor.remove_job(&job.id)?;
|
supervisor.remove_job(&job.id)?;
|
||||||
state.status = Some(format!("removed background session {}", job.id));
|
state.status = Some(format!("removed background session {}", job.id));
|
||||||
|
|
@ -7932,6 +8064,140 @@ fn handle_agent_view_key(
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_agent_view_dispatch(
|
||||||
|
request: &AgentViewPrintRequest<'_>,
|
||||||
|
state: &mut AgentViewTuiState,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let job = start_background_agent_job(BackgroundAgentRequest {
|
||||||
|
prompt: Some(prompt),
|
||||||
|
exec: None,
|
||||||
|
name: None,
|
||||||
|
agent: request.agent,
|
||||||
|
model: request.model,
|
||||||
|
output_format: request.output_format,
|
||||||
|
permission_mode: request.permission_mode,
|
||||||
|
reasoning_effort: request.reasoning_effort,
|
||||||
|
})?;
|
||||||
|
state.status = Some(format!("started background session {}", job.id));
|
||||||
|
state.selected = 0;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_agent_view_job<'a>(
|
||||||
|
state: &AgentViewTuiState,
|
||||||
|
jobs: &'a [runtime::AgentJobRecord],
|
||||||
|
rows: &[AgentViewRow],
|
||||||
|
) -> Option<&'a runtime::AgentJobRecord> {
|
||||||
|
match rows.get(state.selected) {
|
||||||
|
Some(AgentViewRow::Job { job_index }) => jobs.get(*job_index),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_agent_view_group(state: &mut AgentViewTuiState, key: &str) {
|
||||||
|
if !state.collapsed_groups.insert(key.to_string()) {
|
||||||
|
state.collapsed_groups.remove(key);
|
||||||
|
}
|
||||||
|
state.peek_open = false;
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reorder_agent_view_job(
|
||||||
|
supervisor: &runtime::AgentSupervisor,
|
||||||
|
state: &mut AgentViewTuiState,
|
||||||
|
jobs: &[runtime::AgentJobRecord],
|
||||||
|
rows: &[AgentViewRow],
|
||||||
|
direction: isize,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let Some(job) = selected_agent_view_job(state, jobs, rows) else {
|
||||||
|
state.status = Some("select a session row to reorder".to_string());
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let group_key = agent_view_group_key(job, state.group_by_directory);
|
||||||
|
let mut group_indices = jobs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, candidate)| {
|
||||||
|
(agent_view_group_key(candidate, state.group_by_directory) == group_key)
|
||||||
|
.then_some(index)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let Some(current_group_index) = group_indices
|
||||||
|
.iter()
|
||||||
|
.position(|index| jobs[*index].id == job.id)
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let target_group_index = if direction < 0 {
|
||||||
|
current_group_index.checked_sub(1)
|
||||||
|
} else {
|
||||||
|
(current_group_index + 1 < group_indices.len()).then_some(current_group_index + 1)
|
||||||
|
};
|
||||||
|
let Some(target_group_index) = target_group_index else {
|
||||||
|
state.status = Some(format!("{} is already at this edge", job.id));
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
group_indices.swap(current_group_index, target_group_index);
|
||||||
|
for (order, job_index) in group_indices.iter().enumerate() {
|
||||||
|
supervisor.set_display_order(&jobs[*job_index].id, (order as i64) * 10)?;
|
||||||
|
}
|
||||||
|
state.status = Some(format!("reordered {}", job.id));
|
||||||
|
state.selected = if direction < 0 {
|
||||||
|
state.selected.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
(state.selected + 1).min(rows.len().saturating_sub(1))
|
||||||
|
};
|
||||||
|
state.confirm_remove_id = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_agent_view_reply(
|
||||||
|
supervisor: &runtime::AgentSupervisor,
|
||||||
|
job: &runtime::AgentJobRecord,
|
||||||
|
prompt: &str,
|
||||||
|
state: &mut AgentViewTuiState,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if !matches!(
|
||||||
|
job.kind,
|
||||||
|
runtime::AgentJobKind::Claude | runtime::AgentJobKind::Interactive
|
||||||
|
) {
|
||||||
|
state.status = Some(format!(
|
||||||
|
"{} is an exec session; attach or respawn it instead",
|
||||||
|
job.id
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if job.session_id.is_none() {
|
||||||
|
state.status = Some(format!(
|
||||||
|
"{} has no resumable session id yet; wait for the worker to start",
|
||||||
|
job.id
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if job.pending_prompt.is_some() {
|
||||||
|
state.status = Some(format!("{} already has a queued reply", job.id));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if job.pid.is_some() {
|
||||||
|
state.status = Some(format!(
|
||||||
|
"{} still has a live worker; attach or wait for it to finish before replying",
|
||||||
|
job.id
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let queued = supervisor.queue_reply(&job.id, prompt.to_string())?;
|
||||||
|
let child = spawn_agent_worker(&queued.id, Path::new(&queued.cwd))?;
|
||||||
|
let job = supervisor.set_process(&queued.id, child.id())?;
|
||||||
|
state.status = Some(format!(
|
||||||
|
"sent reply to {} with pid {}",
|
||||||
|
job.id,
|
||||||
|
job.pid
|
||||||
|
.map_or_else(|| "<none>".to_string(), |pid| pid.to_string())
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn stop_agent_view_job(
|
fn stop_agent_view_job(
|
||||||
supervisor: &runtime::AgentSupervisor,
|
supervisor: &runtime::AgentSupervisor,
|
||||||
job: &runtime::AgentJobRecord,
|
job: &runtime::AgentJobRecord,
|
||||||
|
|
@ -8006,24 +8272,50 @@ fn render_agent_view_screen(
|
||||||
lines
|
lines
|
||||||
.push("No background sessions. Type a prompt and press Enter to dispatch.".to_string());
|
.push("No background sessions. Type a prompt and press Enter to dispatch.".to_string());
|
||||||
} else {
|
} else {
|
||||||
let mut current_group = "";
|
let rows = build_agent_view_rows(state, jobs);
|
||||||
for (index, job) in jobs.iter().enumerate() {
|
let mut last_was_header = false;
|
||||||
let group = agent_view_group_label(job);
|
for (row_index, row) in rows.iter().enumerate() {
|
||||||
if group != current_group {
|
match row {
|
||||||
if !current_group.is_empty() {
|
AgentViewRow::Header {
|
||||||
lines.push(String::new());
|
label,
|
||||||
|
collapsed,
|
||||||
|
count,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if !lines.last().is_some_and(String::is_empty) && !last_was_header {
|
||||||
|
lines.push(String::new());
|
||||||
|
}
|
||||||
|
let selector = if row_index == state.selected {
|
||||||
|
">"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let marker = if *collapsed { "+" } else { "-" };
|
||||||
|
lines.push(truncate_chars(
|
||||||
|
&format!("{selector} {marker} {label} ({count})"),
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
last_was_header = true;
|
||||||
|
}
|
||||||
|
AgentViewRow::Job { job_index } => {
|
||||||
|
if let Some(job) = jobs.get(*job_index) {
|
||||||
|
lines.push(render_agent_view_row(
|
||||||
|
job,
|
||||||
|
row_index == state.selected,
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
last_was_header = false;
|
||||||
}
|
}
|
||||||
lines.push(group.to_string());
|
|
||||||
current_group = group;
|
|
||||||
}
|
}
|
||||||
lines.push(render_agent_view_row(job, index == state.selected, width));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if state.peek_open {
|
if state.peek_open {
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
lines.push(truncate_chars(&"-".repeat(width), width));
|
lines.push(truncate_chars(&"-".repeat(width), width));
|
||||||
if let Some(job) = jobs.get(state.selected) {
|
let rows = build_agent_view_rows(state, jobs);
|
||||||
|
if let Some(job) = selected_agent_view_job(state, jobs, &rows) {
|
||||||
lines.extend(render_agent_view_peek(job, width));
|
lines.extend(render_agent_view_peek(job, width));
|
||||||
} else {
|
} else {
|
||||||
lines.push("Peek: no session selected".to_string());
|
lines.push("Peek: no session selected".to_string());
|
||||||
|
|
@ -8034,9 +8326,20 @@ fn render_agent_view_screen(
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
}
|
}
|
||||||
lines.truncate(height.saturating_sub(2));
|
lines.truncate(height.saturating_sub(2));
|
||||||
lines.push(truncate_chars(&format!("Dispatch: {}", state.input), width));
|
|
||||||
lines.push(truncate_chars(
|
lines.push(truncate_chars(
|
||||||
"Enter dispatch/attach Space peek Up/Down select s stop k kill r respawn d rm q quit",
|
&format!(
|
||||||
|
"{}: {}",
|
||||||
|
if state.rename_mode {
|
||||||
|
"Rename"
|
||||||
|
} else {
|
||||||
|
"Dispatch"
|
||||||
|
},
|
||||||
|
state.input
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
lines.push(truncate_chars(
|
||||||
|
"Enter dispatch/reply/attach/fold Space peek Ctrl+S group Ctrl+T pin Ctrl+R rename Shift+Up/Down move Ctrl+X stop/rm q quit",
|
||||||
width,
|
width,
|
||||||
));
|
));
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
|
|
@ -8063,6 +8366,35 @@ fn summarize_agent_jobs(jobs: &[runtime::AgentJobRecord]) -> AgentViewSummaryCou
|
||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_agent_view_rows(
|
||||||
|
state: &AgentViewTuiState,
|
||||||
|
jobs: &[runtime::AgentJobRecord],
|
||||||
|
) -> Vec<AgentViewRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < jobs.len() {
|
||||||
|
let key = agent_view_group_key(&jobs[index], state.group_by_directory);
|
||||||
|
let label = agent_view_group_label(&jobs[index], state.group_by_directory);
|
||||||
|
let start = index;
|
||||||
|
while index < jobs.len()
|
||||||
|
&& agent_view_group_key(&jobs[index], state.group_by_directory) == key
|
||||||
|
{
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
let collapsed = state.collapsed_groups.contains(&key);
|
||||||
|
rows.push(AgentViewRow::Header {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
collapsed,
|
||||||
|
count: index - start,
|
||||||
|
});
|
||||||
|
if !collapsed {
|
||||||
|
rows.extend((start..index).map(|job_index| AgentViewRow::Job { job_index }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
fn render_agent_view_row(job: &runtime::AgentJobRecord, selected: bool, width: usize) -> String {
|
fn render_agent_view_row(job: &runtime::AgentJobRecord, selected: bool, width: usize) -> String {
|
||||||
let selector = if selected { ">" } else { " " };
|
let selector = if selected { ">" } else { " " };
|
||||||
let pid = job
|
let pid = job
|
||||||
|
|
@ -8081,9 +8413,10 @@ fn render_agent_view_row(job: &runtime::AgentJobRecord, selected: bool, width: u
|
||||||
.or(job.command.as_deref())
|
.or(job.command.as_deref())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.replace('\n', " ");
|
.replace('\n', " ");
|
||||||
|
let pin = if job.pinned { "^" } else { " " };
|
||||||
truncate_chars(
|
truncate_chars(
|
||||||
&format!(
|
&format!(
|
||||||
"{selector} {pid:<9} {name:<22} {:<8} {}",
|
"{selector} {pin} {pid:<9} {name:<22} {:<8} {}",
|
||||||
agent_state_label(&job.state),
|
agent_state_label(&job.state),
|
||||||
detail
|
detail
|
||||||
),
|
),
|
||||||
|
|
@ -8107,9 +8440,24 @@ fn render_agent_view_peek(job: &runtime::AgentJobRecord, width: usize) -> Vec<St
|
||||||
if let Some(session_id) = &job.session_id {
|
if let Some(session_id) = &job.session_id {
|
||||||
lines.push(truncate_chars(&format!(" Session {session_id}"), width));
|
lines.push(truncate_chars(&format!(" Session {session_id}"), width));
|
||||||
}
|
}
|
||||||
|
if job.pinned {
|
||||||
|
lines.push(" Pinned true".to_string());
|
||||||
|
}
|
||||||
|
if let Some(display_order) = job.display_order {
|
||||||
|
lines.push(truncate_chars(
|
||||||
|
&format!(" Order {display_order}"),
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(prompt) = &job.prompt {
|
if let Some(prompt) = &job.prompt {
|
||||||
lines.push(truncate_chars(&format!(" Prompt {prompt}"), width));
|
lines.push(truncate_chars(&format!(" Prompt {prompt}"), width));
|
||||||
}
|
}
|
||||||
|
if let Some(pending_prompt) = &job.pending_prompt {
|
||||||
|
lines.push(truncate_chars(
|
||||||
|
&format!(" Pending {pending_prompt}"),
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(command) = &job.command {
|
if let Some(command) = &job.command {
|
||||||
lines.push(truncate_chars(&format!(" Command {command}"), width));
|
lines.push(truncate_chars(&format!(" Command {command}"), width));
|
||||||
}
|
}
|
||||||
|
|
@ -8131,17 +8479,53 @@ fn render_agent_view_peek(job: &runtime::AgentJobRecord, width: usize) -> Vec<St
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn agent_view_group_rank(job: &runtime::AgentJobRecord) -> u8 {
|
fn agent_view_order_key(job: &runtime::AgentJobRecord) -> i64 {
|
||||||
match job.state {
|
job.display_order.unwrap_or(i64::MAX)
|
||||||
runtime::AgentJobState::Blocked => 0,
|
|
||||||
runtime::AgentJobState::Working => 1,
|
|
||||||
runtime::AgentJobState::Done => 2,
|
|
||||||
runtime::AgentJobState::Failed => 3,
|
|
||||||
runtime::AgentJobState::Stopped => 4,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn agent_view_group_label(job: &runtime::AgentJobRecord) -> &'static str {
|
fn agent_view_group_sort_key(
|
||||||
|
job: &runtime::AgentJobRecord,
|
||||||
|
group_by_directory: bool,
|
||||||
|
) -> (u8, String) {
|
||||||
|
if job.pinned {
|
||||||
|
return (0, String::new());
|
||||||
|
}
|
||||||
|
if group_by_directory {
|
||||||
|
return (1, job.cwd.clone());
|
||||||
|
}
|
||||||
|
(
|
||||||
|
match job.state {
|
||||||
|
runtime::AgentJobState::Blocked => 1,
|
||||||
|
runtime::AgentJobState::Working => 2,
|
||||||
|
runtime::AgentJobState::Done => 3,
|
||||||
|
runtime::AgentJobState::Failed => 4,
|
||||||
|
runtime::AgentJobState::Stopped => 4,
|
||||||
|
},
|
||||||
|
String::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_view_group_key(job: &runtime::AgentJobRecord, group_by_directory: bool) -> String {
|
||||||
|
if job.pinned {
|
||||||
|
return "pinned".to_string();
|
||||||
|
}
|
||||||
|
if group_by_directory {
|
||||||
|
return format!("dir:{}", job.cwd);
|
||||||
|
}
|
||||||
|
format!("state:{}", agent_view_state_group_label(job))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_view_group_label(job: &runtime::AgentJobRecord, group_by_directory: bool) -> String {
|
||||||
|
if job.pinned {
|
||||||
|
return "Pinned".to_string();
|
||||||
|
}
|
||||||
|
if group_by_directory {
|
||||||
|
return job.cwd.clone();
|
||||||
|
}
|
||||||
|
agent_view_state_group_label(job).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_view_state_group_label(job: &runtime::AgentJobRecord) -> &'static str {
|
||||||
match job.state {
|
match job.state {
|
||||||
runtime::AgentJobState::Blocked => "Needs input",
|
runtime::AgentJobState::Blocked => "Needs input",
|
||||||
runtime::AgentJobState::Working => "Working",
|
runtime::AgentJobState::Working => "Working",
|
||||||
|
|
@ -10826,11 +11210,17 @@ fn run_agent_daemon_stop(
|
||||||
fn run_agent_worker(id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_agent_worker(id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let supervisor = runtime::AgentSupervisor::from_default_config()?;
|
let supervisor = runtime::AgentSupervisor::from_default_config()?;
|
||||||
let job = supervisor.load_job(id)?;
|
let job = supervisor.load_job(id)?;
|
||||||
let output = match job.kind {
|
let output = match match job.kind {
|
||||||
runtime::AgentJobKind::Exec => run_agent_exec_worker(&job)?,
|
runtime::AgentJobKind::Exec => run_agent_exec_worker(&job),
|
||||||
runtime::AgentJobKind::Claude | runtime::AgentJobKind::Interactive => {
|
runtime::AgentJobKind::Claude | runtime::AgentJobKind::Interactive => {
|
||||||
run_agent_prompt_worker(&job)?
|
run_agent_prompt_worker(&job)
|
||||||
}
|
}
|
||||||
|
} {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(error) => AgentWorkerOutput {
|
||||||
|
exit_code: 1,
|
||||||
|
text: format!("agent worker failed: {error}\n"),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
supervisor.append_log(id, &output.text)?;
|
supervisor.append_log(id, &output.text)?;
|
||||||
supervisor.finish_job(id, output.exit_code, Some(&output.text))?;
|
supervisor.finish_job(id, output.exit_code, Some(&output.text))?;
|
||||||
|
|
@ -10862,12 +11252,25 @@ fn run_agent_exec_worker(
|
||||||
fn run_agent_prompt_worker(
|
fn run_agent_prompt_worker(
|
||||||
job: &runtime::AgentJobRecord,
|
job: &runtime::AgentJobRecord,
|
||||||
) -> Result<AgentWorkerOutput, Box<dyn std::error::Error>> {
|
) -> Result<AgentWorkerOutput, Box<dyn std::error::Error>> {
|
||||||
let prompt = job.prompt.as_deref().ok_or_else(|| {
|
let prompt = job
|
||||||
io::Error::new(
|
.pending_prompt
|
||||||
io::ErrorKind::InvalidInput,
|
.as_deref()
|
||||||
"agent prompt job missing prompt",
|
.or(job.prompt.as_deref())
|
||||||
)
|
.ok_or_else(|| {
|
||||||
})?;
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"agent prompt job missing prompt",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if job.pending_prompt.is_some() {
|
||||||
|
let session_id = job.session_id.as_deref().ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"agent reply job missing resumable session id",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return run_agent_follow_up_worker(job, session_id, prompt);
|
||||||
|
}
|
||||||
let executable = env::current_exe()?;
|
let executable = env::current_exe()?;
|
||||||
let mut command = Command::new(executable);
|
let mut command = Command::new(executable);
|
||||||
if let Some(model) = &job.model {
|
if let Some(model) = &job.model {
|
||||||
|
|
@ -10888,6 +11291,50 @@ fn run_agent_prompt_worker(
|
||||||
Ok(command_output_to_worker_output(output))
|
Ok(command_output_to_worker_output(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_agent_follow_up_worker(
|
||||||
|
job: &runtime::AgentJobRecord,
|
||||||
|
session_id: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<AgentWorkerOutput, Box<dyn std::error::Error>> {
|
||||||
|
let _cwd_guard = CurrentDirGuard::enter(Path::new(&job.cwd))?;
|
||||||
|
let (handle, session) = load_session_reference(session_id)?;
|
||||||
|
let model = job
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.or_else(|| session.model.clone())
|
||||||
|
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||||
|
let model = resolve_repl_model(model)?;
|
||||||
|
let permission_mode = job
|
||||||
|
.permission_mode
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_permission_mode_arg)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?
|
||||||
|
.unwrap_or_else(default_permission_mode);
|
||||||
|
let system_prompt = build_system_prompt(&model)?;
|
||||||
|
let mut runtime = build_runtime(
|
||||||
|
session.with_persistence_path(handle.path.clone()),
|
||||||
|
&handle.id,
|
||||||
|
model.clone(),
|
||||||
|
system_prompt,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
permission_mode,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let mut permission_prompter = CliPermissionPrompter::new(permission_mode);
|
||||||
|
let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?;
|
||||||
|
runtime.session().save_to_path(&handle.path)?;
|
||||||
|
let mut text = String::new();
|
||||||
|
text.push_str("User reply:\n");
|
||||||
|
text.push_str(prompt);
|
||||||
|
text.push_str("\n\nAssistant:\n");
|
||||||
|
text.push_str(&final_assistant_text(&summary));
|
||||||
|
text.push('\n');
|
||||||
|
Ok(AgentWorkerOutput { exit_code: 0, text })
|
||||||
|
}
|
||||||
|
|
||||||
fn command_output_to_worker_output(output: std::process::Output) -> AgentWorkerOutput {
|
fn command_output_to_worker_output(output: std::process::Output) -> AgentWorkerOutput {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||||
|
|
@ -17396,11 +17843,14 @@ mod tests {
|
||||||
include_all: true,
|
include_all: true,
|
||||||
cwd: Some(PathBuf::from("/tmp/project")),
|
cwd: Some(PathBuf::from("/tmp/project")),
|
||||||
},
|
},
|
||||||
selected: 0,
|
selected: 1,
|
||||||
peek_open: true,
|
peek_open: true,
|
||||||
input: "write tests".to_string(),
|
input: "write tests".to_string(),
|
||||||
status: Some("started background session abc123".to_string()),
|
status: Some("started background session abc123".to_string()),
|
||||||
confirm_remove_id: None,
|
confirm_remove_id: None,
|
||||||
|
group_by_directory: false,
|
||||||
|
collapsed_groups: std::collections::BTreeSet::new(),
|
||||||
|
rename_mode: false,
|
||||||
};
|
};
|
||||||
let job = runtime::AgentJobRecord {
|
let job = runtime::AgentJobRecord {
|
||||||
id: "abc123".to_string(),
|
id: "abc123".to_string(),
|
||||||
|
|
@ -17414,7 +17864,10 @@ mod tests {
|
||||||
waiting_for: None,
|
waiting_for: None,
|
||||||
session_id: Some("session-abc".to_string()),
|
session_id: Some("session-abc".to_string()),
|
||||||
name: Some("parser fix".to_string()),
|
name: Some("parser fix".to_string()),
|
||||||
|
pinned: true,
|
||||||
|
display_order: Some(10),
|
||||||
prompt: Some("fix parser".to_string()),
|
prompt: Some("fix parser".to_string()),
|
||||||
|
pending_prompt: None,
|
||||||
command: None,
|
command: None,
|
||||||
model: Some(DEFAULT_MODEL.to_string()),
|
model: Some(DEFAULT_MODEL.to_string()),
|
||||||
agent: Some("Explore".to_string()),
|
agent: Some("Explore".to_string()),
|
||||||
|
|
@ -17430,10 +17883,12 @@ mod tests {
|
||||||
let screen = render_agent_view_screen(&state, &[job], 100, 24);
|
let screen = render_agent_view_screen(&state, &[job], 100, 24);
|
||||||
|
|
||||||
assert!(screen.contains("Claude Code Agent View"));
|
assert!(screen.contains("Claude Code Agent View"));
|
||||||
assert!(screen.contains("Working"));
|
assert!(screen.contains("Pinned"));
|
||||||
assert!(screen.contains("> *42"));
|
assert!(screen.contains("> ^ *42"));
|
||||||
assert!(screen.contains("Peek abc123"));
|
assert!(screen.contains("Peek abc123"));
|
||||||
assert!(screen.contains("Session session-abc"));
|
assert!(screen.contains("Session session-abc"));
|
||||||
|
assert!(screen.contains("Pinned true"));
|
||||||
|
assert!(screen.contains("Order 10"));
|
||||||
assert!(screen.contains("Dispatch: write tests"));
|
assert!(screen.contains("Dispatch: write tests"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue