Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
38 changes: 37 additions & 1 deletion crates/rustyclaw-core/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub enum CommandAction {
ThreadClose(u64),
/// Rename a thread (id, new_label)
ThreadRename(u64, String),
/// Background the current foreground thread
ThreadBackground,
/// Foreground a thread by ID
ThreadForeground(u64),
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -91,6 +95,8 @@ fn base_command_names() -> Vec<String> {
"thread list".into(),
"thread close".into(),
"thread rename".into(),
"thread bg".into(),
"thread fg".into(),
"clawhub".into(),
"clawhub auth".into(),
"clawhub auth login".into(),
Expand Down Expand Up @@ -315,6 +321,12 @@ pub fn handle_command(input: &str, context: &mut CommandContext<'_>) -> CommandR
.to_string(),
" /npm <action> [pkg …] - Node.js/npm admin (setup/install/run/build/…)"
.to_string(),
" /thread new <label> - Create a new chat thread".to_string(),
" /thread list - Show threads (or focus sidebar)".to_string(),
" /thread close <id> - Close a thread".to_string(),
" /thread rename <id> <l> - Rename a thread".to_string(),
" /thread bg - Background the current thread".to_string(),
" /thread fg <id> - Foreground a thread by ID".to_string(),
],
action: CommandAction::None,
},
Expand Down Expand Up @@ -756,10 +768,34 @@ fn handle_thread_subcommand(parts: &[&str]) -> CommandResponse {
messages: vec!["Press Tab to focus sidebar and view threads.".to_string()],
action: CommandAction::ThreadList,
},
Some("bg") | Some("background") => CommandResponse {
messages: vec!["Backgrounding current thread…".to_string()],
action: CommandAction::ThreadBackground,
},
Some("fg") | Some("foreground") => {
let id_str = parts.get(1).copied().unwrap_or("");
match id_str.parse::<u64>() {
Ok(0) => CommandResponse {
messages: vec!["Thread ID 0 is reserved. Use a valid thread ID.".to_string()],
action: CommandAction::None,
},
Ok(id) => CommandResponse {
messages: vec![format!("Foregrounding thread {}…", id)],
action: CommandAction::ThreadForeground(id),
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
},
Err(_) => CommandResponse {
messages: vec![
"Usage: /thread fg <id>".to_string(),
"Get thread IDs from /thread list or sidebar.".to_string(),
],
action: CommandAction::None,
},
}
}
Some(sub) => CommandResponse {
messages: vec![
format!("Unknown thread subcommand: {}", sub),
"Available: new, list, close, rename".to_string(),
"Available: new, list, close, rename, bg, fg".to_string(),
],
action: CommandAction::None,
},
Expand Down
42 changes: 40 additions & 2 deletions crates/rustyclaw-core/src/gateway/mod.rs
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,8 @@ where
label: t.label.clone(),
description: t.description.clone(),
status: Some(t.status.clone()),
kind_icon: Some(t.icon.clone()),
status_icon: Some(t.status_icon.clone()),
is_foreground: t.is_foreground,
message_count: t.message_count,
has_summary: t.has_summary,
Expand All @@ -478,11 +480,18 @@ where
}
}

let status_icon = if task.status.is_terminal() {
"✓"
} else {
"▶"
};
threads.push(protocol::ThreadInfoDto {
id: task.id.0,
label: task.kind.display_name().to_string(),
description: Some(task.kind.description()),
status: Some(format!("{:?}", task.status)),
kind_icon: Some("📋".to_string()),
status_icon: Some(status_icon.to_string()),
is_foreground: false,
message_count: 0,
has_summary: task.status.is_terminal(),
Expand Down Expand Up @@ -511,11 +520,20 @@ where
SessionStatus::Stopped => "Stopped",
};

let session_status_icon = match session.status {
SessionStatus::Active => "▶",
SessionStatus::Completed => "✓",
SessionStatus::Error => "✗",
SessionStatus::Timeout => "⊘",
SessionStatus::Stopped => "⊘",
};
threads.push(protocol::ThreadInfoDto {
id,
label: session.label.clone().unwrap_or_else(|| "Sub-agent".to_string()),
description: session.task.clone(),
status: Some(status_str.to_string()),
kind_icon: Some("🤖".to_string()),
status_icon: Some(session_status_icon.to_string()),
is_foreground: false,
message_count: session.messages.len(),
has_summary: session.status != SessionStatus::Active,
Expand Down Expand Up @@ -1321,6 +1339,24 @@ async fn handle_connection(
}
ClientPayload::ThreadSwitch { thread_id } => {
debug!("Thread switch request: {}", thread_id);

// thread_id == 0 is a sentinel meaning "background current thread"
if thread_id == 0 {
// Clear foreground — no thread is active
thread_mgr.clear_foreground();
let frame = ServerFrame {
frame_type: ServerFrameType::ThreadSwitched,
payload: ServerPayload::ThreadSwitched {
thread_id: 0,
context_summary: None,
},
};
send_frame(&mut writer, &frame).await?;
send_threads_update(&mut writer, &thread_mgr, &task_mgr, None).await?;
let _ = thread_mgr.save_to_file(&threads_path);
continue;
}

let target_id = crate::threads::ThreadId(thread_id);

// Get current foreground thread for compaction
Expand Down Expand Up @@ -1383,8 +1419,10 @@ async fn handle_connection(
.get(target_id)
.and_then(|t| t.compact_summary.clone());

// Perform the switch
if thread_mgr.switch_to(target_id).is_some() {
// Perform the switch (use switch_foreground which returns bool,
// not switch_to which returns old foreground ID — the latter
// returns None when there is no previous foreground, e.g. after /thread bg)
if thread_mgr.switch_foreground(target_id) {
let frame = ServerFrame {
frame_type: ServerFrameType::ThreadSwitched,
payload: ServerPayload::ThreadSwitched {
Expand Down
6 changes: 6 additions & 0 deletions crates/rustyclaw-core/src/gateway/protocol/frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,10 @@ pub struct ThreadInfoDto {
pub description: Option<String>,
/// Task status (None = simple thread, Some = spawned task)
pub status: Option<String>,
/// Icon for the thread kind (e.g. chat, sub-agent, background, task)
pub kind_icon: Option<String>,
/// Icon for the thread status (e.g. running, completed, failed)
pub status_icon: Option<String>,
pub is_foreground: bool,
pub message_count: usize,
pub has_summary: bool,
Comment on lines +428 to 434
Expand Down Expand Up @@ -871,6 +875,8 @@ mod frame_size_tests {
label: "Main".to_string(),
description: None,
status: None,
kind_icon: None,
status_icon: None,
is_foreground: true,
message_count: 0,
has_summary: false,
Expand Down
2 changes: 1 addition & 1 deletion crates/rustyclaw-core/src/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,6 @@ mod tests {
let subagents = manager.list(Some(&[SessionKind::Subagent]), false, 10);
assert_eq!(subagents.len(), 2);
}
}

#[test]
fn test_subagent_appears_in_active_list() {
Expand All @@ -398,3 +397,4 @@ mod tests {
assert_eq!(active[0].status, SessionStatus::Active);
assert_eq!(active[0].label, Some("test".to_string()));
}
}
10 changes: 10 additions & 0 deletions crates/rustyclaw-core/src/threads/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,16 @@ impl ThreadManager {
true
}

/// Clear the foreground — no thread is active (background all).
pub fn clear_foreground(&mut self) {
if let Some(old_id) = self.foreground_id {
if let Some(t) = self.threads.get_mut(&old_id) {
t.is_foreground = false;
}
}
self.foreground_id = None;
}
Comment on lines +265 to +272
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 clear_foreground() does not emit a thread event, breaking event-driven sidebar updates

Every other state-changing method in ThreadManager emits an event — switch_foreground emits Foregrounded (manager.rs:256), remove emits Removed (manager.rs:374), set_status emits StatusChanged (manager.rs:226), etc. The new clear_foreground() modifies foreground_id and the thread's is_foreground flag but does not emit any event. The gateway's event-driven sidebar update loop at crates/rustyclaw-core/src/gateway/mod.rs:1555-1561 listens for these events to push updates. While the specific call site at mod.rs:1346 manually sends updates after calling clear_foreground(), this breaks the event system contract — any future caller of clear_foreground() will silently fail to notify subscribers.

Suggested change
pub fn clear_foreground(&mut self) {
if let Some(old_id) = self.foreground_id {
if let Some(t) = self.threads.get_mut(&old_id) {
t.is_foreground = false;
}
}
self.foreground_id = None;
}
pub fn clear_foreground(&mut self) {
let old_id = self.foreground_id;
if let Some(old) = old_id {
if let Some(t) = self.threads.get_mut(&old) {
t.is_foreground = false;
}
}
self.foreground_id = None;
if old_id.is_some() {
self.emit(ThreadEvent::Foregrounded {
thread_id: ThreadId(0),
previous_foreground: old_id,
});
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is a valid consistency concern. The current call site in the gateway (mod.rs:1346) manually sends send_threads_update after clear_foreground(), so the sidebar does update correctly today. Adding the event emission would be a defensive improvement for future callers. Leaving this as-is for now since the current behavior is correct; happy to add the event if the reviewer wants it.


/// Rename a thread.
pub fn rename(&mut self, id: ThreadId, new_label: impl Into<String>) -> bool {
if let Some(thread) = self.threads.get_mut(&id) {
Expand Down
5 changes: 5 additions & 0 deletions crates/rustyclaw-core/src/threads/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@
mod model;
mod manager;
mod events;
pub mod subtask;

pub use model::*;
pub use manager::*;
pub use events::*;
pub use subtask::{
SubtaskHandle, SubtaskRegistry, SubtaskResult, SpawnOptions,
spawn_subagent, spawn_task, spawn_background,
};

// Backwards compatibility: TaskId is now ThreadId
pub type TaskId = ThreadId;
Loading
Loading