Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ tokio = { version = "=1.53.1", features = ["test-util"] }

[patch.crates-io]
agentkit-loop = { git = "https://github.com/danielkov/agentkit.git", rev = "bec9dcc45ee0f436d538286bc220b16dc19fd5f3" }
agentkit-acp = { git = "https://github.com/danielkov/agentkit.git", rev = "bec9dcc45ee0f436d538286bc220b16dc19fd5f3" }
agentkit-acp = { git = "https://github.com/daviddanialy/agentkit.git", rev = "6d519ed1e93e28e54ba1cc18889534e2e8337181" }
agent-client-protocol = { git = "https://github.com/danielkov/rust-sdk.git", rev = "2f039993d1d6ed8da35b38c31f54a7cbb7338c70" }
agent-client-protocol-http = { git = "https://github.com/danielkov/rust-sdk.git", rev = "2f039993d1d6ed8da35b38c31f54a7cbb7338c70" }

Expand All @@ -146,3 +146,9 @@ todo = "deny"
unimplemented = "deny"
disallowed_methods = "deny"
disallowed_macros = "deny"

# Keep the media-budget fork limited to ACP; share the existing loop and registry types.
[patch."https://github.com/daviddanialy/agentkit.git"]
agentkit-loop = { git = "https://github.com/danielkov/agentkit.git", rev = "bec9dcc45ee0f436d538286bc220b16dc19fd5f3" }
agentkit-core = "=0.10.5"
agentkit-tools-core = "=0.10.5"
35 changes: 25 additions & 10 deletions src/protocols/acp/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3223,12 +3223,19 @@ mod tests {
.unwrap();
}

async fn receive_wire(channel: &mut agent_client_protocol::Channel) -> serde_json::Value {
async fn receive_wire(
channel: &mut agent_client_protocol::Channel,
expected: &str,
) -> serde_json::Value {
use futures_util::StreamExt;
let frame = timeout(Duration::from_secs(2), channel.rx.next())
// Real session creation includes best-effort model discovery with a 10-second
// HTTP timeout. Allow that fallback plus CI scheduling headroom; this is a
// deadlock guard, not a latency assertion. Always inspect the very next frame.
let wait = Duration::from_secs(30);
let frame = timeout(wait, channel.rx.next())
.await
.expect("ACP frame timed out")
.expect("ACP transport closed");
.unwrap_or_else(|_| panic!("ACP frame timed out after {wait:?} waiting for {expected}"))
.unwrap_or_else(|| panic!("ACP transport closed waiting for {expected}"));
let agent_client_protocol::TransportFrame::Single(message) = frame else {
panic!("expected a single ACP message, got {frame:?}");
};
Expand Down Expand Up @@ -3261,22 +3268,25 @@ mod tests {
))
.unwrap(),
);
assert_eq!(receive_wire(&mut client).await["id"], 1);
assert_eq!(
receive_wire(&mut client, "initialize response (id 1)").await["id"],
1
);
send_wire(
&client,
"session/new",
2,
serde_json::to_value(wire::NewSessionRequest::new(root.path().to_path_buf())).unwrap(),
);

let response = receive_wire(&mut client).await;
let response = receive_wire(&mut client, "session/new response (id 2)").await;
assert_eq!(
response["id"], 2,
"session response must be the first frame: {response}"
);
let response: wire::NewSessionResponse =
serde_json::from_value(response["result"].clone()).unwrap();
let notification = receive_wire(&mut client).await;
let notification = receive_wire(&mut client, "available commands notification").await;
assert_eq!(notification["method"], "session/update");
let notification: wire::UpdateSessionNotification =
serde_json::from_value(notification["params"].clone()).unwrap();
Expand All @@ -3293,7 +3303,7 @@ mod tests {
3,
serde_json::to_value(wire::CloseSessionRequest::new(response.session_id)).unwrap(),
);
let closed = receive_wire(&mut client).await;
let closed = receive_wire(&mut client, "session/close response (id 3)").await;
assert_eq!(closed["id"], 3);
assert!(closed.get("result").is_some(), "close failed: {closed}");
server.abort();
Expand Down Expand Up @@ -3739,7 +3749,12 @@ mod tests {
))
.unwrap(),
);
assert!(receive_wire(&mut client).await.get("result").is_some());
assert!(
receive_wire(&mut client, "initialize response (id 1)")
.await
.get("result")
.is_some()
);
send_wire(
&client,
"session/inject",
Expand All @@ -3753,7 +3768,7 @@ mod tests {
))
.unwrap(),
);
let response = receive_wire(&mut client).await;
let response = receive_wire(&mut client, "session/inject response (id 2)").await;
assert!(
response.get("result").is_some(),
"injection failed: {response}"
Expand Down
89 changes: 81 additions & 8 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ pub struct App {
next_attachment: usize,
submitted_attachment: usize,
clipboard_route_epoch: u64,
pub(super) pending_clipboard: Vec<String>,
pub phase: Phase,
pub turn_started: Option<Instant>,
/// When the user last started something new, as opposed to steering.
Expand Down Expand Up @@ -1106,6 +1107,7 @@ impl App {
next_attachment: 0,
submitted_attachment: 0,
clipboard_route_epoch: 0,
pending_clipboard: Vec::new(),
phase: Phase::Idle,
turn_started: None,
prompt_started: None,
Expand Down Expand Up @@ -3086,10 +3088,12 @@ impl App {
self.editor.insert_char(' ');
}
self.editor.insert_str(&placeholder);
if after.is_none_or(|character| !character.is_whitespace()) {
self.editor.insert_char(' ');
} else {
self.editor.move_right();
if attachment.temporary.is_none() {
if after.is_none_or(|character| !character.is_whitespace()) {
self.editor.insert_char(' ');
} else {
self.editor.move_right();
}
}
attachment.placeholder = placeholder;
self.attachments.push(attachment);
Expand Down Expand Up @@ -3141,7 +3145,7 @@ impl App {
}

fn delete_with_attachments(&mut self, backwards: bool, delete: fn(&mut Editor)) {
if self.attachments.is_empty() {
if self.attachments.is_empty() && self.pending_clipboard.is_empty() {
delete(&mut self.editor);
return;
}
Expand All @@ -3158,8 +3162,13 @@ impl App {
old_cursor..old_cursor + removed
};
let mut expanded = deleted.clone();
for attachment in &self.attachments {
for (start, placeholder) in old_text.match_indices(&attachment.placeholder) {
for placeholder in self
.attachments
.iter()
.map(|a| &a.placeholder)
.chain(&self.pending_clipboard)
{
for (start, placeholder) in old_text.match_indices(placeholder) {
let end = start + placeholder.len();
let deleted_separator = backwards
&& end == deleted.start
Expand Down Expand Up @@ -3200,6 +3209,39 @@ impl App {
.is_some_and(|dialog| dialog.rename.is_some())
}

pub(super) fn move_out_of_pending_clipboard(&mut self) {
let cursor = self.editor.cursor();
for placeholder in &self.pending_clipboard {
if let Some(start) = self.editor.text().find(placeholder)
&& start < cursor
&& cursor < start + placeholder.len()
{
self.editor.set_cursor(start + placeholder.len());
break;
}
}
}

pub(super) fn cancel_clipboard_placeholders(&mut self) {
for placeholder in self.pending_clipboard.drain(..) {
// A route change may have saved the composer while editing a steer.
for editor in std::iter::once(&mut self.editor)
.chain(self.steer_edit.as_mut().map(|edit| &mut edit.draft))
{
while let Some(start) = editor.text().find(&placeholder) {
let end = start + placeholder.len();
let cursor = editor.cursor();
editor.replace_range(start..end, "");
editor.set_cursor(if cursor >= end {
cursor - placeholder.len()
} else {
cursor.min(start)
});
}
}
}
}

pub(super) fn clipboard_route(&self) -> ClipboardRoute {
if self.model_switch.is_some()
|| self.model_dialog.is_some()
Expand Down Expand Up @@ -4095,6 +4137,10 @@ impl App {
self.toast = None;
}
KeyCode::Enter if key.modifiers.is_empty() && !pasted => {
if !self.pending_clipboard.is_empty() {
self.toast("waiting for clipboard paste before submitting");
return Action::None;
}
if self.editor.is_empty() {
return Action::None;
}
Expand Down Expand Up @@ -4318,7 +4364,10 @@ impl App {
KeyCode::Up if shift => self.scroll_by(-1),
KeyCode::Down if shift => self.scroll_by(1),
KeyCode::Up => {
if !self.editor.move_row_up(self.prompt_width) {
if !self.editor.move_row_up(self.prompt_width) && self.editor.has_history() {
// Cancel before history parks the draft: pending markers
// must never be restored without their paste tracking.
self.cancel_clipboard_placeholders();
self.editor.history_prev();
}
}
Expand Down Expand Up @@ -6481,6 +6530,30 @@ mod tests {
assert_eq!(app.attachments[0].placeholder, "[Image #1]");
}

#[test]
fn clipboard_image_preserves_spacing_and_leaves_cursor_at_placeholder_end() {
for (text, cursor, expected, expected_cursor) in [
("", 0, "[Image #1]", 10),
("leftright", 4, "left [Image #1]right", 15),
("left", 4, "left [Image #1]", 15),
("left right", 4, "left [Image #1] right", 15),
("left right", 5, "left [Image #1] right", 15),
("left\n\nright", 5, "left\n[Image #1]\nright", 15),
] {
let mut app = app();
app.editor.insert_str(text);
app.editor.set_cursor(cursor);
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
app.attach_attachment(Attachment::clipboard_image(
crate::tui::attachment::own_temp_path(path),
0,
));

assert_eq!(app.editor.text(), expected);
assert_eq!(app.editor.cursor(), expected_cursor);
}
}

#[test]
fn accepted_clipboard_image_file_lives_until_the_session_is_cleared() {
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
Expand Down
11 changes: 11 additions & 0 deletions src/tui/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ impl Editor {
self.cursor
}

/// Moves the cursor to a valid byte boundary without changing the draft.
pub fn set_cursor(&mut self, cursor: usize) {
if cursor <= self.text.len() && self.text.is_char_boundary(cursor) {
self.cursor = cursor;
}
}

/// Replaces the initial slash-command token and leaves following text intact.
pub fn replace_command_token(&mut self, replacement: &str) -> bool {
if !self.text.starts_with('/') {
Expand Down Expand Up @@ -282,6 +289,10 @@ impl Editor {
self.cursor = self.line_bounds(self.cursor).1;
}

pub fn has_history(&self) -> bool {
!self.history.is_empty()
}

/// Recalls the previous prompt, parking any unsent draft.
pub fn history_prev(&mut self) {
if self.history.is_empty() {
Expand Down
Loading
Loading