use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Tone {
Info,
Success,
Warning,
Error,
Muted,
}
impl Tone {
pub(super) fn color(self) -> Color {
match self {
Self::Info => Color::Cyan,
Self::Success => Color::Green,
Self::Warning => Color::Yellow,
Self::Error => Color::Red,
Self::Muted => Color::DarkGray,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ThreadState {
Active,
Retained,
}
impl ThreadState {
pub(super) fn label(self) -> &'static str {
match self {
Self::Active => "ACTIVE",
Self::Retained => "RETAINED",
}
}
pub(super) fn tone(self) -> Tone {
match self {
Self::Active => Tone::Success,
Self::Retained => Tone::Muted,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ToolStatus {
Running,
Ok,
Failed,
Error,
TimedOut,
}
impl ToolStatus {
pub(super) fn label(self) -> &'static str {
match self {
Self::Running => "RUN",
Self::Ok => "OK",
Self::Failed => "FAIL",
Self::Error => "ERR",
Self::TimedOut => "TIME",
}
}
pub(super) fn tone(self) -> Tone {
match self {
Self::Running => Tone::Info,
Self::Ok => Tone::Success,
Self::Failed => Tone::Warning,
Self::Error => Tone::Error,
Self::TimedOut => Tone::Warning,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(super) enum PanelId {
Prompt,
Events,
Threads,
Response,
PreviousResponse,
Workspace,
Tools,
Terminals,
Worksets,
FileChanges,
ThreadList,
ThreadEpisodes,
CompactStream,
Stream,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ResponseEntry {
pub(super) content: String,
pub(super) duration: Option<Duration>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct TimelineEntry {
pub(crate) timestamp: String,
pub(crate) actor: String,
pub(crate) detail: String,
pub(crate) tone: Tone,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) tool_record_index: Option<usize>,
}
#[derive(Debug, Clone)]
pub(super) struct ThreadView {
pub(super) name: String,
pub(super) action: String,
pub(super) state: ThreadState,
pub(super) updated_at: String, pub(super) updated_at_ts: u64, pub(super) episodes: i64,
pub(super) summary: String,
}
#[derive(Debug, Clone)]
pub(super) struct ActiveTool {
pub(super) thread_name: Option<String>,
pub(super) name: String,
pub(super) target: String,
pub(super) started_at: Instant,
}
#[derive(Debug, Clone)]
pub(super) struct ToolRecord {
pub(super) thread_name: Option<String>,
pub(super) name: String,
pub(super) target: String,
pub(super) status: ToolStatus,
pub(super) duration: Duration,
pub(super) summary: String,
pub(super) content: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct GitStatusCounts {
pub(super) modified: usize,
pub(super) staged: usize,
pub(super) untracked: usize,
pub(super) added: usize,
pub(super) deleted: usize,
pub(super) renamed: usize,
}
#[derive(Debug, Clone)]
pub(super) struct ChangedFileStat {
pub(super) status: String,
pub(super) path: String,
pub(super) additions: Option<u64>,
pub(super) deletions: Option<u64>,
}
#[derive(Debug, Clone)]
pub(super) struct WorkspaceSnapshot {
pub(super) host_root: Option<PathBuf>,
pub(super) workspace_display: String,
pub(super) repo_label: Option<String>,
pub(super) branch: Option<String>,
pub(super) changed_files: Vec<ChangedFileStat>,
pub(super) total_additions: u64,
pub(super) total_deletions: u64,
pub(super) error: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct WorksetSnapshot {
pub(super) items: Vec<store::WorksetRecord>,
pub(super) error: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct TerminalDisplayInfo {
pub(super) thread_name: Option<String>,
pub(super) name: String,
pub(super) command_state: crate::terminal::CommandState,
pub(super) current_command: Option<String>,
pub(super) last_exit_code: Option<i32>,
pub(super) idle_ms: u64,
pub(super) age_ms: u64,
}
#[derive(Debug, Clone, Default)]
pub(super) struct TerminalsSnapshot {
pub(super) terminals: Vec<TerminalDisplayInfo>,
pub(super) error: Option<String>,
}
impl WorksetSnapshot {
pub(super) fn load(store_path: &Path, session_id: Option<&str>) -> Self {
let Some(session_id) = session_id else {
return Self {
items: Vec::new(),
error: Some("no active session".to_string()),
};
};
match load_workset_records(store_path, session_id) {
Ok(items) => Self { items, error: None },
Err(error) => Self {
items: Vec::new(),
error: Some(error.to_string()),
},
}
}
}
fn load_workset_records(
store_path: &Path,
session_id: &str,
) -> anyhow::Result<Vec<store::WorksetRecord>> {
tokio::task::block_in_place(|| {
let summaries = store::list_worksets(store_path, session_id)?;
let mut worksets = Vec::with_capacity(summaries.len());
for summary in summaries {
if let Some(workset) = store::read_workset(store_path, session_id, &summary.id)? {
worksets.push(workset);
}
}
Ok(worksets)
})
}
impl WorkspaceSnapshot {
pub(super) fn load(workspace_display: &str, host_root: Option<&Path>) -> Self {
let Some(cwd) = host_root else {
return Self {
host_root: None,
workspace_display: workspace_display.to_string(),
repo_label: None,
branch: None,
changed_files: Vec::new(),
total_additions: 0,
total_deletions: 0,
error: Some(format!(
"workspace '{}' is sandbox-only; host-side inspection unavailable",
workspace_display
)),
};
};
let root = run_git(cwd, &["rev-parse", "--show-toplevel"]).and_then(|path| {
if path.is_empty() {
None
} else {
Some(PathBuf::from(path))
}
});
let branch = run_git(cwd, &["branch", "--show-current"]).filter(|value| !value.is_empty());
let remote = run_git(cwd, &["config", "--get", "remote.origin.url"]);
let repo_label = remote.as_deref().and_then(parse_remote_label).or_else(|| {
root.as_ref()
.and_then(|path| path.file_name())
.and_then(|value| value.to_str())
.map(|value| value.to_string())
});
let status_raw = match run_git(cwd, &["status", "--porcelain"]) {
Some(value) => value,
None => {
return Self {
host_root: Some(cwd.to_path_buf()),
workspace_display: workspace_display.to_string(),
repo_label,
branch,
changed_files: Vec::new(),
total_additions: 0,
total_deletions: 0,
error: Some("git status unavailable".to_string()),
};
}
};
let diff_raw = run_git(cwd, &["diff", "--numstat"]).unwrap_or_default();
let cached_raw = run_git(cwd, &["diff", "--cached", "--numstat"]).unwrap_or_default();
let (_, mut file_map) = parse_status_porcelain(&status_raw);
let (diff_map, total_additions, total_deletions) =
parse_numstat_pairs(&diff_raw, &cached_raw);
for (path, (additions, deletions)) in diff_map {
let entry = file_map
.entry(path.clone())
.or_insert_with(|| ChangedFileStat {
status: "M".to_string(),
path,
additions: None,
deletions: None,
});
if let Some(value) = additions {
entry.additions = Some(entry.additions.unwrap_or(0).saturating_add(value));
}
if let Some(value) = deletions {
entry.deletions = Some(entry.deletions.unwrap_or(0).saturating_add(value));
}
}
let mut changed_files: Vec<ChangedFileStat> = file_map.into_values().collect();
changed_files.sort_by(|left, right| {
let left_delta = left
.additions
.unwrap_or(0)
.saturating_add(left.deletions.unwrap_or(0));
let right_delta = right
.additions
.unwrap_or(0)
.saturating_add(right.deletions.unwrap_or(0));
right_delta
.cmp(&left_delta)
.then_with(|| left.path.cmp(&right.path))
});
Self {
host_root: Some(cwd.to_path_buf()),
workspace_display: workspace_display.to_string(),
repo_label,
branch,
changed_files,
total_additions,
total_deletions,
error: None,
}
}
}
#[derive(Debug, Clone)]
pub(super) struct StyledSegment {
pub(super) text: String,
pub(super) style: Style,
}
#[derive(Debug, Clone)]
pub(super) struct WrappedRow {
pub(super) logical_line: usize,
pub(super) start_char: usize,
pub(super) end_char: usize,
pub(super) text: String,
pub(super) spans: Vec<StyledSegment>,
}
#[derive(Debug, Clone)]
pub(super) struct PanelView {
pub(super) id: PanelId,
pub(super) inner: Rect,
pub(super) logical_lines: Vec<String>,
pub(super) rows: Vec<WrappedRow>,
pub(super) scroll_offset: usize,
pub(super) visible_rows: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct SelectionPoint {
pub(super) panel: PanelId,
pub(super) logical_line: usize,
pub(super) char_index: usize,
}
#[derive(Debug, Clone)]
pub(super) struct SelectionState {
pub(super) anchor: SelectionPoint,
pub(super) focus: SelectionPoint,
pub(super) dragging: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FocusPanel {
Prompt,
Events,
Response,
PreviousResponse,
Threads,
Tools,
Terminals,
Workspace,
Worksets,
FileChanges,
Stream,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct PaneFocusBinding {
pub(super) panel: FocusPanel,
pub(super) label: &'static str,
pub(super) mnemonic: char,
}
pub(super) const PANE_FOCUS_BINDINGS: [PaneFocusBinding; 11] = [
PaneFocusBinding {
panel: FocusPanel::Prompt,
label: "Prompts",
mnemonic: 'p',
},
PaneFocusBinding {
panel: FocusPanel::Events,
label: "Events",
mnemonic: 'e',
},
PaneFocusBinding {
panel: FocusPanel::Threads,
label: "Threads",
mnemonic: 't',
},
PaneFocusBinding {
panel: FocusPanel::Response,
label: "Responses",
mnemonic: 'r',
},
PaneFocusBinding {
panel: FocusPanel::PreviousResponse,
label: "Previous",
mnemonic: 'g',
},
PaneFocusBinding {
panel: FocusPanel::Tools,
label: "Tools",
mnemonic: 'o',
},
PaneFocusBinding {
panel: FocusPanel::Terminals,
label: "Terminals",
mnemonic: 'l',
},
PaneFocusBinding {
panel: FocusPanel::Workspace,
label: "Workspace",
mnemonic: 'w',
},
PaneFocusBinding {
panel: FocusPanel::Worksets,
label: "Worksets",
mnemonic: 'k',
},
PaneFocusBinding {
panel: FocusPanel::FileChanges,
label: "File Changes",
mnemonic: 'f',
},
PaneFocusBinding {
panel: FocusPanel::Stream,
label: "Stream",
mnemonic: 's',
},
];
impl PaneFocusBinding {
pub(super) fn short_binding(self) -> String {
format!("C-{}", self.mnemonic.to_ascii_uppercase())
}
pub(super) fn full_binding(self) -> String {
format!("Ctrl-{}", self.mnemonic.to_ascii_uppercase())
}
pub(super) fn matches(self, code: KeyCode, modifiers: KeyModifiers) -> bool {
if !modifiers.contains(KeyModifiers::CONTROL) {
return false;
}
match code {
KeyCode::Char(value) => {
let value = value.to_ascii_lowercase();
value == self.mnemonic
}
_ => false,
}
}
}
pub(super) fn pane_focus_binding(panel: FocusPanel) -> Option<PaneFocusBinding> {
PANE_FOCUS_BINDINGS
.iter()
.copied()
.find(|binding| binding.panel == panel)
}
pub(super) fn pane_focus_panel_for_key(key: KeyEvent) -> Option<FocusPanel> {
PANE_FOCUS_BINDINGS
.iter()
.copied()
.find(|binding| binding.matches(key.code, key.modifiers))
.map(|binding| binding.panel)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ScreenMode {
Dashboard,
Focused(FocusPanel),
SessionPicker { startup: bool },
}
#[derive(Debug, Clone, Default)]
pub(super) struct SessionPickerState {
pub(super) sessions: Vec<sessions::SessionSummary>,
pub(super) selected: usize,
pub(super) error: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct ComposerNotice {
pub(super) text: String,
pub(super) tone: Tone,
pub(super) expires_at: Instant,
}
#[derive(Clone, Debug)]
pub(super) enum StreamEntry {
UserPrompt { text: String, timestamp: String },
ModelTurn { iteration: usize, timestamp: String },
ToolCall { name: String, target: String, timestamp: String },
ToolResult { name: String, content: Option<String>, is_error: bool, duration_ms: u64, timestamp: String },
ThreadStarted { name: String, action: String, timestamp: String },
ThreadFinished { name: String, exit_code: i32, timestamp: String },
AssistantText { text: String, thread_name: Option<String>, timestamp: String },
StreamingDelta { text: String, thread_name: Option<String>, timestamp: String },
}