feat(tui): add multi-tab system core (manager + persistence)

Sister PR to #2753, scoped to the narrow tab-core/persistence slice
Hmbown asked for in the v0.9 stewardship review. Adds the `tab`
module under `crates/tui/src/tui/` and a one-line module registration
in `tui/mod.rs`. Nothing else in the host changes here — the
switcher / picker / meeting UI pass and the host wiring
(`App::tab_manager`, keyboard shortcuts, mouse menu, tab-bar layout
in `ui.rs`) live on #2753 and land in a follow-up PR.

Scope:

* `tab::TabManager` with monotonic `next_tab_id`, max 9 tabs by
  default, snapshot/restore round-trip, group assignment,
  cross-tab event/links, and persistence integration
* `tab::delegator::TaskDelegator` — bounded pending queue with
  `MAX_COMPLETED_RESULTS` auto-prune; `take_pending_for_tab` marks
  InProgress in place and returns a clone so subsequent
  `start_task` / `complete` / `fail_task` / `cancel_task` can still
  find the task (the previous `swap_remove` would have dropped it
  on the first call)
* `tab::meeting::MeetingManager` — participants, messages by
  type (Regular / Question / Answer / Proposal / Agreement /
  Objection / Summary), decisions
* `tab::cross_tab` — `CrossTabEvent` (TaskDelegation / ReviewRequest
  / MeetingInvite / ContextSync / ResultReturn) and `SharedContext`
* `tab::group` — `TabGroup` / `TabGroupManager` /
  `GroupColor` (Red/Orange/Yellow/Green/Cyan/Blue/Magenta/Gray)
* `tab::mention` — `@Tab<N>`, `@N`, `@tab<n>` (case-insensitive)
  parser, with `resolve_tab_mention` for 1-indexed tab lookups
* `tab::persistence` — JSON file in the user's data dir,
  `PersistedTabState` / `PersistedTab` / `PersistedDelegation` /
  `PersistedGroup` with schema-version header, atomic save,
  bounded file size, corruption-tolerant load
* `tab::benches` and `tab::key_e2e` regression suites (roundtrip,
  save/load, end-to-end save→load with keymap)

Lint posture:

* `#![allow(dead_code, unused_imports)]` on `tab/mod.rs` because
  the collab/UI pass is not on this branch; the public surface is
  intentionally exposed for the follow-up wiring in #2753
* One pre-existing `tools/shell.rs` fix piggy-backed: drop the
  redundant `as *mut c_void` cast on `child.as_raw_handle()` (the
  return type is already `*mut c_void`). Pre-existing on
  `codex/v0.9.0-stewardship`; promoted to `clippy::unnecessary_cast`
  by rust 1.95

Local CI matrix on this branch (Windows runner, same flags as
`.github/workflows/ci.yml`):

* `cargo fmt --all -- --check` — pass
* `cargo clippy --workspace --all-features --locked -- -D warnings`
  — pass, 0 errors
* `cargo test --bin codewhale-tui tab::` — 63/63 pass
* `git diff --exit-code -- Cargo.lock` — clean

GitHub Actions will run on the standard fork-PR approval gate; the
`on.pull_request` branch filter on this repo matches
`codex/v0.9.0-stewardship` so the same matrix will run on
ubuntu-latest, macos-latest, and windows-latest.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
G1 AgentandClaude Opus 4.7 committed 2026-06-06 19:06:38 +08:00
1 parent 7bd68279e7
commit 649d3990d6
12 files changed
+3644 -1

No files matched your search

+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
@@ -62,6 +62,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>,
}
+542
View File
@@ -0,0 +1,542 @@
//! 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)
}
}
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);
}
}
+350
View File
@@ -0,0 +1,350 @@
//! 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)
}
}
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());
}
}
+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());
}
}
+876
View File
@@ -0,0 +1,876 @@
//! Tab manager for handling multiple agent sessions
// WIP collaboration surface. The collab/UI pass lives in PR #2753; this
// file's public items are part of the narrow tab-core harvest. The
// `dead_code` allow at the file root makes that explicit.
#![allow(dead_code)]
use super::cross_tab::CrossTabLink;
use super::delegator::{DelegationResult, TaskDelegator};
use super::meeting::{Meeting, MeetingDecision, MeetingManager, MeetingMessage};
use super::{Priority, TabId, TabMetadata, TabStatus, TabType};
use std::collections::HashMap;
/// Maximum number of tabs allowed
pub const MAX_TABS: usize = 9;
/// Tab state including metadata and status
#[derive(Debug, Clone)]
pub struct TabState {
pub metadata: TabMetadata,
pub status: TabStatus,
pub pending_tasks: Vec<String>,
}
impl TabState {
pub fn new(id: TabId, title: String, tab_type: TabType) -> Self {
Self {
metadata: TabMetadata::new(id, title, tab_type),
status: TabStatus::Idle,
pending_tasks: Vec::new(),
}
}
}
/// Manages multiple tabs and their interactions
pub struct TabManager {
tabs: Vec<TabState>,
active_tab: Option<usize>,
max_tabs: usize,
cross_tab_links: HashMap<TabId, Vec<CrossTabLink>>,
delegator: TaskDelegator,
meeting_manager: MeetingManager,
groups: super::group::TabGroupManager,
/// Monotonic counter for assigning unique tab IDs. Independent of wall
/// clock so two `create_tab` calls in the same nanosecond on fast
/// machines still get distinct IDs.
next_tab_id: u64,
}
impl TabManager {
/// Create a new tab manager
pub fn new() -> Self {
Self {
tabs: Vec::new(),
active_tab: None,
max_tabs: MAX_TABS,
cross_tab_links: HashMap::new(),
delegator: TaskDelegator::new(),
meeting_manager: MeetingManager::new(),
groups: super::group::TabGroupManager::new(),
next_tab_id: 1,
}
}
/// Get the number of tabs
/// Returns the number of tabs currently open.
pub fn len(&self) -> usize {
self.tabs.len()
}
/// Returns true if no tabs are open.
pub fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
/// Returns the index of the currently active tab, or `None` if no tabs exist.
pub fn active_index(&self) -> Option<usize> {
self.active_tab
}
/// Returns the ID of the currently active tab, or `None` if no tabs exist.
pub fn active_id(&self) -> Option<TabId> {
self.active_tab
.and_then(|i| self.tabs.get(i))
.map(|t| t.metadata.id)
}
/// Returns metadata for all tabs, in tab order.
pub fn all_tabs(&self) -> Vec<&TabMetadata> {
self.tabs.iter().map(|t| &t.metadata).collect()
}
/// Returns the tab at `index`, or `None` if out of range.
pub fn get(&self, index: usize) -> Option<&TabState> {
self.tabs.get(index)
}
/// Returns a mutable reference to the tab at `index`, or `None` if out of range.
pub fn get_mut(&mut self, index: usize) -> Option<&mut TabState> {
self.tabs.get_mut(index)
}
/// Iterate over tabs by `(index, &TabState)` without allocating.
/// Cheaper than [`Self::all_tabs`] for hot paths like the tab bar renderer.
pub fn iter(&self) -> impl Iterator<Item = (usize, &TabState)> {
self.tabs.iter().enumerate()
}
/// Create a new tab. The new tab becomes active.
///
/// # Arguments
/// * `title` - Display title shown in the tab bar
/// * `tab_type` - Type of tab (Chat/Delegation/Review/Meeting)
///
/// # Returns
/// The new tab's `TabId`, or `None` if `max_tabs` (default 9) is reached.
pub fn create_tab(&mut self, title: String, tab_type: TabType) -> Option<TabId> {
if self.tabs.len() >= self.max_tabs {
return None;
}
let id = TabId::new(self.next_tab_id);
self.next_tab_id += 1;
let tab = TabState::new(id, title, tab_type);
self.tabs.push(tab);
self.active_tab = Some(self.tabs.len() - 1);
Some(id)
}
/// Create a new chat tab with a default `"Tab N"` title where `N` is the
/// 1-indexed slot for the new tab. Returns `None` if `max_tabs` is reached.
pub fn create_default_chat_tab(&mut self) -> Option<TabId> {
let title = format!("Tab {}", self.tabs.len() + 1);
self.create_tab(title, TabType::Chat)
}
/// Get mutable access to the group manager
pub fn groups_mut(&mut self) -> &mut super::group::TabGroupManager {
&mut self.groups
}
/// Get immutable access to the group manager
pub fn groups(&self) -> &super::group::TabGroupManager {
&self.groups
}
/// Create a new tab group
pub fn create_group(&mut self, name: String, color: super::group::GroupColor) -> String {
self.groups.create_group(name, color)
}
/// Delete a tab group
pub fn delete_group(&mut self, group_id: &str) -> bool {
self.groups.delete_group(group_id)
}
/// Assign a tab to a group
pub fn assign_tab_to_group(&mut self, tab_id: TabId, group_id: &str) -> bool {
self.groups.assign_tab(tab_id, group_id)
}
/// Get the group a tab is assigned to
pub fn tab_group(&self, tab_id: TabId) -> Option<&super::group::TabGroup> {
self.groups.group_of(tab_id)
}
/// List all groups
pub fn all_groups(&self) -> Vec<&super::group::TabGroup> {
self.groups.all_groups()
}
/// Cycle a tab to the next group
pub fn cycle_tab_group(&mut self, tab_id: TabId) {
self.groups.cycle_tab_group(tab_id);
}
/// Snapshot the current manager state for persistence
pub fn snapshot(&self) -> super::persistence::PersistedTabState {
use super::persistence::PersistedTab;
let tabs: Vec<PersistedTab> = self
.tabs
.iter()
.map(|t| super::persistence::from_metadata(&t.metadata))
.collect();
let delegations: Vec<super::persistence::PersistedDelegation> = self
.delegator
.all_pending()
.iter()
.map(|t| super::persistence::PersistedDelegation {
task_id: t.task_id.clone(),
from_tab: t.from_tab.0,
to_tab: t.to_tab.0,
description: t.description.clone(),
priority: t.priority,
created_at: t.created_at,
completed_at: t.completed_at,
result: t.result.clone(),
was_successful: None,
})
.collect();
let groups: Vec<super::persistence::PersistedGroup> = self
.groups
.all_groups()
.into_iter()
.map(|g| super::persistence::PersistedGroup {
id: g.id.clone(),
name: g.name.clone(),
color: g.color,
tab_ids: g.tab_ids.clone(),
created_at: g.created_at,
})
.collect();
super::persistence::PersistedTabState {
version: 1,
saved_at: chrono::Utc::now(),
active_tab_index: self.active_tab,
tabs,
delegations,
groups,
}
}
/// Save the current state to a file
pub fn save_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
let state = self.snapshot();
super::persistence::save_to_file(&state, path)
}
/// Restore state from a persisted snapshot. Existing tabs are cleared.
pub fn restore_from_snapshot(&mut self, state: &super::persistence::PersistedTabState) {
self.tabs.clear();
self.active_tab = None;
self.cross_tab_links.clear();
self.delegator = super::delegator::TaskDelegator::new();
self.meeting_manager = super::meeting::MeetingManager::new();
self.groups = super::group::TabGroupManager::new();
self.next_tab_id = 1;
let mut max_seen_id: u64 = 0;
for p in &state.tabs {
let meta = super::persistence::to_metadata(p);
// Advance the monotonic counter past any restored ID so freshly
// created tabs can never collide with restored ones.
if meta.id.0 > max_seen_id {
max_seen_id = meta.id.0;
}
let tab = TabState {
metadata: meta,
status: TabStatus::Idle,
pending_tasks: Vec::new(),
};
self.tabs.push(tab);
}
self.next_tab_id = max_seen_id + 1;
// Restore active delegations so cross-tab work survives restarts.
for d in &state.delegations {
let task = super::delegator::DelegationTask {
task_id: d.task_id.clone(),
from_tab: TabId(d.from_tab),
to_tab: TabId(d.to_tab),
description: d.description.clone(),
priority: d.priority,
status: super::delegator::DelegationStatus::Pending,
created_at: d.created_at,
deadline: None,
completed_at: d.completed_at,
result: d.result.clone(),
};
self.delegator.pending_tasks.push(task);
}
// Restore groups AFTER tabs so tab_ids can reference real tabs
for g in &state.groups {
let group = super::group::TabGroup {
id: g.id.clone(),
name: g.name.clone(),
color: g.color,
tab_ids: g.tab_ids.clone(),
created_at: g.created_at,
};
self.groups.groups.insert(group.id.clone(), group);
for tab_id in &g.tab_ids {
self.groups.tab_to_group.insert(*tab_id, g.id.clone());
}
}
if let Some(idx) = state.active_tab_index {
if idx < self.tabs.len() {
self.active_tab = Some(idx);
} else if !self.tabs.is_empty() {
self.active_tab = Some(self.tabs.len() - 1);
}
} else if !self.tabs.is_empty() {
self.active_tab = Some(0);
}
}
/// Restore state from a file. Missing file is treated as empty.
pub fn restore_from_file(&mut self, path: &std::path::Path) -> std::io::Result<()> {
let state = super::persistence::load_from_file(path)?;
self.restore_from_snapshot(&state);
Ok(())
}
/// Close a tab by index
pub fn close_tab(&mut self, index: usize) -> bool {
if index >= self.tabs.len() {
return false;
}
// Unassign from any group before removing
let tab_id = self.tabs[index].metadata.id;
self.groups.unassign_tab(tab_id);
self.tabs.remove(index);
// Adjust active tab index
if let Some(active) = self.active_tab {
if index < active {
self.active_tab = Some(active - 1);
} else if index == active {
self.active_tab = if self.tabs.is_empty() {
None
} else if active >= self.tabs.len() {
Some(self.tabs.len() - 1)
} else {
Some(active)
};
}
}
true
}
/// Close a tab by ID
pub fn close_tab_by_id(&mut self, id: TabId) -> bool {
if let Some(index) = self.tabs.iter().position(|t| t.metadata.id == id) {
self.close_tab(index)
} else {
false
}
}
/// Switch to a tab by index
pub fn switch_to(&mut self, index: usize) -> bool {
if index < self.tabs.len() {
self.active_tab = Some(index);
if let Some(tab) = self.tabs.get_mut(index) {
tab.metadata.touch();
tab.metadata.clear_unread();
}
true
} else {
false
}
}
/// Switch to the next tab
pub fn switch_to_next(&mut self) -> bool {
if self.tabs.is_empty() {
return false;
}
let next = match self.active_tab {
Some(i) => (i + 1) % self.tabs.len(),
None => 0,
};
self.switch_to(next)
}
/// Switch to the previous tab
pub fn switch_to_prev(&mut self) -> bool {
if self.tabs.is_empty() {
return false;
}
let prev = match self.active_tab {
Some(i) => {
if i == 0 {
self.tabs.len() - 1
} else {
i - 1
}
}
None => self.tabs.len() - 1,
};
self.switch_to(prev)
}
/// Switch to tab by ID
pub fn switch_to_by_id(&mut self, id: TabId) -> bool {
if let Some(index) = self.tabs.iter().position(|t| t.metadata.id == id) {
self.switch_to(index)
} else {
false
}
}
/// Update tab title
pub fn update_title(&mut self, index: usize, title: &str) -> bool {
if let Some(tab) = self.tabs.get_mut(index) {
tab.metadata.title = title.to_string();
true
} else {
false
}
}
/// Update tab status
pub fn update_status(&mut self, index: usize, status: TabStatus) -> bool {
if let Some(tab) = self.tabs.get_mut(index) {
tab.status = status;
true
} else {
false
}
}
/// Mark a tab as having unread content
pub fn mark_unread(&mut self, index: usize) -> bool {
if let Some(tab) = self.tabs.get_mut(index) {
if self.active_tab != Some(index) {
tab.metadata.increment_unread();
}
true
} else {
false
}
}
/// Get pending tasks for a tab
pub fn pending_tasks(&self, id: TabId) -> Vec<&DelegationResult> {
self.delegator.results_for_tab(id)
}
/// Create a link between tabs for cross-tab events
pub fn create_link(&mut self, from: TabId, to: TabId) {
let link = CrossTabLink {
from,
to,
created_at: chrono::Utc::now(),
};
self.cross_tab_links.entry(from).or_default().push(link);
}
/// Remove link between tabs
pub fn remove_link(&mut self, from: TabId, to: TabId) -> bool {
if let Some(links) = self.cross_tab_links.get_mut(&from) {
let initial_len = links.len();
links.retain(|l| l.to != to);
links.len() < initial_len
} else {
false
}
}
/// Get all links from a tab
pub fn get_links(&self, from: TabId) -> Vec<&CrossTabLink> {
self.cross_tab_links
.get(&from)
.map(|l| l.iter().collect())
.unwrap_or_default()
}
/// Delegate a task from one tab to another
pub fn delegate_task(
&mut self,
from: TabId,
to: TabId,
description: String,
priority: Priority,
) -> Option<String> {
self.delegator
.create_delegation(from, to, description, priority)
}
/// Complete a delegated task
pub fn complete_delegation(&mut self, task_id: &str, result: String) {
self.delegator.complete(task_id, result);
}
/// Get pending delegations for a tab
pub fn pending_delegations(&self, tab_id: TabId) -> Vec<&super::delegator::DelegationTask> {
self.delegator.pending_for_tab(tab_id)
}
/// Take the highest-priority pending delegation for a tab.
/// The task is marked as in-progress in place (it is not removed until
/// `complete_delegation` / `fail_delegation` / `cancel_delegation` is
/// called). Returns the task.
pub fn take_next_delegation(
&mut self,
tab_id: TabId,
) -> Option<super::delegator::DelegationTask> {
self.delegator.take_pending_for_tab(tab_id)
}
/// Peek at the next pending delegation for a tab
pub fn peek_next_delegation(&self, tab_id: TabId) -> Option<&super::delegator::DelegationTask> {
self.delegator.peek_pending_for_tab(tab_id)
}
/// Check if a tab has any pending delegations
pub fn has_pending_delegations(&self, tab_id: TabId) -> bool {
self.delegator.peek_pending_for_tab(tab_id).is_some()
}
/// Start a meeting
pub fn start_meeting(&mut self, topic: String, participants: Vec<TabId>) -> Option<String> {
self.meeting_manager.start_meeting(topic, participants)
}
/// End a meeting
pub fn end_meeting(&mut self, meeting_id: &str) -> Option<super::meeting::MeetingSummary> {
self.meeting_manager.end_meeting(meeting_id)
}
/// Add a message to a meeting
pub fn add_meeting_message(&mut self, meeting_id: &str, msg: MeetingMessage) {
self.meeting_manager.add_message(meeting_id, msg);
}
/// Add a decision to a meeting
pub fn add_meeting_decision(&mut self, meeting_id: &str, decision: MeetingDecision) {
self.meeting_manager.add_decision(meeting_id, decision);
}
/// Get active meeting for a tab
pub fn active_meeting(&self, tab_id: TabId) -> Option<&Meeting> {
self.meeting_manager.active_meeting_for(tab_id)
}
/// Get an active meeting by ID.
pub fn meeting(&self, meeting_id: &str) -> Option<&Meeting> {
self.meeting_manager.get_meeting(meeting_id)
}
}
impl Default for TabManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_and_close_tab() {
let mut manager = TabManager::new();
assert!(manager.is_empty());
let id1 = manager.create_tab("Tab 1".to_string(), TabType::Chat);
assert!(id1.is_some());
assert_eq!(manager.len(), 1);
assert_eq!(manager.active_index(), Some(0));
let id2 = manager.create_tab("Tab 2".to_string(), TabType::Chat);
assert!(id2.is_some());
assert_eq!(manager.len(), 2);
assert_eq!(manager.active_index(), Some(1));
// Close second tab
assert!(manager.close_tab(1));
assert_eq!(manager.len(), 1);
// Close first tab
assert!(manager.close_tab(0));
assert!(manager.is_empty());
}
#[test]
fn test_switch_tabs() {
let mut manager = TabManager::new();
manager.create_tab("Tab 1".to_string(), TabType::Chat);
manager.create_tab("Tab 2".to_string(), TabType::Chat);
manager.create_tab("Tab 3".to_string(), TabType::Chat);
assert_eq!(manager.active_index(), Some(2));
// Switch to next (wraps around)
assert!(manager.switch_to_next());
assert_eq!(manager.active_index(), Some(0));
// Switch to prev
assert!(manager.switch_to_prev());
assert_eq!(manager.active_index(), Some(2));
}
#[test]
fn test_max_tabs() {
let mut manager = TabManager::new();
// Create max tabs
for i in 0..MAX_TABS {
let result = manager.create_tab(format!("Tab {}", i), TabType::Chat);
assert!(result.is_some(), "Tab {} should be created", i);
}
// Try to create one more
let result = manager.create_tab("Extra".to_string(), TabType::Chat);
assert!(result.is_none(), "Should not exceed max tabs");
}
#[test]
fn test_delegation_take_priority_order() {
use super::super::Priority;
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();
// Delegate with different priorities
manager.delegate_task(from, to, "Low task".to_string(), Priority::Low);
manager.delegate_task(from, to, "Urgent task".to_string(), Priority::Urgent);
manager.delegate_task(from, to, "Normal task".to_string(), Priority::Normal);
// Take should return Urgent first
let task = manager.take_next_delegation(to).unwrap();
assert_eq!(task.description, "Urgent task");
assert_eq!(task.priority, Priority::Urgent);
// has_pending_delegations should be true
assert!(manager.has_pending_delegations(to));
// Take should return Normal next
let task = manager.take_next_delegation(to).unwrap();
assert_eq!(task.description, "Normal task");
assert_eq!(task.priority, Priority::Normal);
// Take should return Low last
let task = manager.take_next_delegation(to).unwrap();
assert_eq!(task.description, "Low task");
assert_eq!(task.priority, Priority::Low);
// No more delegations
assert!(manager.take_next_delegation(to).is_none());
assert!(!manager.has_pending_delegations(to));
}
#[test]
fn test_delegation_peek() {
use super::super::Priority;
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();
manager.delegate_task(from, to, "Test task".to_string(), Priority::High);
// Peek multiple times should not consume
let peek1 = manager.peek_next_delegation(to).unwrap();
let peek2 = manager.peek_next_delegation(to).unwrap();
assert_eq!(peek1.task_id, peek2.task_id);
assert_eq!(manager.delegator.pending_count(to), 1);
}
#[test]
fn test_tab_type_icons() {
use super::super::TabType;
// Each tab type has a unique icon
assert_eq!(TabType::Chat.icon(), "💬");
assert_eq!(TabType::Delegation.icon(), "📤");
assert_eq!(TabType::Review.icon(), "🔍");
assert_eq!(TabType::Meeting.icon(), "👥");
// ASCII fallback
assert_eq!(TabType::Chat.ascii_icon(), "[C]");
assert_eq!(TabType::Delegation.ascii_icon(), "[D]");
assert_eq!(TabType::Review.ascii_icon(), "[R]");
assert_eq!(TabType::Meeting.ascii_icon(), "[M]");
// Display names
assert_eq!(TabType::Chat.display_name(), "Chat");
assert_eq!(TabType::Delegation.display_name(), "Delegation");
assert_eq!(TabType::Review.display_name(), "Review");
assert_eq!(TabType::Meeting.display_name(), "Meeting");
}
#[test]
fn test_snapshot_and_restore() {
let mut manager = TabManager::new();
let id1 = manager
.create_tab("Tab A".to_string(), TabType::Chat)
.unwrap();
let _id2 = manager
.create_tab("Tab B".to_string(), TabType::Review)
.unwrap();
assert!(manager.switch_to_by_id(id1));
// Snapshot
let snapshot = manager.snapshot();
assert_eq!(snapshot.tabs.len(), 2);
assert_eq!(snapshot.active_tab_index, Some(0));
assert_eq!(snapshot.tabs[0].title, "Tab A");
assert_eq!(snapshot.tabs[1].tab_type, TabType::Review);
// Mutate original
manager.close_tab(0);
assert_eq!(manager.len(), 1);
// Restore
manager.restore_from_snapshot(&snapshot);
assert_eq!(manager.len(), 2);
assert_eq!(manager.active_index(), Some(0));
assert_eq!(manager.all_tabs()[0].title, "Tab A");
}
#[test]
fn test_snapshot_restore_preserves_delegations() {
use super::super::Priority;
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();
// Create a pending delegation that should survive a restart.
manager.delegate_task(from, to, "review the patch".to_string(), Priority::High);
assert_eq!(manager.pending_delegations(to).len(), 1);
let snapshot = manager.snapshot();
assert_eq!(snapshot.delegations.len(), 1);
assert_eq!(snapshot.delegations[0].description, "review the patch");
// Fresh manager restores the delegation.
let mut restored = TabManager::new();
restored.restore_from_snapshot(&snapshot);
assert_eq!(restored.pending_delegations(to).len(), 1);
assert_eq!(
restored.pending_delegations(to)[0].description,
"review the patch"
);
assert_eq!(restored.pending_delegations(to)[0].priority, Priority::High);
}
#[test]
fn test_create_tab_id_does_not_collide_after_restore() {
let mut manager = TabManager::new();
let _a = manager.create_tab("A".to_string(), TabType::Chat).unwrap();
let _b = manager.create_tab("B".to_string(), TabType::Chat).unwrap();
let _c = manager.create_tab("C".to_string(), TabType::Chat).unwrap();
let max_id_before = manager.all_tabs().iter().map(|t| t.id.0).max().unwrap();
let snapshot = manager.snapshot();
manager.close_tab(0);
manager.close_tab(0);
manager.close_tab(0);
assert!(manager.is_empty());
manager.restore_from_snapshot(&snapshot);
let d = manager.create_tab("D".to_string(), TabType::Chat).unwrap();
assert!(
d.0 > max_id_before,
"new tab id {} must exceed restored max {}",
d.0,
max_id_before
);
// The new tab id must not collide with any *restored* tab.
for tab in manager.all_tabs() {
if tab.title == "D" {
continue;
}
assert_ne!(tab.id, d, "newly created tab collided with restored tab");
}
}
#[test]
fn test_persistence_roundtrip() {
let dir = std::env::temp_dir().join("codewhale_tabs_roundtrip");
let path = dir.join("tabs.json");
let _ = std::fs::remove_file(&path);
let mut manager = TabManager::new();
manager
.create_tab("First".to_string(), TabType::Chat)
.unwrap();
manager
.create_tab("Second".to_string(), TabType::Meeting)
.unwrap();
manager.switch_to(1);
// Save
manager.save_to_file(&path).unwrap();
assert!(path.exists());
// Create fresh manager and 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_eq!(restored.all_tabs()[0].title, "First");
assert_eq!(restored.all_tabs()[1].tab_type, TabType::Meeting);
// Cleanup
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_manager_group_operations() {
use super::super::group::GroupColor;
let mut manager = TabManager::new();
let tab1 = manager
.create_tab("Tab 1".to_string(), TabType::Chat)
.unwrap();
let tab2 = manager
.create_tab("Tab 2".to_string(), TabType::Review)
.unwrap();
// Create group
let group_id = manager.create_group("Frontend".to_string(), GroupColor::Blue);
assert_eq!(manager.all_groups().len(), 1);
// Assign tabs
assert!(manager.assign_tab_to_group(tab1, &group_id));
assert!(manager.assign_tab_to_group(tab2, &group_id));
let group = manager.tab_group(tab1).unwrap();
assert_eq!(group.name, "Frontend");
assert_eq!(group.len(), 2);
// Cycle: unassign when at the end of the sorted group list
manager.cycle_tab_group(tab1);
// With only one group, cycling should unassign
assert!(manager.tab_group(tab1).is_none());
// Re-assign and create a second group
manager.assign_tab_to_group(tab1, &group_id);
manager.create_group("Alpha".to_string(), GroupColor::Red);
// Alpha comes before Frontend alphabetically
// Cycling from Frontend (last) should unassign
manager.cycle_tab_group(tab1);
assert!(manager.tab_group(tab1).is_none());
}
#[test]
fn test_close_tab_unassigns_group() {
use super::super::group::GroupColor;
let mut manager = TabManager::new();
let tab1 = manager
.create_tab("Tab 1".to_string(), TabType::Chat)
.unwrap();
let group_id = manager.create_group("Test".to_string(), GroupColor::Cyan);
manager.assign_tab_to_group(tab1, &group_id);
// Verify assignment
assert!(manager.tab_group(tab1).is_some());
// Close the tab
manager.close_tab(0);
// Group should still exist but be empty
let group = manager.groups().all_groups();
assert_eq!(group.len(), 1);
assert_eq!(group[0].len(), 0);
}
}
+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);
}
}
+219
View File
@@ -0,0 +1,219 @@
//! 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.
pub fn resolve_tab_mention<'a, I>(tab_number: usize, tab_ids: I) -> Option<u64>
where
I: IntoIterator<Item = &'a u64>,
{
let mut sorted: Vec<u64> = tab_ids.into_iter().copied().collect();
sorted.sort_unstable();
if tab_number == 0 || tab_number > sorted.len() {
return None;
}
Some(sorted[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() {
let tab_ids = vec![100, 50, 200];
// Tab 1 = smallest id (50)
assert_eq!(resolve_tab_mention(1, tab_ids.iter()), Some(50));
// Tab 2 = middle id (100)
assert_eq!(resolve_tab_mention(2, tab_ids.iter()), Some(100));
// Tab 3 = largest id (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,
}
+375
View File
@@ -0,0 +1,375 @@
//! 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::{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,
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 {
tracing::warn!(
size = metadata.len(),
max = MAX_FILE_SIZE,
path = %path.display(),
"Tab state file too large, ignoring"
);
return Ok(PersistedTabState::default());
}
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,
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 default state, not panic
let loaded = load_from_file(&path).unwrap();
assert!(loaded.tabs.is_empty());
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);
}
}