Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
All notable changes to Subconscious Code are documented here. This project uses
[Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- Interactive follow-up queue: press `Tab` during a turn to queue the current
draft, or `Esc` to hand it off after the active tool call.

### Changed

- Turn dividers show only elapsed time unless files changed, then add compact
`+N -N` counts without redundant prose.

### Fixed

- In-app copy now uses native system clipboards locally and tmux's clipboard
bridge when available, with OSC 52 retained for remote sessions.

## [0.1.0] - 2026-09-01

Initial public release.
Expand All @@ -25,4 +42,5 @@ Initial public release.
- Benchmark completion review, no-progress handling, and endpoint diagnostics.
- Linux sandboxing and fail-closed headless permissions.

[Unreleased]: https://github.com/subconscious-systems/subconscious-code/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/subconscious-systems/subconscious-code/releases/tag/v0.1.0
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ sc

Inside the TUI, type a request normally. Use `@path` to include a file, `/menu`
to edit settings or resume a session, `Shift+Tab` to change permission mode,
`Esc` to interrupt a turn, and `Ctrl+C` to quit.
`Tab` to queue a draft while a turn runs, `Esc` to stop, and `Ctrl+C` to quit.
If a message is queued, `Esc` waits for the current tool call to finish and
then sends it; press `Esc` again to stop immediately.

For a non-interactive read-only task:

Expand Down Expand Up @@ -308,11 +310,12 @@ fields without prompt or tool-result content. The trajectory is an explicit
transcript artifact and may contain sensitive task data; review it before
sharing.

In the TUI: `Shift+Tab` cycles permission mode, `Esc` cancels a turn, `Ctrl+C`
quits, `@` completes file paths, `/` completes commands (`/menu`, `/clear`,
`/help`, `/mode`, `/rewind`). The status bar shows the model, mode, and current
context tokens/cache-hit rate; a preflight estimate is shown until the provider
returns the authoritative prompt-token count.
In the TUI: `Shift+Tab` cycles permission mode, `Tab` queues a draft during a
turn, `Esc` stops (or sends a queued message after the active tool call), and
`Ctrl+C` quits. `@` completes file paths and `/` completes commands (`/menu`,
`/clear`, `/help`, `/mode`, `/rewind`). The status bar shows the model, mode,
and current context tokens/cache-hit rate; a preflight estimate is shown until
the provider returns the authoritative prompt-token count.

### `/menu`

Expand Down
3 changes: 3 additions & 0 deletions crates/rc-rt/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use rc_core::{AgentMode, AskResponse};
pub enum UserAction {
/// Submit a user prompt; the driver runs one turn.
Submit(String),
/// Queue a user prompt behind the in-flight turn. If the turn finishes or
/// is cancelled, the queued prompt starts automatically.
Queue(String),
/// Cancel the in-flight turn, including its model/tool cancellation budget,
/// and deny any pending permission prompt so the turn can terminate.
Cancel,
Expand Down
55 changes: 42 additions & 13 deletions crates/rc-rt/src/pump.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! The action pump: drains `UserAction`s from the host and dispatches them.
//!
//! - `Submit` starts a turn with a fresh cancel token the pump owns as a local
//! (one task → no shared-slot race with a new turn).
//! (one task → no shared-slot race with a new turn); `Queue` retains a
//! follow-up until that turn reaches its terminal boundary.
//! - `Cancel` fires the token and denies any pending ask so the prompter
//! unblocks and the turn winds down.
//! - `SetMode` swaps the engine mode atomically (immediate), tells the host via
Expand All @@ -12,6 +13,7 @@
//! driver exit once its current turn finishes).

use rc_core::PermissionChecker;
use std::collections::VecDeque;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

Expand All @@ -30,13 +32,21 @@ pub(crate) async fn pump_task(
) {
let mut active: Option<(u64, CancellationToken)> = None;
let mut next_turn_id = 0u64;
let mut queued = VecDeque::new();
loop {
let action = tokio::select! {
biased;
feedback = feedback.recv(), if active.is_some() => {
if let Some(DriverFeedback::TurnFinished { turn_id }) = feedback {
if active.as_ref().is_some_and(|(active_id, _)| *active_id == turn_id) {
active = None;
if let Some(prompt) = queued.pop_front() {
let Some(next) = start_turn(&mut next_turn_id, prompt, &driver_tx).await
else {
break;
};
active = Some(next);
}
}
}
continue;
Expand All @@ -52,19 +62,19 @@ pub(crate) async fn pump_task(
));
continue;
}
next_turn_id = next_turn_id.wrapping_add(1);
let token = CancellationToken::new();
active = Some((next_turn_id, token.clone()));
if driver_tx
.send(DriverCmd::Run {
turn_id: next_turn_id,
prompt,
cancel: token,
})
.await
.is_err()
{
let Some(next) = start_turn(&mut next_turn_id, prompt, &driver_tx).await else {
break;
};
active = Some(next);
}
UserAction::Queue(prompt) => {
if active.is_some() {
queued.push_back(prompt);
} else {
let Some(next) = start_turn(&mut next_turn_id, prompt, &driver_tx).await else {
break;
};
active = Some(next);
}
}
UserAction::Cancel => {
Expand Down Expand Up @@ -131,3 +141,22 @@ pub(crate) async fn pump_task(
}
}
}

async fn start_turn(
next_turn_id: &mut u64,
prompt: String,
driver_tx: &mpsc::Sender<DriverCmd>,
) -> Option<(u64, CancellationToken)> {
*next_turn_id = next_turn_id.wrapping_add(1);
let turn_id = *next_turn_id;
let token = CancellationToken::new();
driver_tx
.send(DriverCmd::Run {
turn_id,
prompt,
cancel: token.clone(),
})
.await
.ok()?;
Some((turn_id, token))
}
10 changes: 10 additions & 0 deletions crates/rc-rt/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,20 @@ impl Runtime {

/// Push a user action (sync — safe from any thread/task).
pub fn action(&self, action: UserAction) {
self.try_action(action);
}

/// Try to push a user action, returning whether the runtime accepted it.
/// Interactive hosts use this when they must only mutate local UI state
/// after the matching action is safely in the runtime queue.
pub fn try_action(&self, action: UserAction) -> bool {
if self.actions_tx.try_send(action).is_err() {
self.events_tx.send(AgentEvent::Notice(
"runtime action queue is full; input was not accepted".into(),
));
false
} else {
true
}
}

Expand Down
144 changes: 144 additions & 0 deletions crates/rc-rt/tests/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! `rc-cli/tests/*.rs`.

use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

Expand Down Expand Up @@ -322,6 +323,149 @@ async fn duplicate_submit_is_rejected_until_the_active_turn_finishes() {
rt.shutdown().await;
}

#[tokio::test]
async fn queued_prompt_starts_after_the_active_turn_finishes() {
struct QueuedModel {
calls: AtomicUsize,
entered: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}

#[async_trait]
impl Model for QueuedModel {
async fn complete(
&self,
_req: ModelRequest,
sink: &dyn EventSink,
) -> Result<ModelResponse, ModelError> {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
if call == 0 {
self.entered.notify_one();
self.release.notified().await;
}
let text = if call == 0 {
"first finished"
} else {
"queued finished"
};
sink.on_text(text);
Ok(resp_stop(text))
}
}

let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let rt = Runtime::new(
agent(
Arc::new(QueuedModel {
calls: AtomicUsize::new(0),
entered: entered.clone(),
release: release.clone(),
}),
Arc::new(ToolRegistry::new(vec![])),
Arc::new(AllowAllChecker),
),
session(),
None,
);
let mut rx = rt.subscribe();
rt.action(UserAction::Submit("first".into()));
entered.notified().await;
rt.action(UserAction::Queue("follow up".into()));
release.notify_one();

let got = tokio::time::timeout(Duration::from_secs(2), async {
let mut events = Vec::new();
let mut idles = 0;
while idles < 2 {
match rx.recv().await {
Some(Ok(event)) => {
if matches!(event, AgentEvent::Idle) {
idles += 1;
}
events.push(event);
}
Some(Err(_)) => {}
None => panic!("event stream closed before queued turn"),
}
}
events
})
.await
.expect("queued turn timed out");

assert_eq!(
got.iter()
.filter(|event| matches!(event, AgentEvent::Ready))
.count(),
2
);
assert!(got
.iter()
.any(|event| matches!(event, AgentEvent::Text(text) if text == "first finished")));
assert!(got
.iter()
.any(|event| matches!(event, AgentEvent::Text(text) if text == "queued finished")));
rt.shutdown().await;
}

#[tokio::test]
async fn cancelling_an_active_turn_preserves_and_starts_its_queue() {
struct CancelThenRunModel {
calls: AtomicUsize,
entered: Arc<tokio::sync::Notify>,
}

#[async_trait]
impl Model for CancelThenRunModel {
async fn complete(
&self,
_req: ModelRequest,
sink: &dyn EventSink,
) -> Result<ModelResponse, ModelError> {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
self.entered.notify_one();
std::future::pending::<()>().await;
unreachable!();
}
sink.on_text("queue survived cancellation");
Ok(resp_stop("queue survived cancellation"))
}
}

let entered = Arc::new(tokio::sync::Notify::new());
let rt = Runtime::new(
agent(
Arc::new(CancelThenRunModel {
calls: AtomicUsize::new(0),
entered: entered.clone(),
}),
Arc::new(ToolRegistry::new(vec![])),
Arc::new(AllowAllChecker),
),
session(),
None,
);
let mut rx = rt.subscribe();
rt.action(UserAction::Submit("first".into()));
entered.notified().await;
rt.action(UserAction::Queue("follow up".into()));
rt.action(UserAction::Cancel);

let got = tokio::time::timeout(Duration::from_secs(2), async {
drain_until(&mut rx, |event| {
matches!(event, AgentEvent::Text(text) if text == "queue survived cancellation")
})
.await
})
.await
.expect("queued turn did not start after cancellation");
assert!(got
.iter()
.any(|event| matches!(event, AgentEvent::Outcome(LoopOutcome::Cancelled))));
rt.shutdown().await;
}

#[tokio::test]
async fn file_change_artifact_arrives_before_its_tool_end() {
let tools = Arc::new(ToolRegistry::new(vec![
Expand Down
Loading
Loading