Skip to content

Commit 445a0d3

Browse files
authored
feat(tui): background running compose with Command-B (#2)
* feat(tui): background running compose with command-b * chore: use agentkit task manager 0.10.6
1 parent 6bafae2 commit 445a0d3

9 files changed

Lines changed: 532 additions & 73 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kit"
3-
version = "0.1.85"
3+
version = "0.1.86"
44
edition = "2024"
55
rust-version = "1.94.0"
66
publish = false
@@ -21,7 +21,7 @@ agentkit-mcp = "=0.10.6"
2121
agentkit-http = "=0.10.5"
2222
agentkit-plugins = "=0.10.7"
2323
agentkit-provider-openrouter = "=0.10.7"
24-
agentkit-task-manager = "=0.10.5"
24+
agentkit-task-manager = "=0.10.6"
2525
agentkit-tool-compose = { version = "=0.10.9", default-features = false, features = ["runlet"] }
2626
agentkit-tool-skills = "=0.10.7"
2727
agentkit-tools-core = "=0.10.5"

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,8 +377,11 @@ top-level `background` argument. `background: true` starts it in the background;
377377
it if it is still running. `false` or omission keeps normal foreground behavior.
378378
The delay must be an integer from 1 through 86,400 seconds.
379379

380-
Detached calls remain visible and selectable in the TUI runtime graph. Interrupting
381-
the originating turn does not stop them. The model receives each detached call's ID
380+
Press `Command+B` in the TUI to move the newest running foreground top-level
381+
compose call into the background. This shortcut requires a terminal that reports
382+
the Command key through the Kitty keyboard protocol; there is no control-key equivalent. Detached calls
383+
remain visible and selectable in the TUI runtime graph. Interrupting the originating
384+
turn does not stop them. The model receives each detached call's ID
382385
and can cancel it with `close({ call_id: "call_..." })`; the selected running
383386
background call can also be killed with `Ctrl+K` in the TUI. Completion and
384387
cancellation are delivered through the normal background-result lifecycle. Detached
@@ -542,6 +545,7 @@ child process with `KIT_RUNTIME_EVENTS=1`; other ACP hosts never see them.
542545
| `/model name` | switch immediately to the closest catalog match |
543546
| `⇧⏎`, `⌥⏎`, `^j` | newline |
544547
| `esc` | interrupt the running turn |
548+
| `⌘b` | move the newest running foreground compose call to the background |
545549
| `^c` | interrupt, or quit when idle |
546550
| `⌥←/→`, `^a`/`^e`, `home`/`end` | word and line movement |
547551
| `⌥⌫`, `^w` | delete the previous word |

docs/user/tui-and-sessions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ A session ID must be 1–128 ASCII letters, digits, `-`, or `_`. `kit prompt` us
2525
| `Enter` | Send a non-empty prompt when idle |
2626
| `Shift+Enter`, `Option+Enter`, `Ctrl+J` | Insert a newline |
2727
| `Esc` | Interrupt a running turn; dismiss a notice when idle |
28+
| `Command+B` | Move the newest running foreground top-level compose call to the background |
2829
| `Ctrl+C` | Interrupt a running turn; clear a non-empty idle prompt; quit when idle with an empty prompt |
2930
| `Ctrl+D` | Quit when the prompt is empty |
3031
| `Option+Left/Right`, `Ctrl+A`/`Ctrl+E`, `Home`/`End` | Move by word or to the start/end of a line |
@@ -63,7 +64,7 @@ Press `Esc` or `Ctrl+C` once to request cancellation. The TUI shows `interruptin
6364

6465
If a turn does not stop, press `Ctrl+C` again while Kit is cancelling to leave the TUI and terminate its agent child. On normal exit during a turn, Kit first requests cancellation and briefly allows the turn to unwind so tool outcomes can be persisted, then closes the session and releases its lock.
6566

66-
Interrupting a turn does not stop detached background calls. Select a running background tool card and press `Ctrl+K` to kill only that call; the selected title is accented and shows `^k kill`. When a background result starts an autonomous agent continuation, the TUI displays it as an active turn, and `Esc` or `Ctrl+C` interrupts it normally.
67+
Press `Command+B` to detach the newest running foreground top-level compose call without waiting for it to finish. This shortcut requires a terminal that reports the Command key through the Kitty keyboard protocol; it has no control-key equivalent. Interrupting a turn does not stop detached background calls. Select a running background tool card and press `Ctrl+K` to kill only that call; the selected title is accented and shows `^k kill`. When a background result starts an autonomous agent continuation, the TUI displays it as an active turn, and `Esc` or `Ctrl+C` interrupts it normally.
6768

6869
At an idle, non-empty editor, `Ctrl+C` clears the prompt instead of unexpectedly discarding it and quitting in one step; press it again with the empty editor to quit.
6970

src/protocols/acp.rs

Lines changed: 129 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use tokio::{
3939

4040
use crate::{
4141
provider::{ModelGroup, ModelSelection, ReasoningEffort, SelectableAdapter, model_catalog},
42-
runtime::{AcpDriverContext, BackgroundJobs, Runtime},
42+
runtime::{AcpDriverContext, BackgroundJobs, DetachRegistration, Runtime},
4343
};
4444

4545
const MODEL_CONFIG_ID: &str = "model";
@@ -289,6 +289,19 @@ pub(crate) struct CancelBackgroundResponse {
289289
pub cancelled: bool,
290290
}
291291

292+
/// Kit-private ACP extension used by the bundled TUI to detach one running compose call.
293+
#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)]
294+
#[request(method = "kit/compose/detach", response = DetachComposeResponse)]
295+
pub(crate) struct DetachComposeRequest {
296+
pub session_id: agentkit_acp::SessionId,
297+
pub call_id: String,
298+
}
299+
300+
#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)]
301+
pub(crate) struct DetachComposeResponse {
302+
pub detached: bool,
303+
}
304+
292305
/// Kit-private ACP notification that keeps the bundled TUI synchronized with
293306
/// turns started autonomously by background task results.
294307
#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcNotification)]
@@ -323,6 +336,7 @@ struct SessionHandle {
323336
token: u64,
324337
commands: mpsc::Sender<Command>,
325338
background_jobs: BackgroundJobs,
339+
tasks: TaskManagerHandle,
326340
}
327341

328342
#[derive(Clone)]
@@ -651,6 +665,7 @@ impl Server {
651665
let catalog = model_catalog(&current).await;
652666
let config_options = config_options(&current, reasoning_effort, &catalog);
653667
let background_jobs = driver.background_jobs.clone();
668+
let tasks = driver.tasks.clone();
654669
let canonical_transcript = driver.canonical_transcript;
655670
let (tx, rx) = mpsc::channel(8);
656671
let actor = SessionActor {
@@ -714,6 +729,7 @@ impl Server {
714729
token,
715730
commands: tx,
716731
background_jobs,
732+
tasks,
717733
},
718734
);
719735
drop(sessions);
@@ -797,6 +813,22 @@ impl Server {
797813
.ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string()))
798814
}
799815

816+
async fn detach_compose(
817+
&self,
818+
request: DetachComposeRequest,
819+
) -> Result<DetachComposeResponse, AcpRuntimeError> {
820+
let (background_jobs, tasks) = self
821+
.sessions
822+
.lock()
823+
.expect("ACP session map poisoned")
824+
.get(&request.session_id)
825+
.map(|session| (session.background_jobs.clone(), session.tasks.clone()))
826+
.ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?;
827+
Ok(DetachComposeResponse {
828+
detached: detach_compose_call(&tasks, &background_jobs, &request.call_id).await,
829+
})
830+
}
831+
800832
async fn cancel_background(
801833
&self,
802834
request: CancelBackgroundRequest,
@@ -814,6 +846,31 @@ impl Server {
814846
}
815847
}
816848

849+
async fn detach_compose_call(
850+
tasks: &TaskManagerHandle,
851+
background_jobs: &BackgroundJobs,
852+
call_id: &str,
853+
) -> bool {
854+
let Some(task) = tasks.list_running().await.into_iter().find(|task| {
855+
task.call_id.0 == call_id
856+
&& task.tool_name == agentkit_tool_compose::COMPOSE_TOOL_NAME
857+
&& task.kind == agentkit_task_manager::TaskKind::Foreground
858+
}) else {
859+
return false;
860+
};
861+
match background_jobs.detach(call_id) {
862+
Some(DetachRegistration::AlreadyDetached) => true,
863+
Some(DetachRegistration::Registered) => {
864+
if tasks.detach(task.id).await.is_err() {
865+
background_jobs.restore_foreground(call_id);
866+
return false;
867+
}
868+
true
869+
}
870+
None => false,
871+
}
872+
}
873+
817874
struct SessionActor<S: ModelSession> {
818875
session_id: agentkit_acp::SessionId,
819876
integration: Arc<AcpIntegration>,
@@ -1309,6 +1366,21 @@ fn component(
13091366
},
13101367
agent_client_protocol::on_receive_request!(),
13111368
)
1369+
.on_receive_request(
1370+
{
1371+
let state = Arc::clone(&state);
1372+
async move |request: DetachComposeRequest, responder, cx| {
1373+
let state = Arc::clone(&state);
1374+
cx.spawn(async move {
1375+
responder.respond_with_result(
1376+
state.detach_compose(request).await.map_err(sdk_error),
1377+
)
1378+
})?;
1379+
Ok(())
1380+
}
1381+
},
1382+
agent_client_protocol::on_receive_request!(),
1383+
)
13121384
.on_receive_request(
13131385
{
13141386
let state = Arc::clone(&state);
@@ -1752,7 +1824,7 @@ mod tests {
17521824
request: TurnRequest,
17531825
_cancellation: Option<TurnCancellation>,
17541826
) -> Result<Self::Turn, LoopError> {
1755-
self.turns.fetch_add(1, Ordering::SeqCst);
1827+
let turn = self.turns.fetch_add(1, Ordering::SeqCst) + 1;
17561828
self.user_items_seen.store(
17571829
request
17581830
.transcript
@@ -1769,12 +1841,12 @@ mod tests {
17691841
.count(),
17701842
Ordering::SeqCst,
17711843
);
1772-
let completed = request.transcript.iter().any(|item| {
1844+
let called = request.transcript.iter().any(|item| {
17731845
item.parts
17741846
.iter()
1775-
.any(|part| matches!(part, Part::ToolResult(_)))
1847+
.any(|part| matches!(part, Part::ToolCall(_)))
17761848
});
1777-
let events = if completed {
1849+
let events = if turn >= 3 {
17781850
let text = "autonomous background completion";
17791851
VecDeque::from([
17801852
ModelTurnEvent::Delta(Delta::BeginPart {
@@ -1794,10 +1866,30 @@ mod tests {
17941866
metadata: MetadataMap::new(),
17951867
}),
17961868
])
1869+
} else if called {
1870+
let text = "compose detached";
1871+
VecDeque::from([
1872+
ModelTurnEvent::Delta(Delta::BeginPart {
1873+
part_id: PartId::new("detached"),
1874+
kind: PartKind::Text,
1875+
}),
1876+
ModelTurnEvent::Delta(Delta::AppendText {
1877+
part_id: PartId::new("detached"),
1878+
chunk: text.into(),
1879+
}),
1880+
ModelTurnEvent::Finished(ModelTurnResult {
1881+
model: None,
1882+
response_id: None,
1883+
finish_reason: FinishReason::Completed,
1884+
output_items: vec![Item::text(ItemKind::Assistant, text)],
1885+
usage: None,
1886+
metadata: MetadataMap::new(),
1887+
}),
1888+
])
17971889
} else {
17981890
let call = ToolCallPart {
17991891
id: ToolCallId::new("background-call"),
1800-
name: "background-test".into(),
1892+
name: agentkit_tool_compose::COMPOSE_TOOL_NAME.into(),
18011893
input: json!({}),
18021894
metadata: MetadataMap::new(),
18031895
};
@@ -1921,7 +2013,7 @@ mod tests {
19212013
}
19222014

19232015
#[tokio::test]
1924-
async fn completed_background_task_advances_actor_and_emits_unsolicited_update() {
2016+
async fn foreground_compose_detaches_out_of_band_and_completes_autonomously() {
19252017
let turns = Arc::new(AtomicUsize::new(0));
19262018
let user_items_seen = Arc::new(AtomicUsize::new(0));
19272019
let notification_items_seen = Arc::new(AtomicUsize::new(0));
@@ -1962,19 +2054,14 @@ mod tests {
19622054
}
19632055
});
19642056

1965-
let task_manager =
1966-
AsyncTaskManager::new().routing(|request: &agentkit_tools_core::ToolRequest| {
1967-
if request.tool_name.0 == "background-test" {
1968-
RoutingDecision::Background
1969-
} else {
1970-
RoutingDecision::Foreground
1971-
}
1972-
});
2057+
let task_manager = AsyncTaskManager::new()
2058+
.routing(|_request: &agentkit_tools_core::ToolRequest| RoutingDecision::Foreground);
19732059
let tasks = task_manager.handle();
2060+
let background_jobs = BackgroundJobs::default();
19742061
let tools = ToolRegistry::new().with(BlockingTool {
19752062
spec: ToolSpec {
1976-
name: ToolName::new("background-test"),
1977-
description: "controlled background tool".into(),
2063+
name: ToolName::new(agentkit_tool_compose::COMPOSE_TOOL_NAME),
2064+
description: "controlled compose tool".into(),
19782065
input_schema: json!({"type": "object", "additionalProperties": false}),
19792066
output_schema: None,
19802067
annotations: ToolAnnotations::default(),
@@ -2007,7 +2094,7 @@ mod tests {
20072094
integration: Arc::clone(&integration),
20082095
binding: SessionBindingGuard::new(Arc::clone(&integration), acp_session_id.clone()),
20092096
driver,
2010-
tasks,
2097+
tasks: tasks.clone(),
20112098
adapter: SelectableAdapter::new(crate::ProviderKind::OpenAiSubscription, "gpt-5.4")
20122099
.unwrap(),
20132100
catalog: Vec::new(),
@@ -2029,21 +2116,35 @@ mod tests {
20292116
})
20302117
.await
20312118
.unwrap();
2119+
while !entered.load(Ordering::SeqCst) {
2120+
tokio::task::yield_now().await;
2121+
}
2122+
assert!(detach_compose_call(&tasks, &background_jobs, "background-call").await);
2123+
background_jobs.register_foreground_for_test("background-call");
2124+
assert!(background_jobs.is_detached_for_test("background-call"));
20322125
timeout(Duration::from_secs(1), reply_rx)
20332126
.await
2034-
.expect("first prompt remained blocked on the background tool")
2127+
.expect("prompt remained blocked after the out-of-band detach")
20352128
.unwrap()
20362129
.unwrap();
2037-
assert_eq!(turns.load(Ordering::SeqCst), 1);
2038-
timeout(Duration::from_secs(1), async {
2039-
while !entered.load(Ordering::SeqCst) {
2130+
assert_eq!(turns.load(Ordering::SeqCst), 2);
2131+
2132+
release.notify_one();
2133+
let completed = timeout(Duration::from_secs(1), async {
2134+
loop {
2135+
let completed = tasks.list_completed().await;
2136+
if !completed.is_empty() {
2137+
break completed;
2138+
}
20402139
tokio::task::yield_now().await;
20412140
}
20422141
})
20432142
.await
2044-
.expect("background tool never started");
2045-
2046-
release.notify_one();
2143+
.expect("detached compose did not complete");
2144+
assert_eq!(
2145+
completed[0].kind,
2146+
agentkit_task_manager::TaskKind::Background
2147+
);
20472148
let notification = timeout(Duration::from_secs(1), async {
20482149
loop {
20492150
let notification = updates_rx.recv().await.expect("update stream closed");
@@ -2064,7 +2165,7 @@ mod tests {
20642165
assert!(!ended.active);
20652166
assert_eq!(started.turn_id, ended.turn_id);
20662167
assert_eq!(started.session_id, acp_session_id);
2067-
assert_eq!(turns.load(Ordering::SeqCst), 2);
2168+
assert_eq!(turns.load(Ordering::SeqCst), 3);
20682169
assert_eq!(
20692170
user_items_seen.load(Ordering::SeqCst),
20702171
1,
@@ -2081,8 +2182,8 @@ mod tests {
20812182
let mcp_ended = turn_states_rx.recv().await.expect("missing MCP turn end");
20822183
assert!(mcp_started.active);
20832184
assert!(!mcp_ended.active);
2084-
assert_eq!(turns.load(Ordering::SeqCst), 3);
2085-
assert_eq!(notification_items_seen.load(Ordering::SeqCst), 1);
2185+
assert_eq!(turns.load(Ordering::SeqCst), 4);
2186+
assert_eq!(notification_items_seen.load(Ordering::SeqCst), 2);
20862187
assert_eq!(user_items_seen.load(Ordering::SeqCst), 1);
20872188

20882189
let (close_tx, close_rx) = oneshot::channel();

0 commit comments

Comments
 (0)