aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/agents.rs177
-rw-r--r--src/app/api/panes.rs33
2 files changed, 140 insertions, 70 deletions
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
index d1fbf14c..ffb9cb63 100644
--- a/src/app/api/agents.rs
+++ b/src/app/api/agents.rs
@@ -1,8 +1,8 @@
use bytes::Bytes;
use crate::api::schema::{
- AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget,
- PaneReadResult, ReadFormat, ReadSource, ResponseResult,
+ AgentPromptParams, AgentRenameParams, AgentSendKeysParams, AgentStartParams, AgentTarget,
+ PaneReadResult, ResponseResult,
};
use crate::app::App;
@@ -59,7 +59,7 @@ impl App {
if params.text.is_empty() {
return encode_error(id, "empty_agent_prompt", "agent prompt must not be empty");
}
- let resolved = match self.resolve_terminal_target(&params.target) {
+ let resolved = match self.resolve_agent_target(&params.target) {
Ok(resolved) => resolved,
Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
};
@@ -75,29 +75,12 @@ impl App {
let Some(terminal) = self.state.terminals.get(&terminal_id) else {
return agent_not_found(id, &params.target);
};
- let Some(expected_agent) = terminal.effective_known_agent().filter(|_| {
- terminal.agent_name.as_deref() == Some(params.target.as_str())
- && terminal.state != crate::detect::AgentState::Unknown
- }) else {
+ let Some(expected_agent) = terminal.effective_known_agent() else {
return agent_not_ready(id, &params.target);
};
if terminal.managed_agent_launch_pending() {
return agent_not_ready(id, &params.target);
}
- if terminal.state == crate::detect::AgentState::Working {
- return encode_error(
- id,
- "agent_working",
- format!(
- "agent {} is still working; wait before prompting",
- params.target
- ),
- );
- }
- if terminal.managed_agent_kind().is_some() && !terminal.managed_agent_interactive_ready() {
- return agent_not_ready(id, &params.target);
- }
- let baseline_state_change_seq = terminal.last_agent_state_change_seq.unwrap_or(0);
let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else {
return agent_not_found(id, &params.target);
};
@@ -118,13 +101,7 @@ impl App {
let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else {
return agent_not_found(id, &params.target);
};
- encode_success(
- id,
- ResponseResult::AgentPrompted {
- agent,
- baseline_state_change_seq,
- },
- )
+ encode_success(id, ResponseResult::AgentPrompted { agent })
}
pub(super) fn handle_agent_read(
@@ -132,7 +109,7 @@ impl App {
id: String,
params: crate::api::schema::AgentReadParams,
) -> String {
- let resolved = match self.resolve_terminal_target(&params.target) {
+ let resolved = match self.resolve_agent_target(&params.target) {
Ok(resolved) => resolved,
Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
};
@@ -140,21 +117,12 @@ impl App {
else {
return agent_not_found(id, &params.target);
};
- let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
- let text = match params.format {
- ReadFormat::Text => match params.source {
- ReadSource::Visible => pane.visible_text(),
- ReadSource::Recent => pane.recent_text(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
- ReadSource::Detection => pane.detection_text(),
- },
- ReadFormat::Ansi => match params.source {
- ReadSource::Visible => pane.visible_ansi(),
- ReadSource::Recent => pane.recent_ansi(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
- ReadSource::Detection => pane.detection_text(),
- },
- };
+ let text = crate::app::api_helpers::read_terminal_snapshot(
+ pane,
+ params.source,
+ params.format,
+ params.lines,
+ );
encode_success(
id,
@@ -178,7 +146,7 @@ impl App {
}
pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String {
- let resolved = match self.resolve_terminal_target(&target.target) {
+ let resolved = match self.resolve_agent_target(&target.target) {
Ok(resolved) => resolved,
Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
};
@@ -248,16 +216,46 @@ impl App {
encode_success(id, ResponseResult::AgentExplain { explain: value })
}
- pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String {
- let resolved = match self.resolve_terminal_target(&params.target) {
+ pub(super) fn handle_agent_send_keys(
+ &mut self,
+ id: String,
+ params: AgentSendKeysParams,
+ ) -> String {
+ let resolved = match self.resolve_agent_target(&params.target) {
Ok(resolved) => resolved,
Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
};
+ let Some(terminal_id) = self
+ .state
+ .workspaces
+ .get(resolved.ws_idx)
+ .and_then(|workspace| workspace.terminal_id(resolved.pane_id))
+ else {
+ return agent_not_found(id, &params.target);
+ };
+ let Some(expected_agent) = self
+ .state
+ .terminals
+ .get(terminal_id)
+ .and_then(|terminal| terminal.effective_known_agent())
+ else {
+ return agent_not_ready(id, &params.target);
+ };
let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else {
return agent_not_found(id, &params.target);
};
- if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
- return encode_error(id, "agent_send_failed", err.to_string());
+ if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) {
+ return agent_not_ready(id, &params.target);
+ }
+ let encoded = match super::super::api_helpers::encode_api_keys(runtime, &params.keys) {
+ Ok(encoded) => encoded,
+ Err(key) => {
+ return encode_error(id, "invalid_key", format!("unsupported key {key}"));
+ }
+ };
+ let bytes: Vec<u8> = encoded.into_iter().flatten().collect();
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
+ return encode_error(id, "agent_send_keys_failed", err.to_string());
}
encode_success(id, ResponseResult::Ok {})
@@ -309,7 +307,7 @@ mod tests {
}
#[tokio::test]
- async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() {
+ async fn agent_prompt_accepts_pane_ids_and_working_agents_atomically() {
let mut app = app_with_agent();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
@@ -317,7 +315,7 @@ mod tests {
.clone();
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.set_agent_name("reviewer".into());
- terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working);
let (runtime, mut rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
80, 24, 0, b"", 1,
@@ -325,11 +323,13 @@ mod tests {
runtime.test_process_pty_bytes(b"\x1b[?2004h");
app.state.insert_test_runtime(pane_id, runtime);
+ let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
let response = app.handle_agent_prompt(
"req".into(),
AgentPromptParams {
- target: "reviewer".into(),
+ target: public_pane_id,
text: "A != B".into(),
+ wait: None,
},
);
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
@@ -351,6 +351,7 @@ mod tests {
AgentPromptParams {
target: "reviewer".into(),
text: "A != B".into(),
+ wait: None,
},
);
let raw: SuccessResponse = serde_json::from_str(&raw).unwrap();
@@ -363,10 +364,48 @@ mod tests {
AgentPromptParams {
target: "opencode".into(),
text: "wrong target".into(),
+ wait: None,
},
);
let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap();
- assert_eq!(error.error.code, "agent_not_ready");
+ assert_eq!(error.error.code, "agent_not_found");
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn agent_send_keys_validates_every_key_before_writing() {
+ let mut app = app_with_agent();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.set_agent_name("reviewer".into());
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ app.state.insert_test_runtime(pane_id, runtime);
+
+ let rejected = app.handle_agent_send_keys(
+ "req-invalid".into(),
+ AgentSendKeysParams {
+ target: "reviewer".into(),
+ keys: vec!["enter".into(), "not-a-key".into()],
+ },
+ );
+ let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap();
+ assert_eq!(error.error.code, "invalid_key");
+ assert!(rx.try_recv().is_err());
+
+ let sent = app.handle_agent_send_keys(
+ "req-valid".into(),
+ AgentSendKeysParams {
+ target: "reviewer".into(),
+ keys: vec!["up".into(), "enter".into()],
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&sent).unwrap();
+ assert!(matches!(success.result, ResponseResult::Ok {}));
+ assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"\x1b[A\r"));
assert!(rx.try_recv().is_err());
}
@@ -395,6 +434,7 @@ mod tests {
AgentPromptParams {
target: "reviewer".into(),
text: "A != B".into(),
+ wait: None,
},
);
let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
@@ -426,7 +466,7 @@ mod tests {
let response = app.handle_agent_focus(
"req".into(),
AgentTarget {
- target: "pi".into(),
+ target: app.public_pane_id(0, pane_id).unwrap(),
},
);
@@ -436,4 +476,33 @@ mod tests {
};
assert_eq!(agent.agent_status, AgentStatus::Idle);
}
+
+ #[test]
+ fn agent_rename_does_not_replace_the_pane_label() {
+ let mut app = app_with_agent();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
+ terminal.set_manual_label("shell-pane".into());
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ let target = app.public_pane_id(0, pane_id).unwrap();
+
+ for name in [Some("reviewer".to_string()), None] {
+ let response = app.handle_agent_rename(
+ "req".into(),
+ AgentRenameParams {
+ target: target.clone(),
+ name,
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert!(matches!(success.result, ResponseResult::AgentInfo { .. }));
+ assert_eq!(
+ app.state.terminals[&terminal_id].manual_label.as_deref(),
+ Some("shell-pane")
+ );
+ }
+ }
}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 6a5b41fe..483d73a5 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -11,7 +11,7 @@ use crate::api::schema::{
PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult,
PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams,
PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason,
- PaneZoomResult, ReadFormat, ReadSource, ResponseResult,
+ PaneZoomResult, ResponseResult,
};
use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason};
use crate::app::App;
@@ -1179,21 +1179,12 @@ impl App {
else {
return pane_not_found(id, &params.pane_id);
};
- let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
- let text = match params.format {
- ReadFormat::Text => match params.source {
- ReadSource::Visible => pane.visible_text(),
- ReadSource::Recent => pane.recent_text(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
- ReadSource::Detection => pane.detection_text(),
- },
- ReadFormat::Ansi => match params.source {
- ReadSource::Visible => pane.visible_ansi(),
- ReadSource::Recent => pane.recent_ansi(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
- ReadSource::Detection => pane.detection_text(),
- },
- };
+ let text = crate::app::api_helpers::read_terminal_snapshot(
+ pane,
+ params.source,
+ params.format,
+ params.lines,
+ );
encode_success(
id,
@@ -2591,6 +2582,11 @@ mod tests {
.clone();
let target = app.state.workspaces[1].tabs[0].root_pane;
seed_terminal_states(&mut app);
+ app.state
+ .terminals
+ .get_mut(&source_terminal)
+ .unwrap()
+ .set_detected_state(Some(Agent::Pi), AgentState::Idle);
let previous_pane_id = app.public_pane_id(0, source).unwrap();
let previous_workspace_id = app.public_workspace_id(0);
let target_workspace_id = app.public_workspace_id(1);
@@ -2633,6 +2629,11 @@ mod tests {
Some(&source_terminal)
);
assert_eq!(app.parse_pane_id(&previous_pane_id), Some((0, source)));
+ assert!(matches!(
+ app.resolve_agent_target(&previous_pane_id),
+ Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. })
+ ));
+ assert!(app.resolve_agent_target(&move_result.pane.pane_id).is_ok());
}
#[test]