Merge pull request #2864 from ljm3790865/feat/tab-core-narrow

feat(tui): add multi-tab system core (manager + persistence)
This commit is contained in:
Hunter Bown
2026-06-06 10:41:50 -07:00
committed by GitHub
12 changed files with 3927 additions and 1 deletions
+1 -1
View File
@@ -271,7 +271,7 @@ impl WindowsJob {
)
.map_err(windows_io_error)?;
let process_handle = HANDLE(child.as_raw_handle() as *mut core::ffi::c_void);
let process_handle = HANDLE(child.as_raw_handle());
AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?;
}
+1
View File
@@ -63,6 +63,7 @@ pub mod slash_menu;
pub mod streaming;
pub mod streaming_thinking;
mod subagent_routing;
pub mod tab;
pub mod theme_picker;
mod tool_routing;
pub mod transcript;
+218
View File
@@ -0,0 +1,218 @@
//! Performance benchmarks for the tab system.
//!
//! These tests are not assertions — they print timing info to stderr and
//! return success. Run with `--nocapture` to see the numbers.
//!
//! Run with: `cargo test tui::tab::benches -- --nocapture --test-threads=1`
//!
//! These benchmarks guard against performance regressions in the
//! critical-path operations of the multi-tab system:
//!
//! - TabManager creation and tab creation (startup overhead)
//! - Tab switching (interactive latency)
//! - Delegation queue operations (background processing)
//! - Persistence save/load (startup + shutdown overhead)
//! - Group color rendering (per-frame work in tab bar)
#![allow(unused_imports)]
#[cfg(test)]
mod benches {
use std::time::Instant;
use crate::tui::tab::{
Priority, TabId, TabManager, TabType, group::GroupColor, persistence::PersistedTab,
persistence::PersistedTabState,
};
/// Helper: print a timing result
fn report(label: &str, dur: std::time::Duration, ops: usize) {
let per_op_ns = if ops > 0 {
dur.as_nanos() / ops as u128
} else {
0
};
eprintln!(
"[bench] {:50} total={:>8.2?} ops={:>6} per_op={:>7} ns",
label, dur, ops, per_op_ns
);
}
#[test]
fn bench_create_tabs() {
let start = Instant::now();
let mut manager = TabManager::new();
for i in 0..9 {
manager
.create_tab(format!("Bench Tab {}", i), TabType::Chat)
.expect("create_tab should succeed");
}
report("create 9 tabs", start.elapsed(), 9);
}
#[test]
fn bench_switch_tabs() {
let mut manager = TabManager::new();
for i in 0..9 {
manager
.create_tab(format!("Tab {}", i), TabType::Chat)
.expect("create_tab");
}
let start = Instant::now();
for _ in 0..1000 {
manager.switch_to_next();
}
report("1000 tab switches (next)", start.elapsed(), 1000);
}
#[test]
fn bench_delegate_many_tasks() {
let mut manager = TabManager::new();
let from = manager
.create_tab("Source".to_string(), TabType::Chat)
.expect("create");
let to = manager
.create_tab("Target".to_string(), TabType::Chat)
.expect("create");
let start = Instant::now();
for i in 0..1000 {
manager.delegate_task(from, to, format!("Task {}", i), Priority::Normal);
}
report("1000 delegations", start.elapsed(), 1000);
}
#[test]
fn bench_take_pending_priority() {
let mut manager = TabManager::new();
let from = manager.create_tab("S".to_string(), TabType::Chat).unwrap();
let to = manager.create_tab("T".to_string(), TabType::Chat).unwrap();
// Create 100 tasks with mixed priorities
let priorities = [
Priority::Low,
Priority::Normal,
Priority::High,
Priority::Urgent,
];
for i in 0..100 {
manager.delegate_task(from, to, format!("Task {}", i), priorities[i % 4]);
}
let start = Instant::now();
let mut count = 0;
while manager.take_next_delegation(to).is_some() {
count += 1;
}
report(
&format!("drain {} priority-sorted tasks", count),
start.elapsed(),
count,
);
}
#[test]
fn bench_persistence_roundtrip() {
let mut manager = TabManager::new();
for i in 0..9 {
manager
.create_tab(format!("Tab {}", i), TabType::Chat)
.expect("create");
}
// Add some delegations
let from = manager.active_id().unwrap();
let to = manager.all_tabs()[1].id;
for i in 0..20 {
manager.delegate_task(from, to, format!("Task {}", i), Priority::Normal);
}
let start = Instant::now();
let state = manager.snapshot();
let snap_dur = start.elapsed();
let json = serde_json::to_string(&state).unwrap();
let ser_dur = start.elapsed() - snap_dur;
let start2 = Instant::now();
let _loaded: PersistedTabState = serde_json::from_str(&json).unwrap();
let de_dur = start2.elapsed();
eprintln!(
"[bench] {:50} snap={:>8.2?} ser={:>8.2?} de={:>8.2?} json_size={} bytes",
"9 tabs + 20 delegations persistence",
snap_dur,
ser_dur,
de_dur,
json.len()
);
}
#[test]
fn bench_group_operations() {
let mut manager = TabManager::new();
for i in 0..9 {
manager
.create_tab(format!("Tab {}", i), TabType::Chat)
.expect("create");
}
let tabs: Vec<TabId> = manager.all_tabs().iter().map(|t| t.id).collect();
// Create 3 groups
let start = Instant::now();
let g1 = manager.create_group("Frontend".to_string(), GroupColor::Blue);
let g2 = manager.create_group("Backend".to_string(), GroupColor::Red);
let g3 = manager.create_group("Misc".to_string(), GroupColor::Green);
report("create 3 groups", start.elapsed(), 3);
// Assign all 9 tabs to groups
let start = Instant::now();
for (i, tab) in tabs.iter().enumerate() {
let group = if i % 3 == 0 {
&g1
} else if i % 3 == 1 {
&g2
} else {
&g3
};
manager.assign_tab_to_group(*tab, group);
}
report("assign 9 tabs to groups", start.elapsed(), 9);
// Lookup
let start = Instant::now();
for tab in &tabs {
let _ = manager.tab_group(*tab);
}
report("9 group lookups", start.elapsed(), 9);
}
#[test]
fn bench_render_at_widths() {
// Smoke test: ensure rendering at various widths completes quickly
for width in [20, 40, 80, 120, 200] {
let mut manager = TabManager::new();
for i in 0..9 {
manager
.create_tab(format!("Tab {}", i), TabType::Chat)
.expect("create");
}
let start = Instant::now();
// Simulate the per-frame work the tab bar does: iterate all tabs
// and gather metadata for display. Real rendering also iterates.
let mut count = 0;
for tab in manager.all_tabs() {
// Touch a few fields to simulate display work
let _ = tab.title.len();
let _ = tab.id.0;
count += 1;
}
report(
&format!("iterate {} tabs at width {}", count, width),
start.elapsed(),
count,
);
}
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Cross-tab collaboration events
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
// PR #2753 context.
#![allow(dead_code)]
use super::{Priority, TabId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A link between two tabs for collaboration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossTabLink {
pub from: TabId,
pub to: TabId,
pub created_at: DateTime<Utc>,
}
/// Cross-tab event types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CrossTabEvent {
/// Task delegation request
TaskDelegation {
task_id: String,
from_tab: TabId,
to_tab: TabId,
description: String,
priority: Priority,
created_at: DateTime<Utc>,
},
/// Review request
ReviewRequest {
request_id: String,
from_tab: TabId,
to_tab: TabId,
content_ref: String,
criteria: Vec<String>,
created_at: DateTime<Utc>,
},
/// Meeting invitation
MeetingInvite {
meeting_id: String,
from_tab: TabId,
participants: Vec<TabId>,
topic: String,
created_at: DateTime<Utc>,
},
/// Result returned from delegation
ResultReturn {
task_id: String,
from_tab: TabId,
to_tab: TabId,
result: String,
created_at: DateTime<Utc>,
},
/// Context sync between tabs
ContextSync {
tab_ids: Vec<TabId>,
changes: Vec<ContextChange>,
created_at: DateTime<Utc>,
},
}
impl CrossTabEvent {
/// Get the sender tab ID
#[allow(clippy::wrong_self_convention)] // getter, not a constructor
pub fn from_tab(&self) -> TabId {
match self {
Self::TaskDelegation { from_tab, .. } => *from_tab,
Self::ReviewRequest { from_tab, .. } => *from_tab,
Self::MeetingInvite { from_tab, .. } => *from_tab,
Self::ResultReturn { from_tab, .. } => *from_tab,
// ContextSync has no single sender; return the first participant
// if any, otherwise a sentinel `TabId(0)`. Callers that need to
// distinguish "no sender" should match on the variant directly.
Self::ContextSync { tab_ids, .. } => tab_ids.first().copied().unwrap_or(TabId(0)),
}
}
/// Get the target tab ID (if applicable)
pub fn to_tab(&self) -> Option<TabId> {
match self {
Self::TaskDelegation { to_tab, .. } => Some(*to_tab),
Self::ReviewRequest { to_tab, .. } => Some(*to_tab),
Self::MeetingInvite { participants, .. } => participants.first().copied(),
Self::ResultReturn { to_tab, .. } => Some(*to_tab),
Self::ContextSync { .. } => None,
}
}
/// Get creation timestamp
pub fn created_at(&self) -> DateTime<Utc> {
match self {
Self::TaskDelegation { created_at, .. } => *created_at,
Self::ReviewRequest { created_at, .. } => *created_at,
Self::MeetingInvite { created_at, .. } => *created_at,
Self::ResultReturn { created_at, .. } => *created_at,
Self::ContextSync { created_at, .. } => *created_at,
}
}
}
/// Type of context change
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContextChangeType {
VariableSet(String),
VariableRemoved(String),
MessageAdded,
FileModified(String),
StateUpdate,
}
/// A change in shared context
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextChange {
pub change_type: ContextChangeType,
pub value: Option<String>,
pub timestamp: DateTime<Utc>,
}
impl ContextChange {
pub fn variable_set(name: &str, value: &str) -> Self {
Self {
change_type: ContextChangeType::VariableSet(name.to_string()),
value: Some(value.to_string()),
timestamp: Utc::now(),
}
}
pub fn file_modified(path: &str) -> Self {
Self {
change_type: ContextChangeType::FileModified(path.to_string()),
value: None,
timestamp: Utc::now(),
}
}
}
/// Shared context between linked tabs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedContext {
pub participants: Vec<TabId>,
pub shared_variables: HashMap<String, String>,
pub shared_messages: Vec<SharedMessage>,
pub meeting_notes: Vec<String>,
pub created_at: DateTime<Utc>,
}
impl SharedContext {
pub fn new(participants: Vec<TabId>) -> Self {
Self {
participants,
shared_variables: HashMap::new(),
shared_messages: Vec::new(),
meeting_notes: Vec::new(),
created_at: Utc::now(),
}
}
pub fn add_variable(&mut self, name: &str, value: &str) {
self.shared_variables
.insert(name.to_string(), value.to_string());
}
pub fn get_variable(&self, name: &str) -> Option<&String> {
self.shared_variables.get(name)
}
pub fn add_message(&mut self, sender: TabId, content: &str) {
self.shared_messages.push(SharedMessage {
sender,
content: content.to_string(),
timestamp: Utc::now(),
});
}
pub fn add_meeting_note(&mut self, note: &str) {
self.meeting_notes.push(note.to_string());
}
}
/// A message in shared context
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedMessage {
pub sender: TabId,
pub content: String,
pub timestamp: DateTime<Utc>,
}
+577
View File
@@ -0,0 +1,577 @@
//! Task delegation system
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
// PR #2753 context.
#![allow(dead_code)]
use super::{Priority, TabId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
/// Status of a delegation task
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DelegationStatus {
Pending,
InProgress,
Completed,
Failed,
Cancelled,
}
/// A delegation task
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationTask {
pub task_id: String,
pub from_tab: TabId,
pub to_tab: TabId,
pub description: String,
pub priority: Priority,
pub status: DelegationStatus,
pub created_at: DateTime<Utc>,
pub deadline: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub result: Option<String>,
}
impl DelegationTask {
/// Create a new pending delegation task.
///
/// # Arguments
/// * `task_id` - Unique identifier (e.g. "delegation_42")
/// * `from` - Tab that originated the task
/// * `to` - Tab that should execute the task
/// * `description` - Human-readable description of what to do
/// * `priority` - Priority level (Low/Normal/High/Urgent)
pub fn new(
task_id: String,
from: TabId,
to: TabId,
description: String,
priority: Priority,
) -> Self {
Self {
task_id,
from_tab: from,
to_tab: to,
description,
priority,
status: DelegationStatus::Pending,
created_at: Utc::now(),
deadline: None,
completed_at: None,
result: None,
}
}
/// Builder method: set the deadline for this task. Returns self for chaining.
pub fn with_deadline(mut self, deadline: DateTime<Utc>) -> Self {
self.deadline = Some(deadline);
self
}
/// Transition status to InProgress. Idempotent.
pub fn start(&mut self) {
self.status = DelegationStatus::InProgress;
}
/// Mark as completed with the given result string.
/// Records completion timestamp.
pub fn complete(&mut self, result: String) {
self.status = DelegationStatus::Completed;
self.result = Some(result);
self.completed_at = Some(Utc::now());
}
/// Mark as failed (no result). Records completion timestamp.
pub fn fail(&mut self) {
self.status = DelegationStatus::Failed;
self.completed_at = Some(Utc::now());
}
/// Cancel the task. Records completion timestamp.
pub fn cancel(&mut self) {
self.status = DelegationStatus::Cancelled;
self.completed_at = Some(Utc::now());
}
/// Returns true if the task is still pending (not yet started).
pub fn is_pending(&self) -> bool {
self.status == DelegationStatus::Pending
}
/// Returns true if the task completed successfully.
pub fn is_completed(&self) -> bool {
self.status == DelegationStatus::Completed
}
/// Returns true if the task is pending or in progress (i.e., not terminal).
pub fn is_active(&self) -> bool {
matches!(
self.status,
DelegationStatus::Pending | DelegationStatus::InProgress
)
}
}
/// Result of a completed delegation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationResult {
pub task_id: String,
pub from_tab: TabId,
pub to_tab: TabId,
pub result: String,
pub completed_at: DateTime<Utc>,
pub was_successful: bool,
}
/// Task delegator managing cross-tab task distribution
pub struct TaskDelegator {
/// Active tasks (pending + in-progress). Terminal-state tasks
/// (completed / failed / cancelled) are removed from this vec and
/// recorded in `completed_results`. `pub(crate)` so the persistence
/// layer can restore from snapshot.
pub(crate) pending_tasks: Vec<DelegationTask>,
/// Bounded ring buffer of completed results.
/// Using VecDeque so O(1) front removal when pruning old entries.
/// Bounded to MAX_COMPLETED_RESULTS to prevent unbounded growth.
completed_results: VecDeque<DelegationResult>,
next_id: u64,
}
/// Maximum number of completed results to keep in memory.
/// At this size, prune_results is a no-op for the common case.
const MAX_COMPLETED_RESULTS: usize = 256;
impl TaskDelegator {
pub fn new() -> Self {
Self {
pending_tasks: Vec::new(),
completed_results: VecDeque::new(),
next_id: 1,
}
}
/// Create a new delegation
pub fn create_delegation(
&mut self,
from: TabId,
to: TabId,
description: String,
priority: Priority,
) -> Option<String> {
let task_id = self.generate_task_id();
let task = DelegationTask::new(task_id.clone(), from, to, description, priority);
self.pending_tasks.push(task);
Some(task_id)
}
/// Create a delegation with a deadline
pub fn create_delegation_with_deadline(
&mut self,
from: TabId,
to: TabId,
description: String,
priority: Priority,
deadline: DateTime<Utc>,
) -> Option<String> {
let task_id = self.generate_task_id();
let task = DelegationTask::new(task_id.clone(), from, to, description, priority)
.with_deadline(deadline);
self.pending_tasks.push(task);
Some(task_id)
}
/// Get pending tasks for a tab
pub fn pending_for_tab(&self, tab_id: TabId) -> Vec<&DelegationTask> {
self.pending_tasks
.iter()
.filter(|t| t.to_tab == tab_id && t.is_pending())
.collect()
}
/// Get active tasks for a tab (pending or in progress)
pub fn active_for_tab(&self, tab_id: TabId) -> Vec<&DelegationTask> {
self.pending_tasks
.iter()
.filter(|t| t.to_tab == tab_id && t.is_active())
.collect()
}
/// Get all pending tasks
pub fn all_pending(&self) -> &[DelegationTask] {
&self.pending_tasks
}
/// Get pending tasks sorted by priority (highest first)
pub fn pending_sorted_by_priority(&self) -> Vec<&DelegationTask> {
let mut tasks: Vec<&DelegationTask> = self
.pending_tasks
.iter()
.filter(|t| t.is_pending())
.collect();
tasks.sort_by_key(|t| std::cmp::Reverse(t.priority));
tasks
}
/// Start working on a task
pub fn start_task(&mut self, task_id: &str) -> bool {
if let Some(task) = self.pending_tasks.iter_mut().find(|t| t.task_id == task_id) {
task.start();
true
} else {
false
}
}
/// Take the highest-priority pending task for a tab.
/// Marks the task as `InProgress` in place and returns a clone; the task
/// is only removed from the queue when it reaches a terminal state via
/// `complete` / `fail_task` / `cancel_task`. Higher priority wins; on tie,
/// earlier `created_at` wins.
pub fn take_pending_for_tab(&mut self, tab_id: TabId) -> Option<DelegationTask> {
// Find the highest priority pending task for this tab
let mut best_idx: Option<usize> = None;
for (i, task) in self.pending_tasks.iter().enumerate() {
if task.to_tab != tab_id || !task.is_pending() {
continue;
}
match best_idx {
None => best_idx = Some(i),
Some(b) => {
let best = &self.pending_tasks[b];
// Higher priority wins; if equal, earlier created_at wins
if task.priority > best.priority
|| (task.priority == best.priority && task.created_at < best.created_at)
{
best_idx = Some(i);
}
}
}
}
// Mark as in-progress in place and return a clone; do NOT remove.
best_idx.map(|i| {
self.pending_tasks[i].start();
self.pending_tasks[i].clone()
})
}
/// Peek at the next pending task for a tab without removing it
pub fn peek_pending_for_tab(&self, tab_id: TabId) -> Option<&DelegationTask> {
self.pending_tasks
.iter()
.filter(|t| t.to_tab == tab_id && t.is_pending())
.max_by(|a, b| {
// Higher priority first; on tie, earlier created_at first
a.priority
.cmp(&b.priority)
.then_with(|| b.created_at.cmp(&a.created_at))
})
}
/// Complete a task
pub fn complete(&mut self, task_id: &str, result: String) {
let pos = self.pending_tasks.iter().position(|t| t.task_id == task_id);
let Some(pos) = pos else { return };
let mut task = self.pending_tasks.swap_remove(pos);
let from = task.from_tab;
let to = task.to_tab;
task.complete(result.clone());
self.completed_results.push_back(DelegationResult {
task_id: task_id.to_string(),
from_tab: from,
to_tab: to,
result,
completed_at: Utc::now(),
was_successful: true,
});
// Auto-prune to bound memory
if self.completed_results.len() > MAX_COMPLETED_RESULTS {
self.completed_results.pop_front();
}
}
/// Fail a task
pub fn fail_task(&mut self, task_id: &str) {
let pos = self.pending_tasks.iter().position(|t| t.task_id == task_id);
let Some(pos) = pos else { return };
let mut task = self.pending_tasks.swap_remove(pos);
let from = task.from_tab;
let to = task.to_tab;
task.fail();
self.completed_results.push_back(DelegationResult {
task_id: task_id.to_string(),
from_tab: from,
to_tab: to,
result: String::new(),
completed_at: Utc::now(),
was_successful: false,
});
// Auto-prune to bound memory
if self.completed_results.len() > MAX_COMPLETED_RESULTS {
self.completed_results.pop_front();
}
}
/// Cancel a task
pub fn cancel_task(&mut self, task_id: &str) -> bool {
let Some(pos) = self.pending_tasks.iter().position(|t| t.task_id == task_id) else {
return false;
};
let mut task = self.pending_tasks.swap_remove(pos);
task.cancel();
true
}
/// Get results for a tab
pub fn results_for_tab(&self, tab_id: TabId) -> Vec<&DelegationResult> {
self.completed_results
.iter()
.filter(|r| r.to_tab == tab_id)
.collect()
}
/// Get pending count for a tab
pub fn pending_count(&self, tab_id: TabId) -> usize {
self.pending_tasks
.iter()
.filter(|t| t.to_tab == tab_id && t.is_pending())
.count()
}
/// Clean up old completed results (keep last N)
/// O(N) where N is the number of items to remove, but much faster than
/// the previous drain() implementation because VecDeque supports
/// O(1) front removal.
pub fn prune_results(&mut self, keep_last: usize) {
while self.completed_results.len() > keep_last {
self.completed_results.pop_front();
}
}
/// Get completed results sorted by completion time (most recent first)
pub fn recent_results(&self, limit: usize) -> Vec<&DelegationResult> {
let mut results: Vec<&DelegationResult> = self.completed_results.iter().collect();
results.sort_by_key(|r| std::cmp::Reverse(r.completed_at));
results.into_iter().take(limit).collect()
}
fn generate_task_id(&mut self) -> String {
let id = self.next_id;
self.next_id += 1;
format!("delegation_{}", id)
}
pub(crate) fn advance_next_id_past_existing_tasks(&mut self) {
let max_seen = self
.pending_tasks
.iter()
.filter_map(|task| task.task_id.strip_prefix("delegation_"))
.filter_map(|suffix| suffix.parse::<u64>().ok())
.max()
.unwrap_or(0);
self.next_id = self.next_id.max(max_seen + 1);
}
}
impl Default for TaskDelegator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_and_complete_delegation() {
let mut delegator = TaskDelegator::new();
let from = TabId::new(1);
let to = TabId::new(2);
let task_id = delegator
.create_delegation(from, to, "Fix the bug".to_string(), Priority::High)
.unwrap();
assert_eq!(delegator.pending_count(to), 1);
delegator.complete(&task_id, "Fixed successfully".to_string());
let results = delegator.results_for_tab(from);
assert!(results.is_empty());
let results_to = delegator.results_for_tab(to);
assert_eq!(results_to.len(), 1);
assert_eq!(results_to[0].was_successful, true);
}
#[test]
fn test_priority_ordering() {
let mut delegator = TaskDelegator::new();
let from = TabId::new(1);
let to = TabId::new(2);
delegator.create_delegation(from, to, "Low priority".to_string(), Priority::Low);
delegator.create_delegation(from, to, "Urgent".to_string(), Priority::Urgent);
delegator.create_delegation(from, to, "Normal".to_string(), Priority::Normal);
delegator.create_delegation(from, to, "High".to_string(), Priority::High);
let sorted = delegator.pending_sorted_by_priority();
assert_eq!(sorted[0].description, "Urgent");
assert_eq!(sorted[1].description, "High");
assert_eq!(sorted[2].description, "Normal");
assert_eq!(sorted[3].description, "Low priority");
}
#[test]
fn test_take_pending_priority_order() {
let mut delegator = TaskDelegator::new();
let from = TabId::new(1);
let to = TabId::new(2);
delegator.create_delegation(from, to, "Low task".to_string(), Priority::Low);
delegator.create_delegation(from, to, "Urgent task".to_string(), Priority::Urgent);
delegator.create_delegation(from, to, "Normal task".to_string(), Priority::Normal);
// Should return Urgent first
let task = delegator.take_pending_for_tab(to).unwrap();
assert_eq!(task.description, "Urgent task");
assert_eq!(task.priority, Priority::Urgent);
// Then Normal
let task = delegator.take_pending_for_tab(to).unwrap();
assert_eq!(task.description, "Normal task");
assert_eq!(task.priority, Priority::Normal);
// Then Low
let task = delegator.take_pending_for_tab(to).unwrap();
assert_eq!(task.description, "Low task");
assert_eq!(task.priority, Priority::Low);
// Then nothing
assert!(delegator.take_pending_for_tab(to).is_none());
}
#[test]
fn test_take_pending_filters_by_tab() {
let mut delegator = TaskDelegator::new();
let from = TabId::new(1);
let to_a = TabId::new(2);
let to_b = TabId::new(3);
delegator.create_delegation(from, to_a, "For A".to_string(), Priority::High);
delegator.create_delegation(from, to_b, "For B".to_string(), Priority::High);
let task = delegator.take_pending_for_tab(to_a).unwrap();
assert_eq!(task.description, "For A");
let task = delegator.take_pending_for_tab(to_b).unwrap();
assert_eq!(task.description, "For B");
// Both should be drained now
assert!(delegator.take_pending_for_tab(to_a).is_none());
assert!(delegator.take_pending_for_tab(to_b).is_none());
}
#[test]
fn test_peek_pending_does_not_remove() {
let mut delegator = TaskDelegator::new();
let from = TabId::new(1);
let to = TabId::new(2);
delegator.create_delegation(from, to, "Task".to_string(), Priority::High);
// Peek multiple times
assert!(delegator.peek_pending_for_tab(to).is_some());
assert!(delegator.peek_pending_for_tab(to).is_some());
assert_eq!(delegator.pending_count(to), 1);
// Take should still work
let task = delegator.take_pending_for_tab(to).unwrap();
assert_eq!(task.description, "Task");
// Now should be empty
assert!(delegator.peek_pending_for_tab(to).is_none());
}
#[test]
fn test_auto_prune_bounded_results() {
// Verify auto-prune keeps the queue bounded under heavy load.
let mut delegator = TaskDelegator::new();
let from = TabId::new(1);
let to = TabId::new(2);
// Create and complete 1000 tasks (more than MAX_COMPLETED_RESULTS=256)
for i in 0..1000 {
let task_id = delegator
.create_delegation(from, to, format!("Task {}", i), Priority::Normal)
.unwrap();
delegator.complete(&task_id, format!("Result {}", i));
}
// Should be bounded at MAX_COMPLETED_RESULTS
let results = delegator.results_for_tab(to);
assert!(
results.len() <= 256,
"Results should be bounded, got {}",
results.len()
);
}
#[test]
fn test_prune_results_o1() {
let mut delegator = TaskDelegator::new();
let from = TabId::new(1);
let to = TabId::new(2);
// Complete many tasks
for i in 0..100 {
let task_id = delegator
.create_delegation(from, to, format!("Task {}", i), Priority::Normal)
.unwrap();
delegator.complete(&task_id, format!("Result {}", i));
}
assert_eq!(delegator.results_for_tab(to).len(), 100);
// Prune to keep only 5
delegator.prune_results(5);
assert_eq!(delegator.results_for_tab(to).len(), 5);
// Pruning further works
delegator.prune_results(3);
assert_eq!(delegator.results_for_tab(to).len(), 3);
// Pruning to a larger count is a no-op
delegator.prune_results(10);
assert_eq!(delegator.results_for_tab(to).len(), 3);
}
#[test]
fn test_advance_next_id_after_restore() {
let mut delegator = TaskDelegator::new();
delegator.pending_tasks.push(DelegationTask::new(
"delegation_42".to_string(),
TabId::new(1),
TabId::new(2),
"restored".to_string(),
Priority::Normal,
));
delegator.advance_next_id_past_existing_tasks();
let new_id = delegator
.create_delegation(
TabId::new(1),
TabId::new(2),
"fresh".to_string(),
Priority::Normal,
)
.unwrap();
assert_eq!(new_id, "delegation_43");
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Tab groups for organizing related tabs
//!
//! A TabGroup is a named collection of tabs (e.g. "Frontend Refactor",
//! "Backend Bug Hunt"). Groups help users manage 9 tabs by clustering
//! them by project/topic.
//!
//! Groups are purely organizational - they don't change delegation,
//! meeting, or any other tab behavior. They just provide:
//! - Visual separation in the tab bar (color/icon)
//! - Filtering in the tab switcher
//! - Quick group switching (next/prev group)
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
// PR #2753 context.
#![allow(dead_code)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::TabId;
/// Visual style/color identifier for a group
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum GroupColor {
Red,
Orange,
Yellow,
Green,
Cyan,
#[default]
Blue,
Magenta,
Gray,
}
impl GroupColor {
/// Short name for the color (1-3 chars)
pub fn short(&self) -> &'static str {
match self {
GroupColor::Red => "Rd",
GroupColor::Orange => "Or",
GroupColor::Yellow => "Yl",
GroupColor::Green => "Gn",
GroupColor::Cyan => "Cy",
GroupColor::Blue => "Bl",
GroupColor::Magenta => "Mg",
GroupColor::Gray => "Gy",
}
}
/// Cycle to the next color (used by the group cycle command)
pub fn next(&self) -> Self {
match self {
GroupColor::Red => GroupColor::Orange,
GroupColor::Orange => GroupColor::Yellow,
GroupColor::Yellow => GroupColor::Green,
GroupColor::Green => GroupColor::Cyan,
GroupColor::Cyan => GroupColor::Blue,
GroupColor::Blue => GroupColor::Magenta,
GroupColor::Magenta => GroupColor::Gray,
GroupColor::Gray => GroupColor::Red,
}
}
}
/// A named collection of tabs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabGroup {
pub id: String,
pub name: String,
pub color: GroupColor,
pub tab_ids: Vec<TabId>,
pub created_at: DateTime<Utc>,
}
impl TabGroup {
pub fn new(name: String, color: GroupColor) -> Self {
let id = format!("group_{}", chrono::Utc::now().timestamp_millis());
Self {
id,
name,
color,
tab_ids: Vec::new(),
created_at: Utc::now(),
}
}
/// Add a tab to this group
pub fn add_tab(&mut self, tab_id: TabId) {
if !self.tab_ids.contains(&tab_id) {
self.tab_ids.push(tab_id);
}
}
/// Remove a tab from this group
pub fn remove_tab(&mut self, tab_id: TabId) -> bool {
if let Some(pos) = self.tab_ids.iter().position(|t| *t == tab_id) {
self.tab_ids.swap_remove(pos);
true
} else {
false
}
}
/// Number of tabs in this group
pub fn len(&self) -> usize {
self.tab_ids.len()
}
/// Check if the group is empty
pub fn is_empty(&self) -> bool {
self.tab_ids.is_empty()
}
/// Check if this group contains a tab
pub fn contains(&self, tab_id: TabId) -> bool {
self.tab_ids.contains(&tab_id)
}
}
/// Manager for tab groups
pub struct TabGroupManager {
pub(crate) groups: HashMap<String, TabGroup>,
/// Maps tab_id -> group_id for quick lookup
pub(crate) tab_to_group: HashMap<TabId, String>,
next_id: u64,
}
impl TabGroupManager {
pub fn new() -> Self {
Self {
groups: HashMap::new(),
tab_to_group: HashMap::new(),
next_id: 1,
}
}
/// Create a new group
pub fn create_group(&mut self, name: String, color: GroupColor) -> String {
let id = self.generate_group_id();
let group = TabGroup {
id: id.clone(),
name,
color,
tab_ids: Vec::new(),
created_at: Utc::now(),
};
self.groups.insert(id.clone(), group);
id
}
/// Delete a group (tabs themselves are not deleted)
pub fn delete_group(&mut self, group_id: &str) -> bool {
if let Some(group) = self.groups.remove(group_id) {
for tab_id in &group.tab_ids {
self.tab_to_group.remove(tab_id);
}
true
} else {
false
}
}
/// Assign a tab to a group
pub fn assign_tab(&mut self, tab_id: TabId, group_id: &str) -> bool {
// Remove from any previous group first
self.unassign_tab(tab_id);
if let Some(group) = self.groups.get_mut(group_id) {
group.add_tab(tab_id);
self.tab_to_group.insert(tab_id, group_id.to_string());
true
} else {
false
}
}
/// Remove a tab from its group (if any)
pub fn unassign_tab(&mut self, tab_id: TabId) {
if let Some(prev_group_id) = self.tab_to_group.remove(&tab_id)
&& let Some(group) = self.groups.get_mut(&prev_group_id)
{
group.remove_tab(tab_id);
}
}
/// Get the group a tab is assigned to
pub fn group_of(&self, tab_id: TabId) -> Option<&TabGroup> {
self.tab_to_group
.get(&tab_id)
.and_then(|id| self.groups.get(id))
}
/// Get all groups
pub fn all_groups(&self) -> Vec<&TabGroup> {
let mut groups: Vec<&TabGroup> = self.groups.values().collect();
// Sort by name for stable display
groups.sort_by(|a, b| a.name.cmp(&b.name));
groups
}
/// Get tabs in a specific group
pub fn tabs_in_group(&self, group_id: &str) -> Option<&Vec<TabId>> {
self.groups.get(group_id).map(|g| &g.tab_ids)
}
/// Get the number of groups
pub fn group_count(&self) -> usize {
self.groups.len()
}
/// Iterate over groups
pub fn iter(&self) -> impl Iterator<Item = (&String, &TabGroup)> {
self.groups.iter()
}
/// Cycle a tab to the next group (or unassign if at the end)
pub fn cycle_tab_group(&mut self, tab_id: TabId) {
let group_ids: Vec<String> = self.all_groups().iter().map(|g| g.id.clone()).collect();
if let Some(current_group_id) = self.tab_to_group.get(&tab_id).cloned() {
if let Some(pos) = group_ids.iter().position(|id| id == &current_group_id) {
if pos + 1 < group_ids.len() {
let next = group_ids[pos + 1].clone();
self.assign_tab(tab_id, &next);
} else {
self.unassign_tab(tab_id);
}
}
} else if !group_ids.is_empty() {
let first = group_ids[0].clone();
self.assign_tab(tab_id, &first);
}
}
fn generate_group_id(&mut self) -> String {
let id = self.next_id;
self.next_id += 1;
format!("group_{}", id)
}
pub(crate) fn advance_next_id_past_existing_groups(&mut self) {
let max_seen = self
.groups
.keys()
.filter_map(|id| id.strip_prefix("group_"))
.filter_map(|suffix| suffix.parse::<u64>().ok())
.max()
.unwrap_or(0);
self.next_id = self.next_id.max(max_seen + 1);
}
}
impl Default for TabGroupManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_and_delete_group() {
let mut mgr = TabGroupManager::new();
let id = mgr.create_group("Frontend".to_string(), GroupColor::Blue);
assert_eq!(mgr.group_count(), 1);
assert!(mgr.delete_group(&id));
assert_eq!(mgr.group_count(), 0);
}
#[test]
fn test_assign_tab() {
let mut mgr = TabGroupManager::new();
let group_id = mgr.create_group("Backend".to_string(), GroupColor::Green);
let tab1 = TabId::new(1);
let tab2 = TabId::new(2);
assert!(mgr.assign_tab(tab1, &group_id));
assert!(mgr.assign_tab(tab2, &group_id));
let group = mgr.group_of(tab1).unwrap();
assert_eq!(group.len(), 2);
assert!(group.contains(tab1));
}
#[test]
fn test_unassign_tab() {
let mut mgr = TabGroupManager::new();
let group_id = mgr.create_group("Test".to_string(), GroupColor::Red);
let tab1 = TabId::new(1);
mgr.assign_tab(tab1, &group_id);
mgr.unassign_tab(tab1);
assert!(mgr.group_of(tab1).is_none());
let group = mgr.groups.get(&group_id).unwrap();
assert_eq!(group.len(), 0);
}
#[test]
fn test_reassign_tab() {
let mut mgr = TabGroupManager::new();
let g1 = mgr.create_group("G1".to_string(), GroupColor::Blue);
let g2 = mgr.create_group("G2".to_string(), GroupColor::Red);
let tab1 = TabId::new(1);
mgr.assign_tab(tab1, &g1);
assert_eq!(mgr.group_of(tab1).unwrap().id, g1);
mgr.assign_tab(tab1, &g2);
assert_eq!(mgr.group_of(tab1).unwrap().id, g2);
// G1 should now be empty
let g1_ref = mgr.groups.get(&g1).unwrap();
assert_eq!(g1_ref.len(), 0);
}
#[test]
fn test_color_cycle() {
let c = GroupColor::Red;
assert_eq!(c.next(), GroupColor::Orange);
assert_eq!(c.next().next(), GroupColor::Yellow);
// Cycle back to Red after Gray
let gray = GroupColor::Gray;
assert_eq!(gray.next(), GroupColor::Red);
}
#[test]
fn test_cycle_tab_group() {
let mut mgr = TabGroupManager::new();
let g1 = mgr.create_group("G1".to_string(), GroupColor::Blue);
let g2 = mgr.create_group("G2".to_string(), GroupColor::Red);
let tab1 = TabId::new(1);
// Not assigned yet -> assign to first
mgr.cycle_tab_group(tab1);
assert_eq!(mgr.group_of(tab1).unwrap().id, g1);
// Cycle to g2
mgr.cycle_tab_group(tab1);
assert_eq!(mgr.group_of(tab1).unwrap().id, g2);
// Cycle past end -> unassign
mgr.cycle_tab_group(tab1);
assert!(mgr.group_of(tab1).is_none());
}
#[test]
fn test_delete_group_clears_assignments() {
let mut mgr = TabGroupManager::new();
let g1 = mgr.create_group("G1".to_string(), GroupColor::Blue);
let tab1 = TabId::new(1);
mgr.assign_tab(tab1, &g1);
mgr.delete_group(&g1);
assert!(mgr.group_of(tab1).is_none());
assert!(mgr.tab_to_group.get(&tab1).is_none());
}
#[test]
fn test_advance_next_id_after_restore() {
let mut mgr = TabGroupManager::new();
mgr.groups.insert(
"group_7".to_string(),
TabGroup {
id: "group_7".to_string(),
name: "Restored".to_string(),
color: GroupColor::Blue,
tab_ids: Vec::new(),
created_at: Utc::now(),
},
);
mgr.advance_next_id_past_existing_groups();
let new_id = mgr.create_group("Fresh".to_string(), GroupColor::Green);
assert_eq!(new_id, "group_8");
assert!(mgr.groups.contains_key("group_7"));
assert!(mgr.groups.contains_key("group_8"));
}
}
+348
View File
@@ -0,0 +1,348 @@
//! End-to-end keyboard event tests for tab system
//!
//! These tests simulate the keyboard event handling that happens in
//! `ui.rs` when the user presses tab-related shortcuts. They verify that
//! the TabManager state transitions correctly in response to key events.
//!
//! The actual key event dispatch lives in `ui.rs` (which is hard to
//! test in isolation due to the engine/App dependencies), so these
//! tests exercise the underlying state transitions that the key handlers
//! would trigger.
//!
//! Run with: `cargo test tui::tab::key_e2e -- --nocapture`
#[cfg(test)]
mod tests {
use crate::tui::tab::{Priority, TabId, TabManager, TabType};
/// Simulate the sequence of key events the user would press
/// to: create a new tab, switch to it, type a message, and submit.
fn simulate_create_and_switch(manager: &mut TabManager) {
// Ctrl+Shift+N: create new tab
manager
.create_tab(format!("Tab {}", manager.len() + 1), TabType::Chat)
.expect("Ctrl+Shift+N should create tab");
// The new tab is automatically active after creation,
// simulating the key handler that updates active_tab.
}
/// Simulate Ctrl+1..9 key press
fn simulate_ctrl_number(manager: &mut TabManager, n: u8) {
if n == 0 || n as usize > manager.len() {
return;
}
manager.switch_to((n - 1) as usize);
}
/// Simulate Ctrl+Tab / Ctrl+Shift+Tab
fn simulate_ctrl_tab(manager: &mut TabManager, forward: bool) {
if forward {
manager.switch_to_next();
} else {
manager.switch_to_prev();
}
}
/// Simulate Ctrl+` to open switcher (we just verify the manager
/// can list its tabs as the switcher would)
fn simulate_switcher_list(manager: &TabManager) -> Vec<(usize, String)> {
manager
.all_tabs()
.iter()
.enumerate()
.map(|(i, t)| (i, t.title.clone()))
.collect()
}
/// Simulate Ctrl+Shift+D: process pending delegations
fn simulate_process_delegation(manager: &mut TabManager) -> Option<String> {
let tab_id = manager.active_id()?;
manager.take_next_delegation(tab_id).map(|t| t.task_id)
}
// === Tab creation tests ===
#[test]
fn test_e2e_create_first_tab() {
let mut manager = TabManager::new();
assert!(manager.is_empty());
simulate_create_and_switch(&mut manager);
assert_eq!(manager.len(), 1);
assert_eq!(manager.active_index(), Some(0));
}
#[test]
fn test_e2e_create_max_tabs() {
let mut manager = TabManager::new();
for _ in 0..9 {
simulate_create_and_switch(&mut manager);
}
assert_eq!(manager.len(), 9);
// 10th should fail
let result = manager.create_tab("10th".to_string(), TabType::Chat);
assert!(result.is_none());
}
// === Tab switching tests ===
#[test]
fn test_e2e_ctrl_number_switches() {
let mut manager = TabManager::new();
for i in 1..=5 {
manager
.create_tab(format!("Tab {}", i), TabType::Chat)
.expect("create");
}
simulate_ctrl_number(&mut manager, 3);
assert_eq!(manager.active_index(), Some(2));
simulate_ctrl_number(&mut manager, 1);
assert_eq!(manager.active_index(), Some(0));
simulate_ctrl_number(&mut manager, 5);
assert_eq!(manager.active_index(), Some(4));
}
#[test]
fn test_e2e_ctrl_number_out_of_range() {
let mut manager = TabManager::new();
for i in 1..=3 {
manager
.create_tab(format!("Tab {}", i), TabType::Chat)
.unwrap();
}
// Out-of-range should be a no-op
simulate_ctrl_number(&mut manager, 9);
assert_eq!(manager.active_index(), Some(2)); // last created
}
#[test]
fn test_e2e_ctrl_tab_cycles() {
let mut manager = TabManager::new();
for i in 1..=3 {
manager
.create_tab(format!("Tab {}", i), TabType::Chat)
.unwrap();
}
// Initially at last (2)
assert_eq!(manager.active_index(), Some(2));
simulate_ctrl_tab(&mut manager, true);
assert_eq!(manager.active_index(), Some(0)); // wrap
simulate_ctrl_tab(&mut manager, true);
assert_eq!(manager.active_index(), Some(1));
simulate_ctrl_tab(&mut manager, false);
assert_eq!(manager.active_index(), Some(0));
}
// === Switcher listing tests ===
#[test]
fn test_e2e_switcher_lists_all_tabs() {
let mut manager = TabManager::new();
manager.create_tab("A".to_string(), TabType::Chat).unwrap();
manager
.create_tab("B".to_string(), TabType::Review)
.unwrap();
manager
.create_tab("C".to_string(), TabType::Meeting)
.unwrap();
let listed = simulate_switcher_list(&manager);
assert_eq!(listed.len(), 3);
assert_eq!(listed[0].1, "A");
assert_eq!(listed[1].1, "B");
assert_eq!(listed[2].1, "C");
}
// === Delegation tests ===
#[test]
fn test_e2e_delegate_and_process() {
let mut manager = TabManager::new();
let from = manager
.create_tab("Source".to_string(), TabType::Chat)
.unwrap();
let to = manager
.create_tab("Target".to_string(), TabType::Chat)
.unwrap();
// User delegates a task
let task_id = manager
.delegate_task(from, to, "Review PR".to_string(), Priority::High)
.expect("delegate");
// User switches to target tab
manager.switch_to_by_id(to);
// User presses Ctrl+Shift+D
let processed = simulate_process_delegation(&mut manager);
assert_eq!(processed, Some(task_id));
}
#[test]
fn test_e2e_no_pending_delegation_returns_none() {
let mut manager = TabManager::new();
let _ = manager
.create_tab("Solo".to_string(), TabType::Chat)
.unwrap();
let processed = simulate_process_delegation(&mut manager);
assert_eq!(processed, None);
}
#[test]
fn test_e2e_delegation_priority_drain() {
let mut manager = TabManager::new();
let from = manager.create_tab("S".to_string(), TabType::Chat).unwrap();
let to = manager.create_tab("T".to_string(), TabType::Chat).unwrap();
manager.delegate_task(from, to, "Low".to_string(), Priority::Low);
manager.delegate_task(from, to, "Urgent".to_string(), Priority::Urgent);
manager.delegate_task(from, to, "High".to_string(), Priority::High);
manager.delegate_task(from, to, "Normal".to_string(), Priority::Normal);
manager.switch_to_by_id(to);
// Press Ctrl+Shift+D 4 times
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
// 4th should be the last task
assert_eq!(simulate_process_delegation(&mut manager).is_some(), true);
// 5th should be none
assert_eq!(simulate_process_delegation(&mut manager), None);
}
// === Tab close tests ===
#[test]
fn test_e2e_close_active_tab() {
let mut manager = TabManager::new();
let id_a = manager.create_tab("A".to_string(), TabType::Chat).unwrap();
let _id_b = manager.create_tab("B".to_string(), TabType::Chat).unwrap();
let id_c = manager.create_tab("C".to_string(), TabType::Chat).unwrap();
// Switch to B (index 1)
manager.switch_to(1);
// Ctrl+Shift+W: close current
manager.close_tab(1);
assert_eq!(manager.len(), 2);
// Active should now be C (index 1) since B was removed.
// C is the previously-created 3rd tab.
assert_eq!(manager.active_id().unwrap(), id_c);
assert!(id_a != id_c);
}
#[test]
fn test_e2e_close_only_tab_clears_active() {
let mut manager = TabManager::new();
manager
.create_tab("Solo".to_string(), TabType::Chat)
.unwrap();
manager.close_tab(0);
assert!(manager.is_empty());
assert_eq!(manager.active_index(), None);
}
// === Group management tests ===
#[test]
fn test_e2e_cycle_through_groups() {
use crate::tui::tab::group::GroupColor;
let mut manager = TabManager::new();
let tab1 = manager.create_tab("T1".to_string(), TabType::Chat).unwrap();
let _g1 = manager.create_group("A".to_string(), GroupColor::Blue);
let _g2 = manager.create_group("B".to_string(), GroupColor::Red);
// Cycle: not assigned -> first group (A)
manager.cycle_tab_group(tab1);
assert!(manager.tab_group(tab1).is_some());
// Cycle: A -> B
manager.cycle_tab_group(tab1);
let group = manager.tab_group(tab1).unwrap();
assert_eq!(group.name, "B");
// Cycle: B -> unassigned
manager.cycle_tab_group(tab1);
assert!(manager.tab_group(tab1).is_none());
}
// === Persistence e2e ===
#[test]
fn test_e2e_full_workflow_save_load() {
use std::path::Path;
let dir = std::env::temp_dir().join("codewhale_e2e_persist");
let path = dir.join("tabs.json");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
// Session 1: User creates tabs and groups
let mut manager = TabManager::new();
manager
.create_tab("Work".to_string(), TabType::Chat)
.unwrap();
manager
.create_tab("Personal".to_string(), TabType::Chat)
.unwrap();
let group_id = manager.create_group(
"Default".to_string(),
crate::tui::tab::group::GroupColor::Blue,
);
manager.assign_tab_to_group(manager.all_tabs()[0].id, &group_id);
manager.switch_to(1);
// App saves on shutdown
manager.save_to_file(&path).unwrap();
// Session 2: Restore
let mut restored = TabManager::new();
restored.restore_from_file(&path).unwrap();
assert_eq!(restored.len(), 2);
assert_eq!(restored.active_index(), Some(1));
assert!(restored.tab_group(restored.all_tabs()[0].id).is_some());
// Cleanup
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
// === Edge case tests ===
#[test]
fn test_e2e_rapid_create_close() {
let mut manager = TabManager::new();
for i in 0..9 {
manager
.create_tab(format!("T{}", i), TabType::Chat)
.expect("create");
}
// Close all
for _ in 0..9 {
manager.close_tab(manager.active_index().unwrap());
}
assert!(manager.is_empty());
}
#[test]
fn test_e2e_switch_empty_manager() {
let mut manager = TabManager::new();
simulate_ctrl_tab(&mut manager, true);
// Should be a no-op
assert!(manager.is_empty());
simulate_ctrl_number(&mut manager, 1);
assert!(manager.is_empty());
}
}
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
//! Meeting manager for multi-agent discussions
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
// PR #2753 context.
#![allow(dead_code)]
use super::TabId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Status of a meeting
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MeetingStatus {
Active,
Paused,
Ended,
}
/// Type of meeting message
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MeetingMessageType {
Regular,
Question,
Answer,
Proposal,
Agreement,
Objection,
Summary,
}
/// A message in a meeting
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingMessage {
pub id: u64,
pub sender: TabId,
pub content: String,
pub message_type: MeetingMessageType,
pub timestamp: DateTime<Utc>,
}
impl MeetingMessage {
pub fn new(id: u64, sender: TabId, content: String) -> Self {
Self {
id,
sender,
content,
message_type: MeetingMessageType::Regular,
timestamp: Utc::now(),
}
}
pub fn question(id: u64, sender: TabId, content: String) -> Self {
Self {
id,
sender,
content,
message_type: MeetingMessageType::Question,
timestamp: Utc::now(),
}
}
pub fn proposal(id: u64, sender: TabId, content: String) -> Self {
Self {
id,
sender,
content,
message_type: MeetingMessageType::Proposal,
timestamp: Utc::now(),
}
}
}
/// A decision made in a meeting
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingDecision {
pub id: u64,
pub description: String,
pub proposer: TabId,
pub supporters: Vec<TabId>,
pub timestamp: DateTime<Utc>,
}
impl MeetingDecision {
pub fn new(id: u64, description: String, proposer: TabId) -> Self {
Self {
id,
description,
proposer,
supporters: vec![proposer],
timestamp: Utc::now(),
}
}
pub fn add_supporter(&mut self, tab_id: TabId) {
if !self.supporters.contains(&tab_id) {
self.supporters.push(tab_id);
}
}
pub fn support_count(&self) -> usize {
self.supporters.len()
}
}
/// A meeting session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meeting {
pub id: String,
pub topic: String,
pub participants: Vec<TabId>,
pub messages: Vec<MeetingMessage>,
pub decisions: Vec<MeetingDecision>,
pub status: MeetingStatus,
pub created_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
}
impl Meeting {
pub fn new(id: String, topic: String, participants: Vec<TabId>) -> Self {
Self {
id,
topic,
participants,
messages: Vec::new(),
decisions: Vec::new(),
status: MeetingStatus::Active,
created_at: Utc::now(),
ended_at: None,
}
}
pub fn add_message(&mut self, msg: MeetingMessage) {
self.messages.push(msg);
}
pub fn add_decision(&mut self, decision: MeetingDecision) {
self.decisions.push(decision);
}
pub fn end(&mut self) {
self.status = MeetingStatus::Ended;
self.ended_at = Some(Utc::now());
}
pub fn duration(&self) -> chrono::Duration {
let end = self.ended_at.unwrap_or_else(Utc::now);
end - self.created_at
}
pub fn message_count(&self) -> usize {
self.messages.len()
}
pub fn decision_count(&self) -> usize {
self.decisions.len()
}
}
/// Summary of a completed meeting
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingSummary {
pub id: String,
pub topic: String,
pub participant_count: usize,
pub message_count: usize,
pub decision_count: usize,
pub duration_seconds: i64,
pub decisions: Vec<String>,
}
/// Meeting history entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingHistory {
pub summary: MeetingSummary,
pub created_at: DateTime<Utc>,
}
/// Manager for meeting sessions
pub struct MeetingManager {
active_meetings: HashMap<String, Meeting>,
history: Vec<MeetingHistory>,
next_meeting_id: u64,
next_message_id: u64,
next_decision_id: u64,
}
impl MeetingManager {
pub fn new() -> Self {
Self {
active_meetings: HashMap::new(),
history: Vec::new(),
next_meeting_id: 1,
next_message_id: 1,
next_decision_id: 1,
}
}
/// Start a new meeting
pub fn start_meeting(&mut self, topic: String, participants: Vec<TabId>) -> Option<String> {
if participants.len() < 2 {
return None;
}
let meeting_id = self.generate_meeting_id();
let meeting = Meeting::new(meeting_id.clone(), topic, participants);
self.active_meetings.insert(meeting_id.clone(), meeting);
Some(meeting_id)
}
/// Get an active meeting by ID
pub fn get_meeting(&self, meeting_id: &str) -> Option<&Meeting> {
self.active_meetings.get(meeting_id)
}
/// Get a mutable meeting by ID
pub fn get_meeting_mut(&mut self, meeting_id: &str) -> Option<&mut Meeting> {
self.active_meetings.get_mut(meeting_id)
}
/// Add a message to a meeting
pub fn add_message(&mut self, meeting_id: &str, msg: MeetingMessage) {
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
meeting.add_message(msg);
}
}
/// Create and add a new message
pub fn create_message(
&mut self,
meeting_id: &str,
sender: TabId,
content: String,
) -> Option<u64> {
let msg_id = self.next_message_id;
self.next_message_id += 1;
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
let msg = MeetingMessage::new(msg_id, sender, content);
meeting.add_message(msg);
Some(msg_id)
} else {
None
}
}
/// Add a decision to a meeting
pub fn add_decision(&mut self, meeting_id: &str, decision: MeetingDecision) {
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
meeting.add_decision(decision);
}
}
/// Create and add a new decision
pub fn create_decision(
&mut self,
meeting_id: &str,
description: String,
proposer: TabId,
) -> Option<u64> {
let decision_id = self.next_decision_id;
self.next_decision_id += 1;
if let Some(meeting) = self.active_meetings.get_mut(meeting_id) {
let decision = MeetingDecision::new(decision_id, description, proposer);
meeting.add_decision(decision);
Some(decision_id)
} else {
None
}
}
/// End a meeting
pub fn end_meeting(&mut self, meeting_id: &str) -> Option<MeetingSummary> {
if let Some(mut meeting) = self.active_meetings.remove(meeting_id) {
meeting.end();
let duration = meeting.duration();
let summary = MeetingSummary {
id: meeting.id.clone(),
topic: meeting.topic.clone(),
participant_count: meeting.participants.len(),
message_count: meeting.message_count(),
decision_count: meeting.decision_count(),
duration_seconds: duration.num_seconds(),
decisions: meeting
.decisions
.iter()
.map(|d| d.description.clone())
.collect(),
};
self.history.push(MeetingHistory {
summary: summary.clone(),
created_at: meeting.created_at,
});
Some(summary)
} else {
None
}
}
/// Get active meeting for a specific tab
pub fn active_meeting_for(&self, tab_id: TabId) -> Option<&Meeting> {
self.active_meetings
.values()
.find(|m| m.participants.contains(&tab_id))
}
/// Get all active meetings
pub fn active_meetings(&self) -> Vec<&Meeting> {
self.active_meetings.values().collect()
}
/// Get meeting history
pub fn history(&self) -> &[MeetingHistory] {
&self.history
}
/// Get recent meetings
pub fn recent_meetings(&self, limit: usize) -> Vec<&MeetingHistory> {
let mut history: Vec<&MeetingHistory> = self.history.iter().collect();
history.sort_by_key(|m| std::cmp::Reverse(m.created_at));
history.into_iter().take(limit).collect()
}
/// Check if a tab is in any active meeting
pub fn is_in_meeting(&self, tab_id: TabId) -> bool {
self.active_meetings
.values()
.any(|m| m.participants.contains(&tab_id))
}
fn generate_meeting_id(&mut self) -> String {
let id = self.next_meeting_id;
self.next_meeting_id += 1;
format!("meeting_{}", id)
}
}
impl Default for MeetingManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_meeting_lifecycle() {
let mut manager = MeetingManager::new();
let tab1 = TabId::new(1);
let tab2 = TabId::new(2);
let meeting_id = manager
.start_meeting("Discuss design".to_string(), vec![tab1, tab2])
.unwrap();
assert!(manager.is_in_meeting(tab1));
assert!(manager.is_in_meeting(tab2));
assert!(!manager.is_in_meeting(TabId::new(3)));
manager.create_message(&meeting_id, tab1, "Let's start".to_string());
manager.create_message(&meeting_id, tab2, "Agreed".to_string());
let meeting = manager.get_meeting(&meeting_id).unwrap();
assert_eq!(meeting.message_count(), 2);
manager.create_decision(&meeting_id, "Use component pattern".to_string(), tab1);
let summary = manager.end_meeting(&meeting_id).unwrap();
assert_eq!(summary.topic, "Discuss design");
assert_eq!(summary.participant_count, 2);
assert_eq!(summary.message_count, 2);
assert_eq!(summary.decision_count, 1);
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Smart @-mention parsing for cross-tab references
//!
//! When a user writes a message like "Hey @Tab2 can you review this?",
//! the mention parser extracts the referenced tab and suggests
//! automatically switching to it (or routing the message).
//!
//! Supported mention forms:
//! - `@Tab2` - by tab number (1-indexed)
//! - `@2` - shorthand for tab number
//! - `@tab2` - case-insensitive
//!
//! Examples:
//! ```
//! use crate::tui::tab::mention::extract_tab_mention;
//! assert_eq!(extract_tab_mention("Hello @Tab2!"), Some(2));
//! assert_eq!(extract_tab_mention("see @3"), Some(3));
//! assert_eq!(extract_tab_mention("no mention here"), None);
//! ```
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
// PR #2753 context.
#![allow(dead_code)]
/// Parse a message and extract the first tab mention (1-indexed number).
/// Returns `None` if no valid mention is found.
///
/// Recognized patterns:
/// - `@Tab<number>` (e.g. `@Tab2`, `@tab3`)
/// - `@<number>` at the start of a word (e.g. `@2`, `@3`)
///
/// The mention must be a single token (preceded by start-of-string or
/// whitespace, followed by whitespace, punctuation, or end-of-string).
pub fn extract_tab_mention(message: &str) -> Option<usize> {
let bytes = message.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'@' {
// Must be at start or after whitespace
if i > 0 && !is_mention_boundary(bytes[i - 1]) {
i += 1;
continue;
}
// Try `@Tab<n>`
if i + 4 <= bytes.len()
&& bytes[i..i + 4].eq_ignore_ascii_case(b"@tab")
&& i + 4 < bytes.len()
&& bytes[i + 4].is_ascii_digit()
{
let num_start = i + 4;
let num_end = scan_digits(bytes, num_start);
if let Some(num) = parse_usize(&bytes[num_start..num_end])
&& num > 0
&& is_mention_terminator(bytes, num_end)
{
return Some(num);
}
}
// Try `@<n>` shorthand
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
let num_start = i + 1;
let num_end = scan_digits(bytes, num_start);
if let Some(num) = parse_usize(&bytes[num_start..num_end])
&& num > 0
&& is_mention_terminator(bytes, num_end)
{
return Some(num);
}
}
}
i += 1;
}
None
}
/// Extract all tab mentions in order
pub fn extract_all_tab_mentions(message: &str) -> Vec<usize> {
let mut mentions = Vec::new();
let bytes = message.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'@' && (i == 0 || is_mention_boundary(bytes[i - 1])) {
// Determine the mention token length
let token_start = i;
// @Tab<n> or @<n>
let mut j = i + 1;
if j + 3 <= bytes.len() && bytes[j..j + 3].eq_ignore_ascii_case(b"tab") {
j += 3;
}
let num_start = j;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
let num_end = j;
if num_start < num_end
&& is_mention_terminator(bytes, num_end)
&& let Some(num) = parse_usize(&bytes[num_start..num_end])
&& num > 0
{
mentions.push(num);
// Also skip past the terminator
i = num_end;
continue;
}
let _ = token_start; // suppress unused
}
i += 1;
}
mentions
}
fn is_mention_boundary(b: u8) -> bool {
b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b',' || b == b';'
}
fn is_mention_terminator(bytes: &[u8], pos: usize) -> bool {
pos >= bytes.len()
|| bytes[pos] == b' '
|| bytes[pos] == b'\t'
|| bytes[pos] == b'\n'
|| bytes[pos] == b'\r'
|| bytes[pos] == b','
|| bytes[pos] == b'.'
|| bytes[pos] == b'!'
|| bytes[pos] == b'?'
|| bytes[pos] == b';'
|| bytes[pos] == b':'
}
fn scan_digits(bytes: &[u8], start: usize) -> usize {
let mut end = start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
end
}
fn parse_usize(s: &[u8]) -> Option<usize> {
if s.is_empty() {
return None;
}
let mut result: usize = 0;
for &b in s {
if !b.is_ascii_digit() {
return None;
}
result = result.checked_mul(10)?;
result = result.checked_add((b - b'0') as usize)?;
}
Some(result)
}
/// Given a tab number (1-indexed) and the list of tab IDs, return the
/// matching TabId. Returns `None` if the index is out of range.
///
/// The caller is expected to pass the IDs in **visual order** (i.e. the
/// order they appear in the tab bar). We deliberately do not sort the
/// list here — tab mentions like `@Tab2` should map to the second tab the
/// user sees, not the second-smallest ID.
pub fn resolve_tab_mention<'a, I>(tab_number: usize, tab_ids: I) -> Option<u64>
where
I: IntoIterator<Item = &'a u64>,
{
let ids: Vec<u64> = tab_ids.into_iter().copied().collect();
if tab_number == 0 || tab_number > ids.len() {
return None;
}
Some(ids[tab_number - 1])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_tab_mention() {
assert_eq!(extract_tab_mention("Hello @Tab2!"), Some(2));
assert_eq!(extract_tab_mention("see @3 please"), Some(3));
assert_eq!(extract_tab_mention("@Tab1 help"), Some(1));
assert_eq!(extract_tab_mention("no mention here"), None);
assert_eq!(extract_tab_mention("email@2 is wrong"), None); // not at boundary
assert_eq!(extract_tab_mention("@0 invalid"), None); // 0 not allowed
assert_eq!(extract_tab_mention(""), None);
}
#[test]
fn test_extract_case_insensitive() {
assert_eq!(extract_tab_mention("Hello @TAB2!"), Some(2));
assert_eq!(extract_tab_mention("see @tab3 please"), Some(3));
}
#[test]
fn test_extract_with_punctuation() {
assert_eq!(extract_tab_mention("Please @Tab2, review"), Some(2));
assert_eq!(extract_tab_mention("Ask @Tab2."), Some(2));
assert_eq!(extract_tab_mention("Hey @Tab2!"), Some(2));
assert_eq!(extract_tab_mention("What about @Tab2?"), Some(2));
}
#[test]
fn test_extract_all_mentions() {
let mentions = extract_all_tab_mentions("Hey @Tab2 and @3, also @Tab1");
assert_eq!(mentions, vec![2, 3, 1]);
}
#[test]
fn test_extract_mention_at_start() {
assert_eq!(extract_tab_mention("@Tab1 hi"), Some(1));
}
#[test]
fn test_resolve_tab_mention() {
// Tab IDs in the visual order they appear in the tab bar.
let tab_ids = vec![100, 50, 200];
// Tab 1 = first in visual order (100)
assert_eq!(resolve_tab_mention(1, tab_ids.iter()), Some(100));
// Tab 2 = second in visual order (50)
assert_eq!(resolve_tab_mention(2, tab_ids.iter()), Some(50));
// Tab 3 = third in visual order (200)
assert_eq!(resolve_tab_mention(3, tab_ids.iter()), Some(200));
// Out of range
assert_eq!(resolve_tab_mention(4, tab_ids.iter()), None);
assert_eq!(resolve_tab_mention(0, tab_ids.iter()), None);
}
}
+148
View File
@@ -0,0 +1,148 @@
//! Multi-tab system for CodeWhale TUI
//!
//! This module provides support for multiple concurrent agent sessions
//! in a tabbed interface, similar to Claude Code Windows.
// Cross-tab collaboration APIs (delegator, meeting, cross_tab, group,
// mention) are intentionally exposed here as a public surface for the
// narrow tab-core harvest. They are not yet wired into the TUI host
// (that lands in a follow-up UI pass) and therefore trip `dead_code`
// inside the binary crate. The `pub use manager::TabManager` re-export
// is the public entry point for that follow-up wiring, so it is also
// marked `unused_imports`-tolerated in the meantime.
#![allow(dead_code, unused_imports)]
#[cfg(test)]
mod benches;
mod cross_tab;
mod delegator;
pub mod group;
#[cfg(test)]
mod key_e2e;
mod manager;
pub mod meeting;
pub mod mention;
pub mod persistence;
pub use manager::TabManager;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Unique identifier for a tab
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TabId(pub u64);
impl TabId {
pub fn new(id: u64) -> Self {
Self(id)
}
}
/// Tab type determining the session mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TabType {
/// Regular conversation session
#[default]
Chat,
/// Task delegation session
Delegation,
/// Code review session
Review,
/// Multi-agent meeting session
Meeting,
}
impl TabType {
/// Short single-character icon for display in tight UI (tab bar, picker)
pub fn icon(&self) -> &'static str {
match self {
TabType::Chat => "💬",
TabType::Delegation => "📤",
TabType::Review => "🔍",
TabType::Meeting => "👥",
}
}
/// ASCII fallback icon (for terminals without emoji support)
pub fn ascii_icon(&self) -> &'static str {
match self {
TabType::Chat => "[C]",
TabType::Delegation => "[D]",
TabType::Review => "[R]",
TabType::Meeting => "[M]",
}
}
/// Display name for the tab type
pub fn display_name(&self) -> &'static str {
match self {
TabType::Chat => "Chat",
TabType::Delegation => "Delegation",
TabType::Review => "Review",
TabType::Meeting => "Meeting",
}
}
}
/// Metadata for a tab
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabMetadata {
pub id: TabId,
pub title: String,
pub tab_type: TabType,
pub created_at: DateTime<Utc>,
pub last_active: DateTime<Utc>,
pub unread_count: usize,
pub agent_name: Option<String>,
pub session_path: Option<PathBuf>,
}
impl TabMetadata {
pub fn new(id: TabId, title: String, tab_type: TabType) -> Self {
let now = Utc::now();
Self {
id,
title,
tab_type,
created_at: now,
last_active: now,
unread_count: 0,
agent_name: None,
session_path: None,
}
}
pub fn touch(&mut self) {
self.last_active = Utc::now();
}
pub fn increment_unread(&mut self) {
self.unread_count += 1;
}
pub fn clear_unread(&mut self) {
self.unread_count = 0;
}
}
/// Priority levels for task delegation
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
pub enum Priority {
Low = 0,
#[default]
Normal = 1,
High = 2,
Urgent = 3,
}
/// Status of a tab
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TabStatus {
Active,
#[default]
Idle,
Loading,
Error,
}
+394
View File
@@ -0,0 +1,394 @@
//! Tab state persistence
//!
//! Saves the TabManager's tab list (titles, types, IDs, creation time) to a
//! JSON file in the user's data directory. On startup, the file is loaded
//! to restore the previous session's tabs.
//!
//! Note: messages and conversation history are NOT persisted here - those
//! live in the session_manager. This file only stores the tab metadata.
// WIP collaboration surface — narrow harvest. See `tab/mod.rs` for the
// PR #2753 context.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::delegator::DelegationStatus;
use super::{Priority, TabId, TabMetadata, TabType};
/// Current schema version. Bump when making breaking changes to the
/// on-disk format. Older versions are detected on load so we can
/// give a useful error message rather than silently dropping data.
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
/// Maximum size of the tab state file we'll attempt to load (1 MB).
/// Past this, the file is treated as corrupted and ignored. This
/// prevents a malicious or accidental huge file from OOM-ing the TUI
/// on startup.
pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
/// On-disk representation of a single tab
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedTab {
pub id: u64,
pub title: String,
pub tab_type: TabType,
pub created_at: DateTime<Utc>,
pub last_active: DateTime<Utc>,
}
/// On-disk representation of a tab group
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedGroup {
pub id: String,
pub name: String,
pub color: super::group::GroupColor,
pub tab_ids: Vec<TabId>,
pub created_at: DateTime<Utc>,
}
/// On-disk representation of a single delegation task
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedDelegation {
pub task_id: String,
pub from_tab: u64,
pub to_tab: u64,
pub description: String,
pub priority: Priority,
/// Status of the delegation when it was snapshotted. Without this field,
/// an in-flight `InProgress` task is silently demoted to `Pending` on
/// restart, losing work-in-progress state.
pub status: DelegationStatus,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub result: Option<String>,
pub was_successful: Option<bool>,
}
/// On-disk representation of the tab manager state
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedTabState {
pub version: u32,
pub saved_at: DateTime<Utc>,
pub active_tab_index: Option<usize>,
pub tabs: Vec<PersistedTab>,
pub delegations: Vec<PersistedDelegation>,
#[serde(default)]
pub groups: Vec<PersistedGroup>,
}
impl Default for PersistedTabState {
fn default() -> Self {
Self {
version: 1,
saved_at: Utc::now(),
active_tab_index: None,
tabs: Vec::new(),
delegations: Vec::new(),
groups: Vec::new(),
}
}
}
/// Get the default path for the tab state file
/// `~/.codewhale/tabs.json`
pub fn default_tab_state_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".codewhale").join("tabs.json"))
}
/// Save the tab state to a file.
/// Atomically writes via temp file + rename to prevent corruption
/// from interrupted writes.
pub fn save_to_file(state: &PersistedTabState, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(state)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
// Write to temp file first, then rename for atomicity
let tmp_path = path.with_extension("json.tmp");
std::fs::write(&tmp_path, json)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
/// Load the tab state from a file. Returns default state if file doesn't exist.
/// Refuses to load files larger than MAX_FILE_SIZE to prevent OOM.
/// Detects schema version mismatches and returns a specific error.
pub fn load_from_file(path: &Path) -> std::io::Result<PersistedTabState> {
if !path.exists() {
return Ok(PersistedTabState::default());
}
// Size check
let metadata = std::fs::metadata(path)?;
if metadata.len() > MAX_FILE_SIZE {
// Silently returning `default()` would let the next save overwrite
// the oversized file and destroy the user's data. Surface the error
// so the application can refuse to save and preserve the file.
tracing::error!(
size = metadata.len(),
max = MAX_FILE_SIZE,
path = %path.display(),
"Tab state file too large, refusing to load"
);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Tab state file size {} exceeds maximum allowed size {}",
metadata.len(),
MAX_FILE_SIZE
),
));
}
let content = std::fs::read_to_string(path)?;
let state: PersistedTabState = serde_json::from_str(&content).map_err(|e| {
tracing::error!(
?e,
path = %path.display(),
"Failed to parse tab state file"
);
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
// Schema version check
if state.version > CURRENT_SCHEMA_VERSION {
tracing::warn!(
file_version = state.version,
current = CURRENT_SCHEMA_VERSION,
"Tab state file is from a newer version; some data may be ignored"
);
} else if state.version < CURRENT_SCHEMA_VERSION {
tracing::info!(
file_version = state.version,
current = CURRENT_SCHEMA_VERSION,
"Migrating tab state from older schema"
);
// Future: implement migration logic here
}
Ok(state)
}
/// Convert a TabMetadata to its persisted form
pub fn from_metadata(meta: &TabMetadata) -> PersistedTab {
PersistedTab {
id: meta.id.0,
title: meta.title.clone(),
tab_type: meta.tab_type,
created_at: meta.created_at,
last_active: meta.last_active,
}
}
/// Convert a persisted tab to a TabMetadata
pub fn to_metadata(persisted: &PersistedTab) -> TabMetadata {
let mut meta = TabMetadata::new(
TabId::new(persisted.id),
persisted.title.clone(),
persisted.tab_type,
);
meta.created_at = persisted.created_at;
meta.last_active = persisted.last_active;
meta
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_roundtrip() {
let state = PersistedTabState {
version: 1,
saved_at: Utc::now(),
active_tab_index: Some(0),
tabs: vec![
PersistedTab {
id: 1,
title: "Tab 1".to_string(),
tab_type: TabType::Chat,
created_at: Utc::now(),
last_active: Utc::now(),
},
PersistedTab {
id: 2,
title: "Tab 2".to_string(),
tab_type: TabType::Meeting,
created_at: Utc::now(),
last_active: Utc::now(),
},
],
delegations: vec![PersistedDelegation {
task_id: "delegation_1".to_string(),
from_tab: 1,
to_tab: 2,
description: "Review code".to_string(),
priority: Priority::High,
status: DelegationStatus::Pending,
created_at: Utc::now(),
completed_at: None,
result: None,
was_successful: None,
}],
groups: vec![],
};
let json = serde_json::to_string(&state).unwrap();
let parsed: PersistedTabState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tabs.len(), 2);
assert_eq!(parsed.tabs[0].title, "Tab 1");
assert_eq!(parsed.tabs[1].tab_type, TabType::Meeting);
assert_eq!(parsed.delegations.len(), 1);
assert_eq!(parsed.delegations[0].priority, Priority::High);
}
#[test]
fn test_metadata_conversion() {
let meta = TabMetadata::new(TabId::new(42), "Test".to_string(), TabType::Review);
let persisted = from_metadata(&meta);
assert_eq!(persisted.id, 42);
assert_eq!(persisted.title, "Test");
assert_eq!(persisted.tab_type, TabType::Review);
let restored = to_metadata(&persisted);
assert_eq!(restored.id, TabId::new(42));
assert_eq!(restored.title, "Test");
assert_eq!(restored.tab_type, TabType::Review);
}
#[test]
fn test_load_missing_file() {
let result = load_from_file(Path::new("/nonexistent/path/tabs.json"));
assert!(result.is_ok());
let state = result.unwrap();
assert!(state.tabs.is_empty());
assert!(state.delegations.is_empty());
}
#[test]
fn test_save_and_load() {
let dir = std::env::temp_dir().join("codewhale_tab_test");
let path = dir.join("tabs.json");
// Clean up any leftover
let _ = std::fs::remove_file(&path);
let state = PersistedTabState {
version: 1,
saved_at: Utc::now(),
active_tab_index: Some(1),
tabs: vec![PersistedTab {
id: 1,
title: "Test".to_string(),
tab_type: TabType::Delegation,
created_at: Utc::now(),
last_active: Utc::now(),
}],
delegations: vec![],
groups: vec![],
};
save_to_file(&state, &path).unwrap();
let loaded = load_from_file(&path).unwrap();
assert_eq!(loaded.active_tab_index, Some(1));
assert_eq!(loaded.tabs.len(), 1);
assert_eq!(loaded.tabs[0].tab_type, TabType::Delegation);
// Cleanup
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_load_oversized_file_rejected() {
// Create a file that exceeds MAX_FILE_SIZE
let dir = std::env::temp_dir().join("codewhale_tab_oversize");
let path = dir.join("tabs.json");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
// Write a small header followed by enough junk to exceed 1MB
let mut content = String::from(
r#"{"version":1,"saved_at":"2026-01-01T00:00:00Z","active_tab_index":null,"tabs":[],"delegations":[]}"#,
);
while content.len() < (MAX_FILE_SIZE as usize) + 100 {
content.push(' ');
}
std::fs::write(&path, content).unwrap();
// Should return an error rather than silently overwriting the file
// on next save. Silently returning a default would destroy the
// user's data.
let result = load_from_file(&path);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_load_corrupted_file() {
let dir = std::env::temp_dir().join("codewhale_tab_corrupt");
let path = dir.join("tabs.json");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
// Write invalid JSON
std::fs::write(&path, "{ not valid json :::").unwrap();
// Should return error
let result = load_from_file(&path);
assert!(result.is_err());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_save_is_atomic() {
// Verify that save_to_file uses a temp + rename pattern.
// The test ensures the final file exists and no .tmp file remains.
let dir = std::env::temp_dir().join("codewhale_tab_atomic");
let path = dir.join("tabs.json");
let tmp_path = path.with_extension("json.tmp");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&tmp_path);
let state = PersistedTabState::default();
save_to_file(&state, &path).unwrap();
assert!(path.exists(), "Final file should exist");
assert!(!tmp_path.exists(), "Temp file should be cleaned up");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_load_newer_schema_logs_warning_but_loads() {
// Simulate a file from a future version
let dir = std::env::temp_dir().join("codewhale_tab_newer");
let path = dir.join("tabs.json");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
let json = r#"{"version":99,"saved_at":"2027-01-01T00:00:00Z","active_tab_index":null,"tabs":[],"delegations":[]}"#;
std::fs::write(&path, json).unwrap();
// Should still load successfully (graceful degradation)
let loaded = load_from_file(&path).unwrap();
assert_eq!(loaded.version, 99);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
}