feat: remove Normal mode and consolidate to Agent (#4)
Keep legacy /normal and settings fallback behavior mapped to Agent, align docs around the three visible modes, and include the current TUI and onboarding refinements in this worktree.
This commit is contained in:
@@ -148,4 +148,4 @@ See DEPENDENCY_GRAPH.md for the full dependency graph.
|
||||
- TUI binary still references monolith source (src/) — migration incremental
|
||||
- DeepSeek API: Responses API preferred, chat completions fallback
|
||||
- Sandbox: macOS Seatbelt, Linux Landlock
|
||||
- Modes: Normal, Plan, Agent, YOLO, RLM, Duo (each gates different tools)
|
||||
- Modes: Plan, Agent, YOLO (visible). Hidden `/normal` and legacy `default_mode = "normal"` normalize to Agent.
|
||||
|
||||
@@ -54,11 +54,10 @@ project.
|
||||
|
||||
An agent loop with file editing, shell execution, `web.run` browsing, git operations, task tracking, and [MCP](https://modelcontextprotocol.io) server integration. Context-aware memory compaction keeps long sessions on track. `crates/tui` remains the live shipped runtime while the workspace extraction continues.
|
||||
|
||||
Four modes (**Tab** / **Shift+Tab** to cycle):
|
||||
Three visible modes (**Tab** / **Shift+Tab** to cycle):
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| **Normal** | Chat-first mode for questions, explanation, and low-friction steering |
|
||||
| **Plan** | Design-first — proposes before acting |
|
||||
| **Agent** | Multi-step autonomous tool use |
|
||||
| **YOLO** | Full auto-approve, no guardrails |
|
||||
@@ -67,7 +66,7 @@ Four modes (**Tab** / **Shift+Tab** to cycle):
|
||||
|
||||
1. Paste your API key in onboarding.
|
||||
2. Choose a mode for the task in front of you:
|
||||
`Normal` to ask questions, `Plan` to review a plan first, `Agent` to let the model use tools, `YOLO` only inside a trusted workspace.
|
||||
`Plan` to review a plan first, `Agent` to let the model use tools, `YOLO` only inside a trusted workspace.
|
||||
3. Watch the status area while work is running:
|
||||
approvals, queued work, and active sub-agents stay there while the turn is live.
|
||||
4. Recover work with `Ctrl+R` or `/sessions` if you need to resume an interrupted thread.
|
||||
|
||||
@@ -82,6 +82,14 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.auto_compact = settings.auto_compact;
|
||||
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
|
||||
}
|
||||
"calm_mode" | "calm" => {
|
||||
app.calm_mode = settings.calm_mode;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"low_motion" | "motion" => {
|
||||
app.low_motion = settings.low_motion;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"show_thinking" | "thinking" => {
|
||||
app.show_thinking = settings.show_thinking;
|
||||
app.mark_history_updated();
|
||||
@@ -90,6 +98,16 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.show_tool_details = settings.show_tool_details;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"composer_density" | "composer" => {
|
||||
app.composer_density =
|
||||
crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"transcript_spacing" | "spacing" => {
|
||||
app.transcript_spacing =
|
||||
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let mode = AppMode::from_setting(&settings.default_mode);
|
||||
app.set_mode(mode);
|
||||
@@ -120,13 +138,18 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let display_value = match key.as_str() {
|
||||
"default_mode" | "mode" => settings.default_mode.clone(),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
|
||||
let message = if persist {
|
||||
if let Err(e) = settings.save() {
|
||||
return CommandResult::error(format!("Failed to save: {e}"));
|
||||
}
|
||||
format!("{key} = {value} (saved)")
|
||||
format!("{key} = {display_value} (saved)")
|
||||
} else {
|
||||
format!("{key} = {value} (session only, add --save to persist)")
|
||||
format!("{key} = {display_value} (session only, add --save to persist)")
|
||||
};
|
||||
|
||||
CommandResult {
|
||||
@@ -175,10 +198,10 @@ pub fn yolo(app: &mut App) -> CommandResult {
|
||||
CommandResult::message("YOLO mode enabled - shell + trust + auto-approve!")
|
||||
}
|
||||
|
||||
/// Enable normal mode (read-only chat, suggestions before approvals)
|
||||
/// Legacy alias for the removed normal mode.
|
||||
pub fn normal_mode(app: &mut App) -> CommandResult {
|
||||
app.set_mode(AppMode::Normal);
|
||||
CommandResult::message("Normal mode enabled.")
|
||||
app.set_mode(AppMode::Agent);
|
||||
CommandResult::message("Normal mode was removed. Switched to Agent mode.")
|
||||
}
|
||||
|
||||
/// Enable agent mode (autonomous tool use with approvals)
|
||||
@@ -334,7 +357,7 @@ mod tests {
|
||||
fn test_mode_switch_commands() {
|
||||
let mut app = create_test_app();
|
||||
let _ = normal_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Normal);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = agent_mode(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
let _ = plan_mode(&mut app);
|
||||
@@ -403,6 +426,32 @@ mod tests {
|
||||
assert_eq!(app.model, "deepseek-reasoner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_mode_normal_save_reports_normalized_value() {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-tui-default-mode-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("default_mode normal --save"));
|
||||
let msg = result.message.unwrap();
|
||||
assert_eq!(msg, "default_mode = agent (saved)");
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
|
||||
let settings_path = Settings::path().unwrap();
|
||||
let saved = fs::read_to_string(settings_path).unwrap();
|
||||
assert!(saved.contains("default_mode = \"agent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approval_mode_valid_values() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -180,11 +180,12 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let _ = writeln!(stats, "\nMode Tips");
|
||||
let _ = writeln!(stats, "--------------------------------------------");
|
||||
match app.mode {
|
||||
AppMode::Normal => {
|
||||
let _ = writeln!(stats, "Normal mode - Chat with the assistant");
|
||||
}
|
||||
AppMode::Agent => {
|
||||
let _ = writeln!(stats, "Agent mode - Use tools for autonomous tasks");
|
||||
let _ = writeln!(
|
||||
stats,
|
||||
" Use Ctrl+X to review in Plan mode before executing"
|
||||
);
|
||||
let _ = writeln!(stats, " Type /yolo to enable full tool access");
|
||||
}
|
||||
AppMode::Yolo => {
|
||||
@@ -444,12 +445,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_mode_tips_for_each_mode() {
|
||||
let modes = [
|
||||
AppMode::Normal,
|
||||
AppMode::Agent,
|
||||
AppMode::Yolo,
|
||||
AppMode::Plan,
|
||||
];
|
||||
let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan];
|
||||
for mode in modes {
|
||||
let mut app = create_test_app();
|
||||
app.mode = mode;
|
||||
|
||||
@@ -212,12 +212,6 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Enable YOLO mode (shell + trust + auto-approve)",
|
||||
usage: "/yolo",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "normal",
|
||||
aliases: &[],
|
||||
description: "Switch to normal mode (no autonomous tool flow)",
|
||||
usage: "/normal",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "agent",
|
||||
aliases: &[],
|
||||
@@ -346,7 +340,6 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"config" => config::show_config(app),
|
||||
"settings" => config::show_settings(app),
|
||||
"yolo" => config::yolo(app),
|
||||
"normal" => config::normal_mode(app),
|
||||
"agent" => config::agent_mode(app),
|
||||
"plan" => config::plan_mode(app),
|
||||
"trust" => config::trust(app),
|
||||
@@ -372,6 +365,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"set" => CommandResult::error(
|
||||
"The /set command was retired. Use /config to edit settings and /settings to inspect current values.",
|
||||
),
|
||||
"normal" => config::normal_mode(app),
|
||||
"deepseek" => CommandResult::error(
|
||||
"The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).",
|
||||
),
|
||||
|
||||
@@ -35,7 +35,7 @@ pub enum HookEvent {
|
||||
ToolCallBefore,
|
||||
/// Triggered after a tool completes (success or failure)
|
||||
ToolCallAfter,
|
||||
/// Triggered when the user changes modes (Normal, Edit, Agent, Plan)
|
||||
/// Triggered when the user changes modes (Plan, Agent, Yolo)
|
||||
ModeChange,
|
||||
/// Triggered when an error occurs
|
||||
OnError,
|
||||
|
||||
@@ -50,6 +50,7 @@ pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
|
||||
pub const FOOTER_HINT: Color = Color::Rgb(180, 190, 208); // #B4BED0
|
||||
pub const SELECTION_TEXT: Color = Color::White;
|
||||
pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235); // #D6DFEB
|
||||
|
||||
// Compatibility aliases for existing call sites.
|
||||
pub const TEXT_PRIMARY: Color = TEXT_BODY;
|
||||
@@ -69,6 +70,26 @@ pub const BACKGROUND_LIGHT: Color = Color::Rgb(30, 47, 71); // #1E2F47
|
||||
pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30
|
||||
#[allow(dead_code)]
|
||||
pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); // #18273C
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); // #1D3049
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); // #16383F
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); // #3F1B24
|
||||
pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(146, 198, 248); // #92C6F8
|
||||
pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA
|
||||
pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99
|
||||
pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(205, 216, 228); // #CDD8E4
|
||||
|
||||
// Legacy status colors - keep for backward compatibility
|
||||
pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
|
||||
@@ -78,7 +99,6 @@ pub const STATUS_ERROR: Color = DEEPSEEK_RED;
|
||||
pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
|
||||
|
||||
// Mode-specific accent colors for mode badges
|
||||
pub const MODE_NORMAL: Color = Color::Rgb(192, 192, 192); // #C0C0C0
|
||||
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
|
||||
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
|
||||
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::path::Path;
|
||||
|
||||
// Prompt files loaded at compile time
|
||||
pub const BASE_PROMPT: &str = include_str!("prompts/base.txt");
|
||||
#[allow(dead_code)]
|
||||
pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt");
|
||||
pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt");
|
||||
pub const YOLO_PROMPT: &str = include_str!("prompts/yolo.txt");
|
||||
@@ -18,7 +19,6 @@ pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt");
|
||||
|
||||
fn mode_prompt(mode: AppMode) -> &'static str {
|
||||
match mode {
|
||||
AppMode::Normal => NORMAL_PROMPT,
|
||||
AppMode::Agent => AGENT_PROMPT,
|
||||
AppMode::Yolo => YOLO_PROMPT,
|
||||
AppMode::Plan => PLAN_PROMPT,
|
||||
@@ -95,7 +95,7 @@ pub fn base_system_prompt() -> SystemPrompt {
|
||||
}
|
||||
|
||||
pub fn normal_system_prompt() -> SystemPrompt {
|
||||
system_prompt_for_mode(AppMode::Normal)
|
||||
system_prompt_for_mode(AppMode::Agent)
|
||||
}
|
||||
|
||||
pub fn agent_system_prompt() -> SystemPrompt {
|
||||
|
||||
@@ -2251,7 +2251,6 @@ fn enforce_lru_capacity(
|
||||
|
||||
fn parse_mode(mode: &str) -> AppMode {
|
||||
match mode.trim().to_ascii_lowercase().as_str() {
|
||||
"normal" => AppMode::Normal,
|
||||
"plan" => AppMode::Plan,
|
||||
"yolo" => AppMode::Yolo,
|
||||
_ => AppMode::Agent,
|
||||
|
||||
@@ -17,11 +17,19 @@ pub struct Settings {
|
||||
pub theme: String,
|
||||
/// Auto-compact conversations when they get long
|
||||
pub auto_compact: bool,
|
||||
/// Reduce status noise and collapse details more aggressively
|
||||
pub calm_mode: bool,
|
||||
/// Reduce animation and redraw churn
|
||||
pub low_motion: bool,
|
||||
/// Show thinking blocks from the model
|
||||
pub show_thinking: bool,
|
||||
/// Show detailed tool output
|
||||
pub show_tool_details: bool,
|
||||
/// Default mode: "normal", "agent", "plan", "yolo"
|
||||
/// Composer layout density: compact, comfortable, spacious
|
||||
pub composer_density: String,
|
||||
/// Transcript spacing rhythm: compact, comfortable, spacious
|
||||
pub transcript_spacing: String,
|
||||
/// Default mode: "agent", "plan", "yolo"
|
||||
pub default_mode: String,
|
||||
/// Sidebar width as percentage of terminal width
|
||||
pub sidebar_width_percent: u16,
|
||||
@@ -38,8 +46,12 @@ impl Default for Settings {
|
||||
Self {
|
||||
theme: "whale".to_string(),
|
||||
auto_compact: true,
|
||||
calm_mode: false,
|
||||
low_motion: false,
|
||||
show_thinking: true,
|
||||
show_tool_details: true,
|
||||
composer_density: "comfortable".to_string(),
|
||||
transcript_spacing: "comfortable".to_string(),
|
||||
default_mode: "agent".to_string(),
|
||||
sidebar_width_percent: 28,
|
||||
sidebar_focus: "auto".to_string(),
|
||||
@@ -70,6 +82,10 @@ impl Settings {
|
||||
let mut settings: Settings = toml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse settings from {}", path.display()))?;
|
||||
settings.default_mode = normalize_mode(&settings.default_mode).to_string();
|
||||
settings.composer_density =
|
||||
normalize_composer_density(&settings.composer_density).to_string();
|
||||
settings.transcript_spacing =
|
||||
normalize_transcript_spacing(&settings.transcript_spacing).to_string();
|
||||
settings.sidebar_focus = normalize_sidebar_focus(&settings.sidebar_focus).to_string();
|
||||
settings.default_model = settings
|
||||
.default_model
|
||||
@@ -109,17 +125,41 @@ impl Settings {
|
||||
"auto_compact" | "compact" => {
|
||||
self.auto_compact = parse_bool(value)?;
|
||||
}
|
||||
"calm_mode" | "calm" => {
|
||||
self.calm_mode = parse_bool(value)?;
|
||||
}
|
||||
"low_motion" | "motion" => {
|
||||
self.low_motion = parse_bool(value)?;
|
||||
}
|
||||
"show_thinking" | "thinking" => {
|
||||
self.show_thinking = parse_bool(value)?;
|
||||
}
|
||||
"show_tool_details" | "tool_details" => {
|
||||
self.show_tool_details = parse_bool(value)?;
|
||||
}
|
||||
"composer_density" | "composer" => {
|
||||
let normalized = normalize_composer_density(value);
|
||||
if !["compact", "comfortable", "spacious"].contains(&normalized) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid composer density '{value}'. Expected: compact, comfortable, spacious."
|
||||
);
|
||||
}
|
||||
self.composer_density = normalized.to_string();
|
||||
}
|
||||
"transcript_spacing" | "spacing" => {
|
||||
let normalized = normalize_transcript_spacing(value);
|
||||
if !["compact", "comfortable", "spacious"].contains(&normalized) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid transcript spacing '{value}'. Expected: compact, comfortable, spacious."
|
||||
);
|
||||
}
|
||||
self.transcript_spacing = normalized.to_string();
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let normalized = normalize_mode(value);
|
||||
if !["normal", "agent", "plan", "yolo"].contains(&normalized) {
|
||||
if !["agent", "plan", "yolo"].contains(&normalized) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid mode '{value}'. Expected: normal, agent, plan, yolo."
|
||||
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo."
|
||||
);
|
||||
}
|
||||
self.default_mode = normalized.to_string();
|
||||
@@ -195,8 +235,12 @@ impl Settings {
|
||||
lines.push("─────────────────────────────".to_string());
|
||||
lines.push(format!(" theme: {}", self.theme));
|
||||
lines.push(format!(" auto_compact: {}", self.auto_compact));
|
||||
lines.push(format!(" calm_mode: {}", self.calm_mode));
|
||||
lines.push(format!(" low_motion: {}", self.low_motion));
|
||||
lines.push(format!(" show_thinking: {}", self.show_thinking));
|
||||
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
|
||||
lines.push(format!(" composer_density: {}", self.composer_density));
|
||||
lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
|
||||
lines.push(format!(" default_mode: {}", self.default_mode));
|
||||
lines.push(format!(
|
||||
" sidebar_width: {}%",
|
||||
@@ -222,9 +266,19 @@ impl Settings {
|
||||
vec![
|
||||
("theme", "Color theme: default, dark, light"),
|
||||
("auto_compact", "Auto-compact conversations: on/off"),
|
||||
("calm_mode", "Calmer UI defaults: on/off"),
|
||||
("low_motion", "Reduce animation and redraw churn: on/off"),
|
||||
("show_thinking", "Show model thinking: on/off"),
|
||||
("show_tool_details", "Show detailed tool output: on/off"),
|
||||
("default_mode", "Default mode: normal, agent, plan, yolo"),
|
||||
(
|
||||
"composer_density",
|
||||
"Composer density: compact, comfortable, spacious",
|
||||
),
|
||||
(
|
||||
"transcript_spacing",
|
||||
"Transcript spacing: compact, comfortable, spacious",
|
||||
),
|
||||
("default_mode", "Default mode: agent, plan, yolo"),
|
||||
("sidebar_width", "Sidebar width percentage: 10-50"),
|
||||
(
|
||||
"sidebar_focus",
|
||||
@@ -253,7 +307,7 @@ fn parse_bool(value: &str) -> Result<bool> {
|
||||
fn normalize_mode(value: &str) -> &str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"edit" => "agent",
|
||||
"normal" => "normal",
|
||||
"normal" => "agent",
|
||||
"agent" => "agent",
|
||||
"plan" => "plan",
|
||||
"yolo" => "yolo",
|
||||
@@ -261,6 +315,24 @@ fn normalize_mode(value: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_composer_density(value: &str) -> &str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"compact" | "tight" => "compact",
|
||||
"comfortable" | "default" | "normal" => "comfortable",
|
||||
"spacious" | "loose" => "spacious",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_transcript_spacing(value: &str) -> &str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"compact" | "tight" => "compact",
|
||||
"comfortable" | "default" | "normal" => "comfortable",
|
||||
"spacious" | "loose" => "spacious",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_sidebar_focus(value: &str) -> &str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"plan" => "plan",
|
||||
|
||||
@@ -175,7 +175,7 @@ impl ToolRegistry {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get read-only tools (for Normal mode).
|
||||
/// Get read-only tools.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn read_only_tools(&self) -> Vec<Arc<dyn ToolSpec>> {
|
||||
|
||||
+67
-71
@@ -1,7 +1,7 @@
|
||||
//! Application state for the `DeepSeek` TUI.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
@@ -29,28 +29,6 @@ use crate::tui::streaming::StreamingState;
|
||||
use crate::tui::transcript::TranscriptViewCache;
|
||||
use crate::tui::views::ViewStack;
|
||||
|
||||
/// Format a nice welcome banner.
|
||||
fn format_welcome_banner(model: &str, workspace: &Path, yolo: bool) -> String {
|
||||
let mode_line = if yolo {
|
||||
"\nYOLO mode — shell + trust + auto-approve enabled\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!(
|
||||
"Start with a workflow instead of a shortcut:\n\
|
||||
- Normal asks questions, Agent runs tools, Plan reviews the approach first\n\
|
||||
- Watch approvals, queued prompts, and sub-agents in the runtime status area\n\
|
||||
- Use /queue to edit pending work and Ctrl+R or /sessions to resume past threads\n\
|
||||
- Ctrl+K opens the command palette, F1 opens help, Esc cancels current work\n\
|
||||
{mode_line}\
|
||||
Directory: {}\n\
|
||||
Model: {}",
|
||||
workspace.display(),
|
||||
model
|
||||
)
|
||||
}
|
||||
|
||||
// === Types ===
|
||||
|
||||
/// State machine for onboarding new users.
|
||||
@@ -66,7 +44,6 @@ pub enum OnboardingState {
|
||||
/// Supported application modes for the TUI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppMode {
|
||||
Normal,
|
||||
Agent,
|
||||
Yolo,
|
||||
Plan,
|
||||
@@ -82,6 +59,42 @@ pub enum SidebarFocus {
|
||||
Agents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ComposerDensity {
|
||||
Compact,
|
||||
Comfortable,
|
||||
Spacious,
|
||||
}
|
||||
|
||||
impl ComposerDensity {
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"compact" | "tight" => Self::Compact,
|
||||
"spacious" | "loose" => Self::Spacious,
|
||||
_ => Self::Comfortable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TranscriptSpacing {
|
||||
Compact,
|
||||
Comfortable,
|
||||
Spacious,
|
||||
}
|
||||
|
||||
impl TranscriptSpacing {
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"compact" | "tight" => Self::Compact,
|
||||
"spacious" | "loose" => Self::Spacious,
|
||||
_ => Self::Comfortable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SidebarFocus {
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Self {
|
||||
@@ -184,7 +197,6 @@ impl AppMode {
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"normal" => Self::Normal,
|
||||
"plan" => Self::Plan,
|
||||
"yolo" => Self::Yolo,
|
||||
_ => Self::Agent,
|
||||
@@ -194,7 +206,6 @@ impl AppMode {
|
||||
#[must_use]
|
||||
pub fn as_setting(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "normal",
|
||||
Self::Agent => "agent",
|
||||
Self::Yolo => "yolo",
|
||||
Self::Plan => "plan",
|
||||
@@ -204,7 +215,6 @@ impl AppMode {
|
||||
/// Short label used in the UI footer.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
AppMode::Normal => "NORMAL",
|
||||
AppMode::Agent => "AGENT",
|
||||
AppMode::Yolo => "YOLO",
|
||||
AppMode::Plan => "PLAN",
|
||||
@@ -215,7 +225,6 @@ impl AppMode {
|
||||
/// Description shown in help or onboarding text.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
AppMode::Normal => "Chat mode - ask questions, get answers",
|
||||
AppMode::Agent => "Agent mode - autonomous task execution with tools",
|
||||
AppMode::Yolo => "YOLO mode - full tool access without approvals",
|
||||
AppMode::Plan => "Plan mode - design before implementing",
|
||||
@@ -301,8 +310,12 @@ pub struct App {
|
||||
pub input_history: Vec<String>,
|
||||
pub history_index: Option<usize>,
|
||||
pub auto_compact: bool,
|
||||
pub calm_mode: bool,
|
||||
pub low_motion: bool,
|
||||
pub show_thinking: bool,
|
||||
pub show_tool_details: bool,
|
||||
pub composer_density: ComposerDensity,
|
||||
pub transcript_spacing: TranscriptSpacing,
|
||||
pub sidebar_width_percent: u16,
|
||||
pub sidebar_focus: SidebarFocus,
|
||||
/// Slash menu selection index in composer.
|
||||
@@ -409,8 +422,6 @@ pub struct App {
|
||||
pub task_panel: Vec<TaskPanelEntry>,
|
||||
/// Whether the UI needs to be redrawn.
|
||||
pub needs_redraw: bool,
|
||||
/// Session start time for elapsed-time display in the footer.
|
||||
pub session_start: Instant,
|
||||
/// When the current thinking block started (for duration tracking).
|
||||
pub thinking_started_at: Option<Instant>,
|
||||
/// Whether context compaction is currently in progress.
|
||||
@@ -504,8 +515,12 @@ impl App {
|
||||
let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key);
|
||||
let settings = Settings::load().unwrap_or_else(|_| Settings::default());
|
||||
let auto_compact = settings.auto_compact;
|
||||
let calm_mode = settings.calm_mode;
|
||||
let low_motion = settings.low_motion;
|
||||
let show_thinking = settings.show_thinking;
|
||||
let show_tool_details = settings.show_tool_details;
|
||||
let composer_density = ComposerDensity::from_setting(&settings.composer_density);
|
||||
let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
|
||||
let sidebar_width_percent = settings.sidebar_width_percent;
|
||||
let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
|
||||
let max_input_history = settings.max_input_history;
|
||||
@@ -534,14 +549,6 @@ impl App {
|
||||
};
|
||||
let allow_shell = allow_shell || initial_mode == AppMode::Yolo;
|
||||
|
||||
let history = if needs_onboarding {
|
||||
Vec::new() // No welcome message during onboarding
|
||||
} else {
|
||||
vec![HistoryCell::System {
|
||||
content: format_welcome_banner(&model, &workspace, yolo),
|
||||
}]
|
||||
};
|
||||
|
||||
// Initialize hooks executor from config
|
||||
let hooks_config = config.hooks_config();
|
||||
let hooks = HookExecutor::new(hooks_config, workspace.clone());
|
||||
@@ -549,8 +556,6 @@ impl App {
|
||||
// Initialize plan state
|
||||
let plan_state = new_shared_plan_state();
|
||||
|
||||
let history_len = history.len() as u64;
|
||||
|
||||
let agents_skills_dir = workspace.join(".agents").join("skills");
|
||||
let local_skills_dir = workspace.join("skills");
|
||||
let skills_dir = if agents_skills_dir.exists() {
|
||||
@@ -566,8 +571,8 @@ impl App {
|
||||
input: String::new(),
|
||||
cursor_position: 0,
|
||||
paste_burst: PasteBurst::default(),
|
||||
history,
|
||||
history_version: history_len,
|
||||
history: Vec::new(),
|
||||
history_version: 0,
|
||||
api_messages: Vec::new(),
|
||||
transcript_scroll: TranscriptScroll::ToBottom,
|
||||
pending_scroll_delta: 0,
|
||||
@@ -593,8 +598,12 @@ impl App {
|
||||
input_history: Vec::new(),
|
||||
history_index: None,
|
||||
auto_compact,
|
||||
calm_mode,
|
||||
low_motion,
|
||||
show_thinking,
|
||||
show_tool_details,
|
||||
composer_density,
|
||||
transcript_spacing,
|
||||
sidebar_width_percent,
|
||||
sidebar_focus,
|
||||
slash_menu_selected: 0,
|
||||
@@ -665,7 +674,6 @@ impl App {
|
||||
workspace_context_refreshed_at: None,
|
||||
task_panel: Vec::new(),
|
||||
needs_redraw: true,
|
||||
session_start: Instant::now(),
|
||||
thinking_started_at: None,
|
||||
is_compacting: false,
|
||||
last_send_at: None,
|
||||
@@ -694,9 +702,7 @@ impl App {
|
||||
if let Err(err) = crate::tui::onboarding::mark_onboarded() {
|
||||
self.status_message = Some(format!("Failed to mark onboarding: {err}"));
|
||||
}
|
||||
self.add_message(HistoryCell::System {
|
||||
content: format_welcome_banner(&self.model, &self.workspace, self.yolo),
|
||||
});
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: AppMode) -> bool {
|
||||
@@ -743,22 +749,20 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
/// Cycle through modes: Normal -> Agent -> YOLO -> Plan
|
||||
/// Cycle through modes: Plan -> Agent -> YOLO
|
||||
pub fn cycle_mode(&mut self) {
|
||||
let next = match self.mode {
|
||||
AppMode::Normal => AppMode::Agent,
|
||||
AppMode::Plan => AppMode::Agent,
|
||||
AppMode::Agent => AppMode::Yolo,
|
||||
AppMode::Yolo => AppMode::Plan,
|
||||
AppMode::Plan => AppMode::Normal,
|
||||
};
|
||||
let _ = self.set_mode(next);
|
||||
}
|
||||
|
||||
/// Cycle through modes in reverse: Plan -> YOLO -> Agent -> Normal
|
||||
/// Cycle through modes in reverse: YOLO -> Agent -> Plan
|
||||
pub fn cycle_mode_reverse(&mut self) {
|
||||
let next = match self.mode {
|
||||
AppMode::Normal => AppMode::Plan,
|
||||
AppMode::Agent => AppMode::Normal,
|
||||
AppMode::Agent => AppMode::Plan,
|
||||
AppMode::Yolo => AppMode::Agent,
|
||||
AppMode::Plan => AppMode::Yolo,
|
||||
};
|
||||
@@ -935,6 +939,9 @@ impl App {
|
||||
TranscriptRenderOptions {
|
||||
show_thinking: self.show_thinking,
|
||||
show_tool_details: self.show_tool_details,
|
||||
calm_mode: self.calm_mode,
|
||||
low_motion: self.low_motion,
|
||||
spacing: self.transcript_spacing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,24 +1190,6 @@ impl App {
|
||||
self.queued_messages.remove(index)
|
||||
}
|
||||
|
||||
pub fn queued_message_previews(&self, max: usize) -> Vec<String> {
|
||||
if max == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut previews: Vec<String> = self
|
||||
.queued_messages
|
||||
.iter()
|
||||
.take(max)
|
||||
.map(|msg| msg.display.clone())
|
||||
.collect();
|
||||
let extra = self.queued_messages.len().saturating_sub(previews.len());
|
||||
if extra > 0 {
|
||||
previews.push(format!("+{extra} more"));
|
||||
}
|
||||
previews
|
||||
}
|
||||
|
||||
pub fn queued_message_count(&self) -> usize {
|
||||
self.queued_messages.len()
|
||||
}
|
||||
@@ -1344,6 +1333,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_starts_without_seeded_transcript_messages() {
|
||||
let app = App::new(test_options(false), &Config::default());
|
||||
assert!(app.history.is_empty());
|
||||
assert_eq!(app.history_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_todos_resets_plan_state() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
@@ -1390,7 +1386,7 @@ mod tests {
|
||||
app.cycle_mode_reverse();
|
||||
assert_eq!(app.mode, AppMode::Yolo);
|
||||
|
||||
app.mode = AppMode::Normal;
|
||||
app.mode = AppMode::Agent;
|
||||
app.cycle_mode_reverse();
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,6 @@ fn command_runs_directly(name: &str) -> bool {
|
||||
| "export"
|
||||
| "config"
|
||||
| "yolo"
|
||||
| "normal"
|
||||
| "agent"
|
||||
| "plan"
|
||||
| "trust"
|
||||
|
||||
+566
-285
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Paragraph, Wrap},
|
||||
widgets::{Block, Borders, Padding, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::palette;
|
||||
@@ -21,8 +21,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
f.render_widget(block, area);
|
||||
|
||||
let content_width = 72.min(area.width.saturating_sub(4));
|
||||
let content_height = 26.min(area.height.saturating_sub(4));
|
||||
let content_width = 76.min(area.width.saturating_sub(4));
|
||||
let content_height = 20.min(area.height.saturating_sub(4));
|
||||
let content_area = Rect {
|
||||
x: (area.width - content_width) / 2,
|
||||
y: (area.height - content_height) / 2,
|
||||
@@ -40,18 +40,27 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
if !lines.is_empty() {
|
||||
let (step, total) = onboarding_step(app);
|
||||
let mut decorated = vec![
|
||||
Line::from(Span::styled(
|
||||
let panel = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
" DeepSeek TUI ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_BLUE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.title_bottom(Line::from(Span::styled(
|
||||
format!(" Step {step}/{total} "),
|
||||
Style::default()
|
||||
.fg(palette::TEXT_MUTED)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
];
|
||||
decorated.extend(lines);
|
||||
let paragraph = Paragraph::new(decorated).wrap(Wrap { trim: false });
|
||||
f.render_widget(paragraph, content_area);
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_SLATE))
|
||||
.padding(Padding::new(2, 2, 1, 1));
|
||||
let inner = panel.inner(content_area);
|
||||
f.render_widget(panel, content_area);
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
f.render_widget(paragraph, inner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,45 +97,23 @@ pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> {
|
||||
|
||||
vec![
|
||||
Line::from(Span::styled(
|
||||
"Start With These Workflows",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::raw(" 1. Pick a mode for the task:")),
|
||||
Line::from(Span::raw(
|
||||
" Normal asks questions, Agent runs tools, Plan lets you review the approach first.",
|
||||
)),
|
||||
Line::from(Span::raw(" 2. Watch the runtime state while work runs:")),
|
||||
Line::from(Span::raw(
|
||||
" approvals, queued prompts, and active sub-agents stay visible in the status area.",
|
||||
)),
|
||||
Line::from(Span::raw(
|
||||
" 3. Use /queue when you want to review or edit queued prompts.",
|
||||
)),
|
||||
Line::from(Span::raw(
|
||||
" 4. Use /subagents or the status strip to inspect agent fan-out.",
|
||||
)),
|
||||
Line::from(Span::raw(
|
||||
" 5. Use Ctrl+R or /sessions to resume interrupted work.",
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Controls",
|
||||
"Start Simple",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::raw(
|
||||
" - F1 help, Ctrl+K command palette, Esc cancel current work",
|
||||
"Write the task in plain language. Use /help or Ctrl+K when you want a command.",
|
||||
)),
|
||||
Line::from(Span::raw(
|
||||
" - Tab cycles modes, Alt+1/2/3/4 switches directly",
|
||||
"The bottom composer is multi-line: Enter sends, Alt+Enter or Ctrl+J adds a new line.",
|
||||
)),
|
||||
Line::from(Span::raw(
|
||||
" - Alt+!/@/#/$/) focuses Plan/Todos/Tasks/Agents/Auto",
|
||||
"Switch modes only when the job changes: Plan for review-first work, Agent for execution, YOLO when you want auto-approval.",
|
||||
)),
|
||||
Line::from(Span::raw(
|
||||
"Ctrl+R resumes earlier sessions, and Esc backs out of the current draft or overlay.",
|
||||
)),
|
||||
Line::from(vec![
|
||||
Span::styled("Press ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
@@ -137,7 +124,7 @@ pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
" to start working",
|
||||
" to open the workspace",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -5,66 +5,39 @@ use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::palette;
|
||||
|
||||
const LOGO: &str = r"
|
||||
██████╗ ███████╗███████╗██████╗ ███████╗███████╗███████╗██╗ ██╗
|
||||
██╔══██╗██╔════╝██╔════╝██╔══██╗██╔════╝██╔════╝██╔════╝██║ ██╔╝
|
||||
██║ ██║█████╗ █████╗ ██████╔╝███████╗█████╗ █████╗ █████╔╝
|
||||
██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ╚════██║██╔══╝ ██╔══╝ ██╔═██╗
|
||||
██████╔╝███████╗███████╗██║ ███████║███████╗███████╗██║ ██╗
|
||||
╚═════╝ ╚══════╝╚══════╝╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝
|
||||
";
|
||||
|
||||
pub fn lines() -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for (i, line) in LOGO.lines().enumerate() {
|
||||
let color = match i % 3 {
|
||||
0 => palette::DEEPSEEK_BLUE,
|
||||
1 => palette::DEEPSEEK_SKY,
|
||||
_ => palette::DEEPSEEK_RED,
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
line,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Welcome to ", Style::default().fg(palette::TEXT_PRIMARY)),
|
||||
Span::styled(
|
||||
vec![
|
||||
Line::from(Span::styled(
|
||||
"DeepSeek TUI",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_BLUE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(Span::styled(
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("Version {}", env!("CARGO_PKG_VERSION")),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Agent workflows for the DeepSeek API in your terminal",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Set up your key, pick a mode, and watch approvals, queue state, and agents as work runs.",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Not affiliated with DeepSeek Inc.",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Press Enter to start setup.",
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"A focused terminal workspace for longer model sessions.",
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"You'll add an API key, review trust for this directory, and then land in the chat.",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"The main composer is multi-line, so you can write full prompts instead of squeezing everything into one line.",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Press Enter to continue.",
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"Ctrl+C exits at any point.",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
|
||||
lines
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Cached transcript rendering for the TUI.
|
||||
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::app::TranscriptSpacing;
|
||||
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
|
||||
use crate::tui::scrolling::TranscriptLineMeta;
|
||||
|
||||
@@ -61,22 +60,14 @@ impl TranscriptViewCache {
|
||||
});
|
||||
}
|
||||
|
||||
if cell_index + 1 < cells.len()
|
||||
&& !cell.is_stream_continuation()
|
||||
&& cell.is_conversational()
|
||||
&& cells[cell_index + 1].is_conversational()
|
||||
{
|
||||
// Add subtle horizontal separator between messages
|
||||
let separator = Span::styled(
|
||||
"─".repeat(usize::from(width)),
|
||||
Style::default()
|
||||
.fg(palette::TEXT_MUTED)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
lines.push(Line::from(separator));
|
||||
if let Some(next_cell) = cells.get(cell_index + 1) {
|
||||
let spacer_rows = spacer_rows_between(cell, next_cell, options.spacing);
|
||||
for _ in 0..spacer_rows {
|
||||
lines.push(Line::from(""));
|
||||
meta.push(TranscriptLineMeta::Spacer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.lines = lines;
|
||||
self.line_meta = meta;
|
||||
@@ -100,3 +91,34 @@ impl TranscriptViewCache {
|
||||
self.lines.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn spacer_rows_between(
|
||||
current: &HistoryCell,
|
||||
next: &HistoryCell,
|
||||
spacing: TranscriptSpacing,
|
||||
) -> usize {
|
||||
if current.is_stream_continuation() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let conversational_gap = match spacing {
|
||||
TranscriptSpacing::Compact => 0,
|
||||
TranscriptSpacing::Comfortable => 1,
|
||||
TranscriptSpacing::Spacious => 2,
|
||||
};
|
||||
let secondary_gap = match spacing {
|
||||
TranscriptSpacing::Compact => 0,
|
||||
TranscriptSpacing::Comfortable => 1,
|
||||
TranscriptSpacing::Spacious => 1,
|
||||
};
|
||||
|
||||
if current.is_conversational() && next.is_conversational() {
|
||||
conversational_gap
|
||||
} else if matches!(current, HistoryCell::System { .. } | HistoryCell::Tool(_))
|
||||
|| matches!(next, HistoryCell::System { .. } | HistoryCell::Tool(_))
|
||||
{
|
||||
secondary_gap
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
+315
-400
@@ -84,27 +84,21 @@ use super::widgets::{
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const MAX_QUEUED_PREVIEW: usize = 3;
|
||||
const SLASH_MENU_LIMIT: usize = 6;
|
||||
const MIN_CHAT_HEIGHT: u16 = 3;
|
||||
const MIN_COMPOSER_HEIGHT: u16 = 1;
|
||||
const MIN_COMPOSER_HEIGHT: u16 = 3;
|
||||
const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
|
||||
const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
|
||||
const UI_IDLE_POLL_MS: u64 = 33;
|
||||
const UI_ACTIVE_POLL_MS: u64 = 16;
|
||||
const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 120;
|
||||
const UI_TYPING_INDICATOR_MS: u64 = 120;
|
||||
const UI_STATUS_ANIMATION_MS: u64 = UI_DEEPSEEK_SQUIGGLE_MS;
|
||||
const MAX_ACTIVE_AGENT_STATUS_ROWS: usize = 2;
|
||||
const UI_IDLE_POLL_MS: u64 = 48;
|
||||
const UI_ACTIVE_POLL_MS: u64 = 24;
|
||||
const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 320;
|
||||
const UI_STATUS_ANIMATION_MS: u64 = 360;
|
||||
const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15;
|
||||
const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct StatusLayoutPlan {
|
||||
status_height: u16,
|
||||
queued_preview: Vec<String>,
|
||||
queued_compacted: bool,
|
||||
compact_runtime_summary: bool,
|
||||
}
|
||||
|
||||
/// Run the interactive TUI event loop.
|
||||
@@ -830,8 +824,12 @@ async fn run_event_loop(
|
||||
|
||||
let has_running_agents = running_agent_count(app) > 0;
|
||||
if (app.is_loading || has_running_agents || app.is_compacting)
|
||||
&& last_status_frame.elapsed() >= Duration::from_millis(UI_STATUS_ANIMATION_MS)
|
||||
&& last_status_frame.elapsed()
|
||||
>= Duration::from_millis(status_animation_interval_ms(app))
|
||||
{
|
||||
if !app.low_motion && history_has_live_motion(&app.history) {
|
||||
app.mark_history_updated();
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
last_status_frame = Instant::now();
|
||||
}
|
||||
@@ -852,9 +850,9 @@ async fn run_event_loop(
|
||||
}
|
||||
|
||||
let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting {
|
||||
Duration::from_millis(UI_ACTIVE_POLL_MS)
|
||||
Duration::from_millis(active_poll_ms(app))
|
||||
} else {
|
||||
Duration::from_millis(UI_IDLE_POLL_MS)
|
||||
Duration::from_millis(idle_poll_ms(app))
|
||||
};
|
||||
if let Some(until_flush) = app.paste_burst.next_flush_delay(now) {
|
||||
poll_timeout = poll_timeout.min(until_flush);
|
||||
@@ -1097,7 +1095,7 @@ async fn run_event_loop(
|
||||
app.set_sidebar_focus(SidebarFocus::Plan);
|
||||
app.status_message = Some("Sidebar focus: plan".to_string());
|
||||
} else {
|
||||
app.set_mode(AppMode::Normal);
|
||||
app.set_mode(AppMode::Plan);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1120,12 +1118,7 @@ async fn run_event_loop(
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
app.set_sidebar_focus(SidebarFocus::Agents);
|
||||
app.status_message = Some("Sidebar focus: agents".to_string());
|
||||
} else {
|
||||
app.set_mode(AppMode::Plan);
|
||||
}
|
||||
apply_alt_4_shortcut(app, key.modifiers);
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char('!') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
@@ -1370,18 +1363,14 @@ async fn run_event_loop(
|
||||
}
|
||||
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let new_mode = match app.mode {
|
||||
AppMode::Agent => AppMode::Normal,
|
||||
_ => AppMode::Agent,
|
||||
AppMode::Plan => AppMode::Agent,
|
||||
_ => AppMode::Plan,
|
||||
};
|
||||
app.set_mode(new_mode);
|
||||
}
|
||||
KeyCode::Char('v') if is_paste_shortcut(&key) => {
|
||||
app.paste_from_clipboard();
|
||||
}
|
||||
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
app.set_mode(AppMode::Normal);
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
app.set_mode(AppMode::Agent);
|
||||
continue;
|
||||
@@ -1394,10 +1383,6 @@ async fn run_event_loop(
|
||||
app.set_mode(AppMode::Plan);
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char('N') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
app.set_mode(AppMode::Normal);
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
app.set_mode(AppMode::Agent);
|
||||
continue;
|
||||
@@ -1465,6 +1450,15 @@ fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn apply_alt_4_shortcut(app: &mut App, modifiers: KeyModifiers) {
|
||||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||||
app.set_sidebar_focus(SidebarFocus::Agents);
|
||||
app.status_message = Some("Sidebar focus: agents".to_string());
|
||||
} else {
|
||||
app.set_mode(AppMode::Plan);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste_burst_decision(
|
||||
app: &mut App,
|
||||
decision: CharDecision,
|
||||
@@ -2143,7 +2137,7 @@ fn plan_next_step_prompt() -> String {
|
||||
" 1) Accept + implement in Agent mode",
|
||||
" 2) Accept + implement in YOLO mode",
|
||||
" 3) Revise the plan / ask follow-ups",
|
||||
" 4) Exit Plan mode",
|
||||
" 4) Return to Agent mode without implementing",
|
||||
"",
|
||||
"Use the plan confirmation popup or type 1-4 and press Enter.",
|
||||
]
|
||||
@@ -2173,7 +2167,7 @@ fn parse_plan_choice(input: &str) -> Option<PlanChoice> {
|
||||
"accept" | "approve" | "agent" | "a" => Some(PlanChoice::AcceptAgent),
|
||||
"accept-yolo" | "yolo" | "y" => Some(PlanChoice::AcceptYolo),
|
||||
"revise" | "edit" | "plan" | "stay" => Some(PlanChoice::RevisePlan),
|
||||
"normal" | "exit" | "cancel" | "back" => Some(PlanChoice::ExitPlan),
|
||||
"exit" | "cancel" | "back" => Some(PlanChoice::ExitPlan),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2268,30 +2262,6 @@ fn status_row_budget(
|
||||
body_height.saturating_sub(composer_height.max(MIN_COMPOSER_HEIGHT) + chat_floor)
|
||||
}
|
||||
|
||||
fn compact_queued_preview(app: &App, preview_rows_budget: usize) -> (Vec<String>, bool) {
|
||||
if app.queued_message_count() == 0 || preview_rows_budget == 0 {
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
let preview_rows_budget = preview_rows_budget.min(MAX_QUEUED_PREVIEW);
|
||||
let queue_count = app.queued_message_count();
|
||||
let mut previews = app.queued_message_previews(preview_rows_budget);
|
||||
if previews.len() > preview_rows_budget {
|
||||
previews.truncate(preview_rows_budget);
|
||||
if let Some(last) = previews.last_mut() {
|
||||
let shown_count = preview_rows_budget.saturating_sub(1);
|
||||
let hidden_count = queue_count.saturating_sub(shown_count);
|
||||
*last = format!("+{hidden_count} more");
|
||||
}
|
||||
}
|
||||
|
||||
let shown_count = previews
|
||||
.iter()
|
||||
.filter(|line| !line.starts_with('+'))
|
||||
.count();
|
||||
(previews, queue_count > shown_count)
|
||||
}
|
||||
|
||||
fn running_agent_count(app: &App) -> usize {
|
||||
let mut ids: std::collections::HashSet<&str> =
|
||||
app.agent_progress.keys().map(String::as_str).collect();
|
||||
@@ -2370,98 +2340,28 @@ fn reconcile_subagent_activity_state(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_runtime_parts(app: &App) -> Vec<String> {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
let active_agents = running_agent_count(app);
|
||||
if active_agents > 0 {
|
||||
parts.push(format!(
|
||||
"{active_agents} agent{}",
|
||||
if active_agents == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
|
||||
let running_tasks = app
|
||||
.task_panel
|
||||
.iter()
|
||||
.filter(|task| task.status == "running")
|
||||
.count();
|
||||
if running_tasks > 0 {
|
||||
parts.push(format!(
|
||||
"{running_tasks} task{}",
|
||||
if running_tasks == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
|
||||
let queued = app.queued_message_count();
|
||||
if queued > 0 {
|
||||
parts.push(format!("{queued} queued"));
|
||||
}
|
||||
if app.queued_draft.is_some() {
|
||||
parts.push("editing queue".to_string());
|
||||
}
|
||||
|
||||
match app.view_stack.top_kind() {
|
||||
Some(ModalKind::Approval) => parts.push("approval open".to_string()),
|
||||
Some(ModalKind::Elevation) => parts.push("elevation open".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
parts
|
||||
}
|
||||
|
||||
fn compute_status_layout(
|
||||
app: &App,
|
||||
terminal_height: u16,
|
||||
terminal_width: u16,
|
||||
composer_height: u16,
|
||||
) -> StatusLayoutPlan {
|
||||
let status_budget = status_row_budget(terminal_height, 1, 1, composer_height);
|
||||
if status_budget == 0 {
|
||||
return StatusLayoutPlan {
|
||||
status_height: 0,
|
||||
queued_preview: Vec::new(),
|
||||
queued_compacted: app.queued_message_count() > 0,
|
||||
compact_runtime_summary: false,
|
||||
};
|
||||
return StatusLayoutPlan { status_height: 0 };
|
||||
}
|
||||
|
||||
let active_agents = running_agent_count(app);
|
||||
let compact_runtime_summary =
|
||||
terminal_width < SIDEBAR_VISIBLE_MIN_WIDTH && !compact_runtime_parts(app).is_empty();
|
||||
let fixed_rows = usize::from(app.is_loading || app.is_compacting)
|
||||
+ usize::from(compact_runtime_summary)
|
||||
let active_details = usize::from(app.is_loading || app.is_compacting)
|
||||
+ usize::from(app.queued_draft.is_some())
|
||||
+ usize::from(active_agents > 0);
|
||||
let queue_rows_budget = usize::from(status_budget).saturating_sub(fixed_rows);
|
||||
|
||||
let (queued_preview, preview_compacted) = if queue_rows_budget > 0 {
|
||||
compact_queued_preview(app, queue_rows_budget.saturating_sub(1))
|
||||
} else {
|
||||
(Vec::new(), app.queued_message_count() > 0)
|
||||
};
|
||||
|
||||
let queue_rows = if app.queued_message_count() > 0 && queue_rows_budget > 0 {
|
||||
1 + queued_preview.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut requested_rows = fixed_rows + queue_rows;
|
||||
if active_agents > 0 {
|
||||
let detail_rows_budget = usize::from(status_budget).saturating_sub(requested_rows);
|
||||
let detail_rows = detail_rows_budget.min(active_agents.min(MAX_ACTIVE_AGENT_STATUS_ROWS));
|
||||
requested_rows += detail_rows;
|
||||
}
|
||||
+ usize::from(running_agent_count(app) > 0)
|
||||
+ usize::from(matches!(
|
||||
app.view_stack.top_kind(),
|
||||
Some(ModalKind::Approval | ModalKind::Elevation)
|
||||
));
|
||||
let requested_rows = 1 + active_details.min(2);
|
||||
let status_height =
|
||||
u16::try_from(requested_rows.min(usize::from(status_budget))).unwrap_or(status_budget);
|
||||
let queued_compacted = preview_compacted || (app.queued_message_count() > 0 && queue_rows == 0);
|
||||
|
||||
StatusLayoutPlan {
|
||||
status_height,
|
||||
queued_preview,
|
||||
queued_compacted,
|
||||
compact_runtime_summary,
|
||||
}
|
||||
StatusLayoutPlan { status_height }
|
||||
}
|
||||
|
||||
fn render(f: &mut Frame, app: &mut App) {
|
||||
@@ -2480,23 +2380,20 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
let header_height = 1;
|
||||
let footer_height = 1;
|
||||
let body_height = size.height.saturating_sub(header_height + footer_height);
|
||||
let prompt = prompt_for_mode(app.mode);
|
||||
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
|
||||
let composer_for_budget = {
|
||||
let max_composer_height = body_height
|
||||
.saturating_sub(chat_height_floor(body_height))
|
||||
.max(MIN_COMPOSER_HEIGHT);
|
||||
let composer_widget =
|
||||
ComposerWidget::new(app, prompt, max_composer_height, &slash_menu_entries);
|
||||
let composer_widget = ComposerWidget::new(app, max_composer_height, &slash_menu_entries);
|
||||
composer_widget.desired_height(size.width)
|
||||
};
|
||||
let status_layout = compute_status_layout(app, size.height, size.width, composer_for_budget);
|
||||
let status_layout = compute_status_layout(app, size.height, composer_for_budget);
|
||||
let composer_max_height = body_height
|
||||
.saturating_sub(status_layout.status_height + chat_height_floor(body_height))
|
||||
.max(MIN_COMPOSER_HEIGHT);
|
||||
let composer_height = {
|
||||
let composer_widget =
|
||||
ComposerWidget::new(app, prompt, composer_max_height, &slash_menu_entries);
|
||||
let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries);
|
||||
composer_widget.desired_height(size.width)
|
||||
};
|
||||
|
||||
@@ -2514,8 +2411,19 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
// Render header
|
||||
{
|
||||
let context_window = crate::models::context_window_for_model(&app.model);
|
||||
let header_data =
|
||||
HeaderData::new(app.mode, &app.model, app.is_loading, app.ui_theme.header_bg)
|
||||
let workspace_name = app
|
||||
.workspace
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("workspace");
|
||||
let header_data = HeaderData::new(
|
||||
app.mode,
|
||||
&app.model,
|
||||
workspace_name,
|
||||
app.is_loading,
|
||||
app.ui_theme.header_bg,
|
||||
)
|
||||
.with_usage(
|
||||
app.total_conversation_tokens,
|
||||
context_window,
|
||||
@@ -2560,21 +2468,12 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
|
||||
// Render status
|
||||
if status_layout.status_height > 0 {
|
||||
render_status_indicator(
|
||||
f,
|
||||
chunks[2],
|
||||
app,
|
||||
app.queued_message_count(),
|
||||
&status_layout.queued_preview,
|
||||
status_layout.queued_compacted,
|
||||
status_layout.compact_runtime_summary,
|
||||
);
|
||||
render_status_indicator(f, chunks[2], app);
|
||||
}
|
||||
|
||||
// Render composer
|
||||
let cursor_pos = {
|
||||
let composer_widget =
|
||||
ComposerWidget::new(app, prompt, composer_max_height, &slash_menu_entries);
|
||||
let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries);
|
||||
let buf = f.buffer_mut();
|
||||
composer_widget.render(chunks[3], buf);
|
||||
composer_widget.cursor_pos(chunks[3])
|
||||
@@ -2861,7 +2760,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
if app.subagent_cache.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"No sub-agents",
|
||||
"No agents",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
} else {
|
||||
@@ -2923,7 +2822,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
}
|
||||
|
||||
render_sidebar_section(f, area, "Sub-Agents", lines);
|
||||
render_sidebar_section(f, area, "Agents", lines);
|
||||
}
|
||||
|
||||
fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec<Line<'static>>) {
|
||||
@@ -3334,198 +3233,189 @@ fn resume_terminal(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_status_indicator(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &App,
|
||||
queued_count: usize,
|
||||
queued: &[String],
|
||||
queued_compacted: bool,
|
||||
compact_runtime_summary: bool,
|
||||
) {
|
||||
let max_rows = usize::from(area.height);
|
||||
if max_rows == 0 {
|
||||
fn render_status_indicator(f: &mut Frame, area: Rect, app: &App) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
let mut lines = Vec::with_capacity(
|
||||
2 + queued.len() + usize::from(app.queued_draft.is_some()) + MAX_ACTIVE_AGENT_STATUS_ROWS,
|
||||
);
|
||||
|
||||
if app.is_loading {
|
||||
let header = if app.show_thinking {
|
||||
app.reasoning_header.clone()
|
||||
let mut lines = vec![status_summary_line(app, area.width)];
|
||||
let detail_budget = usize::from(area.height.saturating_sub(1));
|
||||
lines.extend(status_detail_lines(app, area.width, detail_budget));
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn approval_mode_summary(app: &App) -> &'static str {
|
||||
match app.approval_mode {
|
||||
ApprovalMode::Auto => "auto",
|
||||
ApprovalMode::Suggest => "review",
|
||||
ApprovalMode::Never => "off",
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_short_name(app: &App) -> String {
|
||||
app.workspace
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("workspace")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn current_run_state(app: &App) -> (&'static str, ratatui::style::Color) {
|
||||
if app.is_compacting {
|
||||
("Compacting", palette::STATUS_WARNING)
|
||||
} else if app.is_loading {
|
||||
("Working", palette::DEEPSEEK_SKY)
|
||||
} else if running_agent_count(app) > 0 {
|
||||
("Agents active", palette::DEEPSEEK_SKY)
|
||||
} else if app.queued_draft.is_some() {
|
||||
("Editing queue", palette::STATUS_WARNING)
|
||||
} else {
|
||||
None
|
||||
("Ready", palette::TEXT_MUTED)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_summary_line(app: &App, width: u16) -> Line<'static> {
|
||||
let queue = app.queued_message_count();
|
||||
let running_tasks = app
|
||||
.task_panel
|
||||
.iter()
|
||||
.filter(|task| task.status == "running")
|
||||
.count();
|
||||
let active_agents = running_agent_count(app);
|
||||
let (state, state_color) = current_run_state(app);
|
||||
let mut parts = vec![workspace_short_name(app)];
|
||||
if queue > 0 {
|
||||
parts.push(format!("queue {queue}"));
|
||||
}
|
||||
if !matches!(app.approval_mode, ApprovalMode::Suggest) {
|
||||
parts.push(format!("approvals {}", approval_mode_summary(app)));
|
||||
}
|
||||
if running_tasks > 0 {
|
||||
parts.push(format!(
|
||||
"{} task{}",
|
||||
running_tasks,
|
||||
if running_tasks == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if active_agents > 0 {
|
||||
parts.push(format!(
|
||||
"{} agent{}",
|
||||
active_agents,
|
||||
if active_agents == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if width >= 100
|
||||
&& let Some(workspace_context) = app.workspace_context.as_ref()
|
||||
{
|
||||
parts.push(workspace_context.to_string());
|
||||
}
|
||||
let text = parts.join(" · ");
|
||||
|
||||
let available_width = if state == "Ready" {
|
||||
usize::from(width)
|
||||
} else {
|
||||
usize::from(width).saturating_sub(state.len() + 3)
|
||||
};
|
||||
let elapsed = app.turn_started_at.map(format_elapsed);
|
||||
// Distinguish thinking streaming from answer streaming.
|
||||
let is_thinking_streaming = app.streaming_message_index.is_some_and(|idx| {
|
||||
matches!(
|
||||
app.history.get(idx),
|
||||
Some(HistoryCell::Thinking {
|
||||
streaming: true,
|
||||
..
|
||||
})
|
||||
)
|
||||
});
|
||||
let is_answer_streaming = app.streaming_message_index.is_some() && !is_thinking_streaming;
|
||||
let spinner = if is_answer_streaming {
|
||||
typing_indicator(app.turn_started_at)
|
||||
let mut spans = vec![Span::styled(
|
||||
truncate_line_to_width(&text, available_width.max(1)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)];
|
||||
if state != "Ready" {
|
||||
spans.push(Span::styled(
|
||||
" · ",
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
state.to_string(),
|
||||
Style::default().fg(state_color),
|
||||
));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn status_detail_lines(app: &App, width: u16, budget: usize) -> Vec<Line<'static>> {
|
||||
if budget == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if app.is_loading && lines.len() < budget {
|
||||
let header = app
|
||||
.reasoning_header
|
||||
.as_deref()
|
||||
.filter(|header| !header.trim().is_empty())
|
||||
.unwrap_or("streaming response");
|
||||
let spinner = if app.low_motion {
|
||||
"·"
|
||||
} else {
|
||||
deepseek_squiggle(app.turn_started_at)
|
||||
};
|
||||
let (label, label_color) = if is_answer_streaming {
|
||||
("ANSWER", palette::DEEPSEEK_SKY)
|
||||
} else if app.show_thinking || is_thinking_streaming {
|
||||
("THINKING", palette::STATUS_WARNING)
|
||||
let elapsed = app.turn_started_at.map(format_elapsed).unwrap_or_default();
|
||||
let detail = if elapsed.is_empty() {
|
||||
format!("{spinner} {header} · Esc interrupts")
|
||||
} else {
|
||||
("WORKING", palette::STATUS_WARNING)
|
||||
format!("{spinner} {header} · {elapsed} · Esc interrupts")
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled(label, Style::default().fg(label_color).bold()),
|
||||
];
|
||||
if !is_answer_streaming && let Some(header) = header {
|
||||
spans.push(Span::raw(": "));
|
||||
spans.push(Span::styled(
|
||||
header,
|
||||
Style::default().fg(palette::STATUS_WARNING),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(elapsed) = elapsed {
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(
|
||||
elapsed,
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
));
|
||||
}
|
||||
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(
|
||||
"Esc/Ctrl+C to interrupt",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
));
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
} else if app.is_compacting {
|
||||
let spinner = deepseek_squiggle(None);
|
||||
let spans = vec![
|
||||
Span::styled(spinner, Style::default().fg(palette::STATUS_WARNING).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"COMPACTING",
|
||||
Style::default().fg(palette::STATUS_WARNING).bold(),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"summarizing context...",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
];
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
if compact_runtime_summary && lines.len() < max_rows {
|
||||
let summary = compact_runtime_parts(app).join(" | ");
|
||||
if !summary.is_empty() {
|
||||
let available = area.width as usize;
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("STATE ", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled(
|
||||
truncate_line_to_width(&summary, available.saturating_sub(6).max(1)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
let active_agent_total = running_agent_count(app);
|
||||
if active_agent_total > 0 && lines.len() < max_rows {
|
||||
let available = area.width as usize;
|
||||
let spinner_start = app.agent_activity_started_at.or(app.turn_started_at);
|
||||
let spinner = deepseek_squiggle(spinner_start);
|
||||
let header = format!("AGENTS {active_agent_total} running | /agents");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
truncate_line_to_width(&header, available.saturating_sub(2).max(1)),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
),
|
||||
]));
|
||||
|
||||
let preview_limit = max_rows
|
||||
.saturating_sub(lines.len())
|
||||
.min(MAX_ACTIVE_AGENT_STATUS_ROWS);
|
||||
let active_rows = active_agent_rows(app, preview_limit);
|
||||
for (id, status) in &active_rows {
|
||||
if lines.len() >= max_rows {
|
||||
break;
|
||||
}
|
||||
let id_short = truncate_line_to_width(id, 12);
|
||||
let status_single_line = status.lines().next().unwrap_or(status.as_str());
|
||||
let prefix = format!(" {id_short}: ");
|
||||
let detail_width = available.saturating_sub(prefix.width()).max(1);
|
||||
let detail = truncate_line_to_width(status_single_line, detail_width);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(detail, Style::default().fg(palette::TEXT_DIM)),
|
||||
]));
|
||||
}
|
||||
|
||||
let hidden = active_agent_total.saturating_sub(active_rows.len());
|
||||
if hidden > 0 && lines.len() < max_rows {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" +{hidden} more running"),
|
||||
truncate_line_to_width(&detail, usize::from(width)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
if app.is_compacting && lines.len() < budget {
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
"Compacting context · summarizing older turns · Esc interrupts",
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(draft) = app.queued_draft.as_ref()
|
||||
&& lines.len() < budget
|
||||
{
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
&format!("Editing queued draft · {}", draft.display),
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
if running_agent_count(app) > 0 && lines.len() < budget {
|
||||
let active_rows = active_agent_rows(app, 1);
|
||||
if let Some((id, status)) = active_rows.first() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
&format!("Agent {id} · {}", status.lines().next().unwrap_or(status)),
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(draft) = app.queued_draft.as_ref() {
|
||||
let available = area.width as usize;
|
||||
let prefix = "Editing queued:";
|
||||
let prefix_width = prefix.width() + 1;
|
||||
let max_len = available.saturating_sub(prefix_width).max(1);
|
||||
let preview = truncate_line_to_width(&draft.display, max_len);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled(preview, Style::default().fg(palette::DEEPSEEK_SKY)),
|
||||
]));
|
||||
}
|
||||
|
||||
if queued_count > 0 {
|
||||
let available = area.width as usize;
|
||||
let header = if queued_compacted {
|
||||
format!("Queued ({queued_count}) [compact] - /queue edit <n>")
|
||||
} else {
|
||||
format!("Queued ({queued_count}) - /queue edit <n>")
|
||||
};
|
||||
let header = truncate_line_to_width(&header, available.max(1));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
header,
|
||||
if matches!(
|
||||
app.view_stack.top_kind(),
|
||||
Some(ModalKind::Approval | ModalKind::Elevation)
|
||||
) && lines.len() < budget
|
||||
{
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
"Review open request · Esc closes the overlay",
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)]));
|
||||
|
||||
for (idx, message) in queued.iter().enumerate() {
|
||||
let label = if message.starts_with('+') {
|
||||
message.to_string()
|
||||
} else {
|
||||
format!("{}. {message}", idx + 1)
|
||||
};
|
||||
let preview = truncate_line_to_width(&label, available.max(1));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
preview,
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
)]));
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
f.render_widget(paragraph, area);
|
||||
lines
|
||||
}
|
||||
|
||||
fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
|
||||
@@ -3591,47 +3481,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
Style::default().fg(status_color(toast.level)),
|
||||
)]
|
||||
} else {
|
||||
// Kimi-style: "00:07 yolo agent (model, thinking)"
|
||||
let mut spans = Vec::new();
|
||||
|
||||
// Elapsed time HH:MM
|
||||
let elapsed = app.session_start.elapsed();
|
||||
let total_secs = elapsed.as_secs();
|
||||
let hours = total_secs / 3600;
|
||||
let mins = (total_secs % 3600) / 60;
|
||||
spans.push(Span::styled(
|
||||
format!("{hours:02}:{mins:02}"),
|
||||
let hint = footer_hint_text(app);
|
||||
vec![Span::styled(
|
||||
hint,
|
||||
Style::default().fg(palette::FOOTER_HINT),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
// Mode (lowercase, colored)
|
||||
let (mode_label, mode_color) = footer_mode_style(app);
|
||||
spans.push(Span::styled(
|
||||
mode_label.to_string(),
|
||||
Style::default().fg(mode_color),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
// "agent" label + model name + status in parens
|
||||
let model_short = app.model.rsplit('/').next().unwrap_or(&app.model);
|
||||
let status = if app.is_compacting {
|
||||
", compacting"
|
||||
} else if app.is_loading {
|
||||
", thinking"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
"agent ",
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!("({model_short}{status})"),
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
));
|
||||
|
||||
spans
|
||||
)]
|
||||
};
|
||||
|
||||
let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum();
|
||||
@@ -3645,10 +3499,32 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
f.render_widget(footer, area);
|
||||
}
|
||||
|
||||
fn footer_hint_text(app: &App) -> String {
|
||||
let slash_menu_open = !visible_slash_menu_entries(app, 1).is_empty();
|
||||
if !app.view_stack.is_empty() {
|
||||
return "Esc close overlay".to_string();
|
||||
}
|
||||
if app.is_loading || app.is_compacting {
|
||||
return "Esc interrupt".to_string();
|
||||
}
|
||||
if slash_menu_open {
|
||||
return "Up/Down move · Tab accept".to_string();
|
||||
}
|
||||
if !app.input.is_empty() {
|
||||
if app.input.contains('\n') {
|
||||
return "Enter send · Esc clear".to_string();
|
||||
}
|
||||
return "Enter send · Alt+Enter newline".to_string();
|
||||
}
|
||||
if app.input_history.is_empty() {
|
||||
return "Ctrl+K commands · F1 help".to_string();
|
||||
}
|
||||
"Ctrl+K commands".to_string()
|
||||
}
|
||||
|
||||
fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) {
|
||||
let label = app.mode.as_setting();
|
||||
let color = match app.mode {
|
||||
crate::tui::app::AppMode::Normal => palette::MODE_NORMAL,
|
||||
crate::tui::app::AppMode::Agent => palette::MODE_AGENT,
|
||||
crate::tui::app::AppMode::Yolo => palette::MODE_YOLO,
|
||||
crate::tui::app::AppMode::Plan => palette::MODE_PLAN,
|
||||
@@ -3824,15 +3700,6 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool
|
||||
false
|
||||
}
|
||||
|
||||
fn prompt_for_mode(mode: AppMode) -> &'static str {
|
||||
match mode {
|
||||
AppMode::Normal => "> ",
|
||||
AppMode::Agent => "agent> ",
|
||||
AppMode::Yolo => "yolo> ",
|
||||
AppMode::Plan => "plan> ",
|
||||
}
|
||||
}
|
||||
|
||||
fn estimated_context_tokens(app: &App) -> Option<i64> {
|
||||
i64::try_from(estimate_input_tokens_conservative(
|
||||
&app.api_messages,
|
||||
@@ -3917,20 +3784,52 @@ fn format_elapsed(start: Instant) -> String {
|
||||
}
|
||||
|
||||
fn deepseek_squiggle(start: Option<Instant>) -> &'static str {
|
||||
const FRAMES: [&str; 6] = ["◍", "◉", "◌", "◌", "◉", "◍"];
|
||||
const FRAMES: [&str; 4] = ["·", "◦", "•", "◦"];
|
||||
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
||||
let idx = ((elapsed_ms / u128::from(UI_DEEPSEEK_SQUIGGLE_MS)) as usize) % FRAMES.len();
|
||||
FRAMES[idx]
|
||||
}
|
||||
|
||||
/// Braille spinner frames — a prominent rotating circle pattern.
|
||||
const TYPING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
fn status_animation_interval_ms(app: &App) -> u64 {
|
||||
if app.low_motion {
|
||||
2_400
|
||||
} else {
|
||||
UI_STATUS_ANIMATION_MS
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the typing indicator frame based on elapsed time.
|
||||
fn typing_indicator(start: Option<Instant>) -> &'static str {
|
||||
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
||||
let idx = ((elapsed_ms / u128::from(UI_TYPING_INDICATOR_MS)) as usize) % TYPING_FRAMES.len();
|
||||
TYPING_FRAMES[idx]
|
||||
fn active_poll_ms(app: &App) -> u64 {
|
||||
if app.low_motion {
|
||||
96
|
||||
} else {
|
||||
UI_ACTIVE_POLL_MS
|
||||
}
|
||||
}
|
||||
|
||||
fn idle_poll_ms(app: &App) -> u64 {
|
||||
if app.low_motion { 120 } else { UI_IDLE_POLL_MS }
|
||||
}
|
||||
|
||||
fn history_has_live_motion(history: &[HistoryCell]) -> bool {
|
||||
history.iter().any(|cell| match cell {
|
||||
HistoryCell::Thinking { streaming, .. } => *streaming,
|
||||
HistoryCell::Tool(tool) => match tool {
|
||||
ToolCell::Exec(cell) => cell.status == ToolStatus::Running,
|
||||
ToolCell::Exploring(cell) => cell
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.status == ToolStatus::Running),
|
||||
ToolCell::PlanUpdate(cell) => cell.status == ToolStatus::Running,
|
||||
ToolCell::PatchSummary(cell) => cell.status == ToolStatus::Running,
|
||||
ToolCell::Review(cell) => cell.status == ToolStatus::Running,
|
||||
ToolCell::DiffPreview(_) => false,
|
||||
ToolCell::Mcp(cell) => cell.status == ToolStatus::Running,
|
||||
ToolCell::ViewImage(_) => false,
|
||||
ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running,
|
||||
ToolCell::Generic(cell) => cell.status == ToolStatus::Running,
|
||||
},
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate_line_to_width(text: &str, max_width: usize) -> String {
|
||||
@@ -4140,23 +4039,15 @@ fn open_tool_details_pager(app: &mut App) -> bool {
|
||||
.and_then(|meta| meta.cell_line())
|
||||
.map(|(cell_index, _)| cell_index)
|
||||
} else {
|
||||
app.history
|
||||
.len()
|
||||
.checked_sub(1)
|
||||
.filter(|idx| app.tool_details_by_cell.contains_key(idx))
|
||||
.or_else(|| app.tool_details_by_cell.keys().copied().max())
|
||||
app.history.len().checked_sub(1)
|
||||
};
|
||||
|
||||
let Some(cell_index) = target_cell else {
|
||||
return false;
|
||||
};
|
||||
let Some(detail) = app.tool_details_by_cell.get(&cell_index) else {
|
||||
app.status_message = Some("No tool details for selected line".to_string());
|
||||
return false;
|
||||
};
|
||||
|
||||
let input =
|
||||
serde_json::to_string_pretty(&detail.input).unwrap_or_else(|_| detail.input.to_string());
|
||||
if let Some(detail) = app.tool_details_by_cell.get(&cell_index) {
|
||||
let input = serde_json::to_string_pretty(&detail.input)
|
||||
.unwrap_or_else(|_| detail.input.to_string());
|
||||
let output = detail.output.as_deref().map_or(
|
||||
"(not available)".to_string(),
|
||||
std::string::ToString::to_string,
|
||||
@@ -4175,6 +4066,30 @@ fn open_tool_details_pager(app: &mut App) -> bool {
|
||||
&content,
|
||||
width.saturating_sub(2),
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(cell) = app.history.get(cell_index) else {
|
||||
app.status_message = Some("No details available for the selected line".to_string());
|
||||
return false;
|
||||
};
|
||||
let title = match cell {
|
||||
HistoryCell::User { .. } => "You".to_string(),
|
||||
HistoryCell::Assistant { .. } => "Assistant".to_string(),
|
||||
HistoryCell::System { .. } => "Note".to_string(),
|
||||
HistoryCell::Thinking { .. } => "Reasoning".to_string(),
|
||||
HistoryCell::Tool(_) => "Message".to_string(),
|
||||
};
|
||||
let width = app
|
||||
.last_transcript_area
|
||||
.map(|area| area.width)
|
||||
.unwrap_or(80);
|
||||
let content = history_cell_to_text(cell, width);
|
||||
app.view_stack.push(PagerView::from_text(
|
||||
title,
|
||||
&content,
|
||||
width.saturating_sub(2),
|
||||
));
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,29 @@ fn create_test_app() -> App {
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_4_switches_to_plan_mode() {
|
||||
let mut app = create_test_app();
|
||||
app.mode = AppMode::Agent;
|
||||
|
||||
apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);
|
||||
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {
|
||||
let mut app = create_test_app();
|
||||
app.mode = AppMode::Agent;
|
||||
app.sidebar_focus = SidebarFocus::Auto;
|
||||
|
||||
apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);
|
||||
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
assert_eq!(app.sidebar_focus, SidebarFocus::Agents);
|
||||
assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents"));
|
||||
}
|
||||
|
||||
fn make_subagent(
|
||||
id: &str,
|
||||
status: crate::tools::subagent::SubAgentStatus,
|
||||
@@ -166,34 +189,35 @@ fn running_agent_count_unions_cache_and_progress() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_status_layout_reserves_rows_for_active_agents() {
|
||||
fn compute_status_layout_reserves_extra_rows_for_active_state() {
|
||||
let app = create_test_app();
|
||||
let baseline = compute_status_layout(&app, 30, 120, 3);
|
||||
assert_eq!(baseline.status_height, 0);
|
||||
let baseline = compute_status_layout(&app, 30, 3);
|
||||
assert_eq!(baseline.status_height, 1);
|
||||
|
||||
let mut with_agents = create_test_app();
|
||||
with_agents
|
||||
.agent_progress
|
||||
.insert("agent_a".to_string(), "running".to_string());
|
||||
let active = compute_status_layout(&with_agents, 30, 120, 3);
|
||||
assert!(active.status_height >= 1);
|
||||
let active = compute_status_layout(&with_agents, 30, 3);
|
||||
assert!(active.status_height > baseline.status_height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_layout_adds_compact_runtime_summary_without_sidebar() {
|
||||
fn status_summary_line_mentions_queue_and_approval_mode() {
|
||||
let mut app = create_test_app();
|
||||
app.agent_progress
|
||||
.insert("agent_a".to_string(), "running".to_string());
|
||||
app.approval_mode = crate::tui::approval::ApprovalMode::Auto;
|
||||
app.queue_message(crate::tui::app::QueuedMessage::new(
|
||||
"queued message".to_string(),
|
||||
None,
|
||||
));
|
||||
|
||||
let narrow = compute_status_layout(&app, 30, 80, 3);
|
||||
let wide = compute_status_layout(&app, 30, 120, 3);
|
||||
|
||||
assert!(narrow.compact_runtime_summary);
|
||||
assert!(!wide.compact_runtime_summary);
|
||||
let summary = status_summary_line(&app, 120);
|
||||
let summary_text = summary
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert!(summary_text.contains("queue 1"));
|
||||
assert!(summary_text.contains("approvals auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -476,10 +500,8 @@ fn status_layout_budget_preserves_chat_and_composer_on_tiny_heights() {
|
||||
));
|
||||
}
|
||||
|
||||
let layout = compute_status_layout(&app, 9, 80, 3);
|
||||
let layout = compute_status_layout(&app, 9, 3);
|
||||
assert_eq!(layout.status_height, 1);
|
||||
assert!(layout.queued_preview.is_empty());
|
||||
assert!(layout.queued_compacted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -507,25 +529,20 @@ fn api_key_validation_warns_without_blocking_unusual_formats() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_queued_preview_summarizes_hidden_messages() {
|
||||
fn status_detail_lines_show_queue_draft_when_editing() {
|
||||
let mut app = create_test_app();
|
||||
for idx in 0..4 {
|
||||
app.queue_message(crate::tui::app::QueuedMessage::new(
|
||||
format!("queued message {idx}"),
|
||||
app.queued_draft = Some(crate::tui::app::QueuedMessage::new(
|
||||
"refine the queued prompt".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let (one_row, compacted_one_row) = compact_queued_preview(&app, 1);
|
||||
assert_eq!(one_row, vec!["+4 more".to_string()]);
|
||||
assert!(compacted_one_row);
|
||||
|
||||
let (two_rows, compacted_two_rows) = compact_queued_preview(&app, 2);
|
||||
assert_eq!(
|
||||
two_rows,
|
||||
vec!["queued message 0".to_string(), "+3 more".to_string()]
|
||||
);
|
||||
assert!(compacted_two_rows);
|
||||
let details = status_detail_lines(&app, 120, 2);
|
||||
assert!(!details.is_empty());
|
||||
let text = details[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert!(text.contains("Editing queued draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -199,7 +199,7 @@ const HELP_COMMAND_SECTION_ORDER: [&str; 7] = [
|
||||
fn help_command_section(name: &str) -> &'static str {
|
||||
match name {
|
||||
"help" | "clear" | "exit" | "model" | "models" | "home" | "links" => "Core",
|
||||
"normal" | "agent" | "yolo" | "plan" | "trust" | "logout" => "Modes",
|
||||
"agent" | "yolo" | "plan" | "trust" | "logout" => "Modes",
|
||||
"save" | "sessions" | "load" | "export" | "compact" | "queue" => "Session",
|
||||
"config" | "settings" => "Configuration",
|
||||
"task" | "skills" | "skill" | "subagents" | "review" => "Workflows",
|
||||
@@ -303,6 +303,18 @@ impl ConfigView {
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "calm_mode".to_string(),
|
||||
value: settings.calm_mode.to_string(),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "low_motion".to_string(),
|
||||
value: settings.low_motion.to_string(),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "show_thinking".to_string(),
|
||||
value: settings.show_thinking.to_string(),
|
||||
@@ -315,6 +327,18 @@ impl ConfigView {
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "composer_density".to_string(),
|
||||
value: settings.composer_density.clone(),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "transcript_spacing".to_string(),
|
||||
value: settings.transcript_spacing.clone(),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "default_mode".to_string(),
|
||||
value: settings.default_mode.clone(),
|
||||
@@ -553,8 +577,11 @@ fn config_hint_for_key(key: &str) -> &'static str {
|
||||
"deepseek-chat | deepseek-reasoner | deepseek-* (aliases: deepseek-v3, deepseek-v3.2, deepseek-r1)"
|
||||
}
|
||||
"approval_mode" => "auto | suggest | never",
|
||||
"auto_compact" | "show_thinking" | "show_tool_details" => "on/off, true/false, yes/no, 1/0",
|
||||
"default_mode" => "normal | agent | plan | yolo",
|
||||
"auto_compact" | "calm_mode" | "low_motion" | "show_thinking" | "show_tool_details" => {
|
||||
"on/off, true/false, yes/no, 1/0"
|
||||
}
|
||||
"composer_density" | "transcript_spacing" => "compact | comfortable | spacious",
|
||||
"default_mode" => "agent | plan | yolo",
|
||||
"theme" => "default | dark | light | whale",
|
||||
"sidebar_width" => "10..=50",
|
||||
"sidebar_focus" => "auto | plan | todos | tasks | agents",
|
||||
@@ -968,19 +995,19 @@ impl ModalView for HelpView {
|
||||
Line::from(" Left / Right - Move cursor"),
|
||||
Line::from(" Ctrl+A / Ctrl+E - Jump to start / end of line"),
|
||||
Line::from(" Backspace / Delete - Delete character before / after cursor"),
|
||||
Line::from(" Ctrl+U - Clear entire input line"),
|
||||
Line::from(" Ctrl+U - Clear the current draft"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Multi-line Input ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Ctrl+J / Alt+Enter - Insert newline (without submitting)"),
|
||||
Line::from(" Ctrl+J / Alt+Enter - Insert a new line in the composer"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Actions ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Enter - Submit message"),
|
||||
Line::from(" Enter - Send the current draft"),
|
||||
Line::from(
|
||||
" Esc - Close menu, cancel request, discard draft, or clear input",
|
||||
),
|
||||
@@ -988,7 +1015,7 @@ impl ModalView for HelpView {
|
||||
Line::from(" Ctrl+D - Exit when input is empty"),
|
||||
Line::from(" Ctrl+K - Open command palette"),
|
||||
Line::from(" l - Open pager for last message (when input empty)"),
|
||||
Line::from(" v - Open tool details (when input empty)"),
|
||||
Line::from(" v - Open details for the selected tool or message"),
|
||||
Line::from(" Enter (selection) - Open pager for selected text"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
@@ -996,11 +1023,11 @@ impl ModalView for HelpView {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Tab / Shift+Tab - Complete /command or cycle modes"),
|
||||
Line::from(" Alt+1/2/3/4 - Directly jump to Normal/Agent/YOLO/Plan"),
|
||||
Line::from(" Alt+N/A/Y/P - Alternative jump to Normal/Agent/YOLO/Plan"),
|
||||
Line::from(" Alt+1/2/3 - Directly jump to Plan/Agent/YOLO"),
|
||||
Line::from(" Alt+P/A/Y - Alternative jump to Plan/Agent/YOLO"),
|
||||
Line::from(" Alt+!/@/#/$/+) - Focus Plan/Todos/Tasks/Agents/Auto sidebar"),
|
||||
Line::from(" /normal /agent /yolo /plan - Set mode directly"),
|
||||
Line::from(" Ctrl+X - Toggle between Agent and Normal modes"),
|
||||
Line::from(" /agent /yolo /plan - Set mode directly"),
|
||||
Line::from(" Ctrl+X - Toggle between Plan and Agent modes"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Sessions ===",
|
||||
@@ -1032,7 +1059,7 @@ impl ModalView for HelpView {
|
||||
"Mode Cycle:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Normal → Agent → YOLO → Plan (Tab), reverse with Shift+Tab"),
|
||||
Line::from(" Plan → Agent → YOLO (Tab), reverse with Shift+Tab"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Commands:",
|
||||
@@ -1178,7 +1205,7 @@ impl ModalView for SubAgentsView {
|
||||
|
||||
if self.agents.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"No sub-agents running.",
|
||||
"No agents running.",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
} else {
|
||||
|
||||
@@ -17,6 +17,7 @@ use super::Renderable;
|
||||
/// Data required to render the header bar.
|
||||
pub struct HeaderData<'a> {
|
||||
pub model: &'a str,
|
||||
pub workspace_name: &'a str,
|
||||
pub mode: AppMode,
|
||||
pub is_streaming: bool,
|
||||
pub background: ratatui::style::Color,
|
||||
@@ -36,11 +37,13 @@ impl<'a> HeaderData<'a> {
|
||||
pub fn new(
|
||||
mode: AppMode,
|
||||
model: &'a str,
|
||||
workspace_name: &'a str,
|
||||
is_streaming: bool,
|
||||
background: ratatui::style::Color,
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
workspace_name,
|
||||
mode,
|
||||
is_streaming,
|
||||
background,
|
||||
@@ -84,33 +87,53 @@ impl<'a> HeaderWidget<'a> {
|
||||
/// Get the color for a mode.
|
||||
fn mode_color(mode: AppMode) -> ratatui::style::Color {
|
||||
match mode {
|
||||
AppMode::Normal => palette::MODE_NORMAL,
|
||||
AppMode::Agent => palette::MODE_AGENT,
|
||||
AppMode::Yolo => palette::MODE_YOLO,
|
||||
AppMode::Plan => palette::MODE_PLAN,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the mode badge span (no brackets, lowercase, bold).
|
||||
fn mode_badge(&self) -> Span<'static> {
|
||||
let label = self.data.mode.label().to_lowercase();
|
||||
let color = Self::mode_color(self.data.mode);
|
||||
Span::styled(
|
||||
label,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
)
|
||||
fn mode_name(mode: AppMode) -> &'static str {
|
||||
match mode {
|
||||
AppMode::Agent => "Agent",
|
||||
AppMode::Yolo => "Yolo",
|
||||
AppMode::Plan => "Plan",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the model name span (muted, truncated).
|
||||
fn model_span(&self) -> Span<'static> {
|
||||
let display_name = if self.data.model.chars().count() > 25 {
|
||||
let truncated: String = self.data.model.chars().take(22).collect();
|
||||
format!("{truncated}...")
|
||||
fn mode_segments(&self) -> Vec<Span<'static>> {
|
||||
let modes = [AppMode::Plan, AppMode::Agent, AppMode::Yolo];
|
||||
let mut spans = Vec::new();
|
||||
for (idx, mode) in modes.into_iter().enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
let is_selected = mode == self.data.mode;
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(self.data.background)
|
||||
.bg(Self::mode_color(mode))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
self.data.model.to_string()
|
||||
Style::default().fg(palette::TEXT_HINT)
|
||||
};
|
||||
spans.push(Span::styled(format!(" {} ", Self::mode_name(mode)), style));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
Span::styled(display_name, Style::default().fg(palette::TEXT_HINT))
|
||||
fn context_text(&self, max_chars: usize) -> String {
|
||||
let raw = format!("{} · {}", self.data.workspace_name, self.data.model);
|
||||
if raw.chars().count() <= max_chars {
|
||||
raw
|
||||
} else {
|
||||
let mut truncated = String::new();
|
||||
for ch in raw.chars().take(max_chars.saturating_sub(3)) {
|
||||
truncated.push(ch);
|
||||
}
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the streaming indicator span.
|
||||
@@ -134,31 +157,36 @@ impl Renderable for HeaderWidget<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build left section: mode + model
|
||||
let mode_span = self.mode_badge();
|
||||
let model_span = self.model_span();
|
||||
let mut left_spans = self.mode_segments();
|
||||
|
||||
// Build right section: streaming indicator only. Footer owns context.
|
||||
let streaming_span = self.streaming_indicator();
|
||||
|
||||
// Calculate widths
|
||||
let mode_width = mode_span.content.width();
|
||||
let model_width = model_span.content.width();
|
||||
let mut left_width: usize = left_spans.iter().map(|span| span.content.width()).sum();
|
||||
let streaming_width = streaming_span.as_ref().map_or(0, |s| s.content.width());
|
||||
let right_width = streaming_width;
|
||||
|
||||
let left_width = mode_width + 2 + model_width; // mode + " " + model
|
||||
|
||||
let available = area.width as usize;
|
||||
|
||||
// Build final line based on available space
|
||||
let mut spans = Vec::new();
|
||||
|
||||
if available >= left_width + right_width + 2 {
|
||||
// Full layout: mode model (spacer) ●
|
||||
spans.push(mode_span);
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(model_span);
|
||||
let context_room = available
|
||||
.saturating_sub(left_width + right_width)
|
||||
.saturating_sub(2);
|
||||
if context_room >= 10 {
|
||||
let context_text = self.context_text(context_room);
|
||||
left_spans.push(Span::raw(" "));
|
||||
left_spans.push(Span::styled(
|
||||
context_text,
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
));
|
||||
left_width = left_spans.iter().map(|span| span.content.width()).sum();
|
||||
}
|
||||
|
||||
if available >= left_width + right_width {
|
||||
spans.extend(left_spans);
|
||||
|
||||
// Spacer to push right elements to the end
|
||||
let padding_needed = available.saturating_sub(left_width + right_width);
|
||||
@@ -170,24 +198,14 @@ impl Renderable for HeaderWidget<'_> {
|
||||
if let Some(streaming) = streaming_span {
|
||||
spans.push(streaming);
|
||||
}
|
||||
} else if available >= mode_width + 2 + model_width.min(10) {
|
||||
// Compact layout: mode truncated_model
|
||||
spans.push(mode_span);
|
||||
spans.push(Span::raw(" "));
|
||||
let model_str = self.data.model;
|
||||
let display_model = if model_str.chars().count() > 10 {
|
||||
let truncated: String = model_str.chars().take(7).collect();
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
model_str.to_string()
|
||||
};
|
||||
} else if available >= 12 {
|
||||
spans.push(Span::styled(
|
||||
display_model,
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
format!(" {} ", Self::mode_name(self.data.mode)),
|
||||
Style::default()
|
||||
.fg(self.data.background)
|
||||
.bg(Self::mode_color(self.data.mode))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else if available >= mode_width {
|
||||
// Minimal: just mode badge
|
||||
spans.push(mode_span);
|
||||
} else {
|
||||
// Ultra-minimal: single lowercase char
|
||||
let first_char = self
|
||||
|
||||
@@ -7,7 +7,7 @@ pub use renderable::Renderable;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::app::{App, AppMode, ComposerDensity};
|
||||
use crate::tui::approval::{ApprovalRequest, ElevationOption, ElevationRequest, ToolCategory};
|
||||
use crate::tui::history::HistoryCell;
|
||||
use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll};
|
||||
@@ -24,6 +24,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
const SEND_FLASH_DURATION: Duration = Duration::from_millis(500);
|
||||
const COMPOSER_PANEL_HEIGHT: u16 = 2;
|
||||
|
||||
pub struct ChatWidget {
|
||||
content_area: Rect,
|
||||
@@ -36,6 +37,19 @@ impl ChatWidget {
|
||||
let visible_lines = content_area.height as usize;
|
||||
let render_options = app.transcript_render_options();
|
||||
|
||||
if should_render_empty_state(app) {
|
||||
let lines = build_empty_state_lines(app, content_area);
|
||||
app.last_transcript_area = Some(content_area);
|
||||
app.last_transcript_top = 0;
|
||||
app.last_transcript_visible = visible_lines;
|
||||
app.last_transcript_total = 0;
|
||||
app.last_transcript_padding_top = 0;
|
||||
return Self {
|
||||
content_area,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
app.transcript_cache.ensure(
|
||||
&app.history,
|
||||
content_area.width.max(1),
|
||||
@@ -74,7 +88,9 @@ impl ChatWidget {
|
||||
};
|
||||
|
||||
// Brief flash highlight on the most recently sent user message.
|
||||
if let Some(send_at) = app.last_send_at {
|
||||
if !app.low_motion
|
||||
&& let Some(send_at) = app.last_send_at
|
||||
{
|
||||
if send_at.elapsed() < SEND_FLASH_DURATION {
|
||||
apply_send_flash(&mut lines, top, &app.history, line_meta);
|
||||
} else {
|
||||
@@ -109,74 +125,113 @@ impl Renderable for ChatWidget {
|
||||
|
||||
pub struct ComposerWidget<'a> {
|
||||
app: &'a App,
|
||||
prompt: &'a str,
|
||||
max_height: u16,
|
||||
slash_menu_entries: &'a [String],
|
||||
}
|
||||
|
||||
impl<'a> ComposerWidget<'a> {
|
||||
pub fn new(
|
||||
app: &'a App,
|
||||
prompt: &'a str,
|
||||
max_height: u16,
|
||||
slash_menu_entries: &'a [String],
|
||||
) -> Self {
|
||||
pub fn new(app: &'a App, max_height: u16, slash_menu_entries: &'a [String]) -> Self {
|
||||
Self {
|
||||
app,
|
||||
prompt,
|
||||
max_height,
|
||||
slash_menu_entries,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_panel(area: Rect) -> bool {
|
||||
area.height >= 3 && area.width >= 12
|
||||
}
|
||||
|
||||
fn inner_area(area: Rect) -> Rect {
|
||||
if Self::has_panel(area) {
|
||||
Block::default().borders(Borders::ALL).inner(area)
|
||||
} else {
|
||||
area
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_color(&self) -> Color {
|
||||
match self.app.mode {
|
||||
AppMode::Agent => palette::MODE_AGENT,
|
||||
AppMode::Yolo => palette::MODE_YOLO,
|
||||
AppMode::Plan => palette::MODE_PLAN,
|
||||
}
|
||||
}
|
||||
|
||||
fn max_height_cap(&self) -> u16 {
|
||||
composer_max_height(self.app.composer_density)
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ComposerWidget<'_> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let prompt_width = self.prompt.width();
|
||||
let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX);
|
||||
let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1));
|
||||
let background = Style::default().bg(self.app.ui_theme.composer_bg);
|
||||
let has_panel = Self::has_panel(area);
|
||||
let inner_area = Self::inner_area(area);
|
||||
let menu_lines = self.slash_menu_entries.len();
|
||||
let max_height = usize::from(area.height).saturating_sub(menu_lines).max(1);
|
||||
let continuation = " ".repeat(prompt_width);
|
||||
|
||||
let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines);
|
||||
let content_width = usize::from(inner_area.width.max(1));
|
||||
let (visible_lines, _cursor_row, _cursor_col) = layout_input(
|
||||
&self.app.input,
|
||||
self.app.cursor_position,
|
||||
content_width,
|
||||
max_height,
|
||||
input_rows_budget,
|
||||
);
|
||||
|
||||
let background = Style::default().bg(self.app.ui_theme.composer_bg);
|
||||
let block = Block::default().style(background);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
if self.app.input.is_empty() {
|
||||
let placeholder = "Type a message or /help for commands...";
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
self.prompt,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
placeholder,
|
||||
Style::default().fg(palette::TEXT_MUTED).italic(),
|
||||
),
|
||||
]));
|
||||
let is_draft_mode = self.app.input.contains('\n') || visible_lines.len() > 1;
|
||||
if has_panel {
|
||||
let border_color = if self.app.input.trim().is_empty() {
|
||||
palette::BORDER_COLOR
|
||||
} else {
|
||||
for (idx, line) in visible_lines.iter().enumerate() {
|
||||
let prefix = if idx == 0 {
|
||||
self.prompt
|
||||
} else {
|
||||
continuation.as_str()
|
||||
self.mode_color()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled(line.clone(), Style::default().fg(palette::TEXT_PRIMARY)),
|
||||
]));
|
||||
let hint_line = if self.slash_menu_entries.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Line::from(vec![
|
||||
Span::styled(" Up/Down move ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled("Tab accept ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)),
|
||||
]))
|
||||
};
|
||||
|
||||
let mut block = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
if is_draft_mode { "Draft" } else { "Composer" },
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.style(background);
|
||||
if let Some(hint_line) = hint_line {
|
||||
block = block.title_bottom(hint_line);
|
||||
}
|
||||
block.render(area, buf);
|
||||
} else {
|
||||
Block::default().style(background).render(area, buf);
|
||||
}
|
||||
|
||||
let mut input_lines = Vec::new();
|
||||
if self.app.input.is_empty() {
|
||||
input_lines.push(Line::from(Span::styled(
|
||||
"Write a task or use /.",
|
||||
Style::default().fg(palette::TEXT_MUTED).italic(),
|
||||
)));
|
||||
} else {
|
||||
for line in &visible_lines {
|
||||
input_lines.push(Line::from(Span::styled(
|
||||
line.clone(),
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let top_padding = composer_vertical_padding(input_lines.len(), input_rows_budget);
|
||||
let mut lines = Vec::new();
|
||||
for _ in 0..top_padding {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(input_lines);
|
||||
|
||||
if !self.slash_menu_entries.is_empty() {
|
||||
let selected = self
|
||||
.app
|
||||
@@ -201,42 +256,56 @@ impl Renderable for ComposerWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).style(background);
|
||||
paragraph.render(area, buf);
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.style(background)
|
||||
.wrap(Wrap { trim: false });
|
||||
paragraph.render(inner_area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
composer_height(
|
||||
&self.app.input,
|
||||
width,
|
||||
self.max_height,
|
||||
self.prompt,
|
||||
self.max_height.min(self.max_height_cap()),
|
||||
self.slash_menu_entries.len(),
|
||||
self.app.composer_density,
|
||||
)
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let prompt_width = self.prompt.width();
|
||||
let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX);
|
||||
let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1));
|
||||
let max_height = usize::from(area.height)
|
||||
.saturating_sub(self.slash_menu_entries.len())
|
||||
.max(1);
|
||||
let inner_area = Self::inner_area(area);
|
||||
let content_width = usize::from(inner_area.width.max(1));
|
||||
let input_rows_budget =
|
||||
composer_input_rows_budget(inner_area.height, self.slash_menu_entries.len());
|
||||
|
||||
let (_visible_lines, cursor_row, cursor_col) = layout_input(
|
||||
&self.app.input,
|
||||
self.app.cursor_position,
|
||||
content_width,
|
||||
max_height,
|
||||
input_rows_budget,
|
||||
);
|
||||
let top_padding = if self.app.input.is_empty() {
|
||||
let empty_lines = if self.app.input_history.is_empty() && input_rows_budget > 1 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
composer_vertical_padding(empty_lines, input_rows_budget)
|
||||
} else {
|
||||
let visible_count = wrap_input_lines(&self.app.input, content_width)
|
||||
.len()
|
||||
.max(1);
|
||||
composer_vertical_padding(visible_count.min(input_rows_budget), input_rows_budget)
|
||||
};
|
||||
|
||||
let cursor_x = area
|
||||
.x
|
||||
.saturating_add(prompt_width_u16)
|
||||
.saturating_add(inner_area.x.saturating_sub(area.x))
|
||||
.saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX));
|
||||
let cursor_y = area
|
||||
.y
|
||||
.saturating_add(u16::try_from(cursor_row).unwrap_or(u16::MAX));
|
||||
.saturating_add(inner_area.y.saturating_sub(area.y))
|
||||
.saturating_add(u16::try_from(top_padding + cursor_row).unwrap_or(u16::MAX));
|
||||
if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
|
||||
Some((cursor_x, cursor_y))
|
||||
} else {
|
||||
@@ -681,22 +750,110 @@ fn char_display_width(ch: char) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
fn should_render_empty_state(app: &App) -> bool {
|
||||
app.history.is_empty() && !app.is_loading && !app.is_compacting
|
||||
}
|
||||
|
||||
fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let workspace_name = app
|
||||
.workspace
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::string::ToString::to_string)
|
||||
.unwrap_or_else(|| app.workspace.to_string_lossy().into_owned());
|
||||
let body_width = usize::from(area.width.saturating_sub(8).clamp(24, 72));
|
||||
let left_padding = usize::from(area.width.saturating_sub(body_width as u16) / 2);
|
||||
let inset = " ".repeat(left_padding);
|
||||
|
||||
let mut body = vec![
|
||||
Line::from(Span::styled(
|
||||
format!("{inset}DeepSeek TUI"),
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("{inset}{workspace_name} · {}", app.model),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
for line in wrap_text(
|
||||
"Start in plain language. The transcript stays clear until the first real turn.",
|
||||
body_width,
|
||||
) {
|
||||
body.push(Line::from(Span::styled(
|
||||
format!("{inset}{line}"),
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
)));
|
||||
}
|
||||
|
||||
let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3);
|
||||
let mut lines = Vec::new();
|
||||
for _ in 0..top_padding {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(body);
|
||||
lines
|
||||
}
|
||||
|
||||
fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize {
|
||||
usize::from(inner_height).saturating_sub(extra_lines).max(1)
|
||||
}
|
||||
|
||||
fn composer_vertical_padding(content_lines: usize, rows_budget: usize) -> usize {
|
||||
rows_budget.saturating_sub(content_lines)
|
||||
}
|
||||
|
||||
fn composer_min_input_rows(density: ComposerDensity) -> usize {
|
||||
match density {
|
||||
ComposerDensity::Compact => 2,
|
||||
ComposerDensity::Comfortable => 3,
|
||||
ComposerDensity::Spacious => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn composer_max_height(density: ComposerDensity) -> u16 {
|
||||
match density {
|
||||
ComposerDensity::Compact => 7,
|
||||
ComposerDensity::Comfortable => 9,
|
||||
ComposerDensity::Spacious => 12,
|
||||
}
|
||||
}
|
||||
|
||||
fn composer_height(
|
||||
input: &str,
|
||||
width: u16,
|
||||
available_height: u16,
|
||||
prompt: &str,
|
||||
extra_lines: usize,
|
||||
density: ComposerDensity,
|
||||
) -> u16 {
|
||||
let prompt_width = prompt.width();
|
||||
let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX);
|
||||
let content_width = usize::from(width.saturating_sub(prompt_width_u16).max(1));
|
||||
let has_panel = available_height >= 3 && width >= 12;
|
||||
let chrome_height = if has_panel {
|
||||
usize::from(COMPOSER_PANEL_HEIGHT)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let content_width = if has_panel {
|
||||
usize::from(width.saturating_sub(2).max(1))
|
||||
} else {
|
||||
usize::from(width.max(1))
|
||||
};
|
||||
let mut line_count = wrap_input_lines(input, content_width).len();
|
||||
if line_count == 0 {
|
||||
line_count = 1;
|
||||
}
|
||||
line_count = line_count.saturating_add(extra_lines);
|
||||
let max_height = usize::from(available_height.clamp(1, 8));
|
||||
if has_panel {
|
||||
line_count = line_count.max(composer_min_input_rows(density));
|
||||
}
|
||||
line_count = line_count
|
||||
.saturating_add(extra_lines)
|
||||
.saturating_add(chrome_height);
|
||||
let max_height = usize::from(available_height.clamp(1, composer_max_height(density)));
|
||||
line_count.clamp(1, max_height).try_into().unwrap_or(1)
|
||||
}
|
||||
|
||||
@@ -864,10 +1021,12 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_selection_to_line, composer_height, cursor_row_col, layout_input,
|
||||
pad_lines_to_bottom, slash_completion_hints, wrap_input_lines, wrap_text,
|
||||
COMPOSER_PANEL_HEIGHT, apply_selection_to_line, composer_height, composer_max_height,
|
||||
composer_min_input_rows, cursor_row_col, layout_input, pad_lines_to_bottom,
|
||||
should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text,
|
||||
};
|
||||
use crate::palette;
|
||||
use crate::tui::app::ComposerDensity;
|
||||
use ratatui::{
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
@@ -1058,15 +1217,32 @@ mod tests {
|
||||
#[test]
|
||||
fn composer_layout_helpers_stay_consistent() {
|
||||
let input = "line one wraps nicely\nline two wraps as well";
|
||||
let prompt = "> ";
|
||||
let width = 16;
|
||||
let available_height = 6;
|
||||
let menu_lines = 2;
|
||||
|
||||
let height = composer_height(input, width, available_height, prompt, menu_lines);
|
||||
let prompt_width = u16::try_from(prompt.width()).unwrap_or(u16::MAX);
|
||||
let content_width = usize::from(width.saturating_sub(prompt_width).max(1));
|
||||
let input_height_budget = usize::from(height).saturating_sub(menu_lines).max(1);
|
||||
let height = composer_height(
|
||||
input,
|
||||
width,
|
||||
available_height,
|
||||
menu_lines,
|
||||
ComposerDensity::Comfortable,
|
||||
);
|
||||
let has_panel = available_height >= 3 && width >= 12;
|
||||
let chrome_height = if has_panel {
|
||||
usize::from(COMPOSER_PANEL_HEIGHT)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let content_width = if has_panel {
|
||||
usize::from(width.saturating_sub(2).max(1))
|
||||
} else {
|
||||
usize::from(width.max(1))
|
||||
};
|
||||
let input_height_budget = usize::from(height)
|
||||
.saturating_sub(menu_lines)
|
||||
.saturating_sub(chrome_height)
|
||||
.max(1);
|
||||
let (visible, cursor_row, cursor_col) = layout_input(
|
||||
input,
|
||||
input.chars().count(),
|
||||
@@ -1078,5 +1254,52 @@ mod tests {
|
||||
assert!(!visible.is_empty());
|
||||
assert!(cursor_row < visible.len());
|
||||
assert!(cursor_col < content_width.max(1));
|
||||
assert!(height >= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_height_prefers_panel_shape_when_space_allows() {
|
||||
let height = composer_height("", 40, 8, 0, ComposerDensity::Comfortable);
|
||||
assert_eq!(height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_density_changes_min_rows_and_height_cap() {
|
||||
assert_eq!(composer_min_input_rows(ComposerDensity::Compact), 2);
|
||||
assert_eq!(composer_min_input_rows(ComposerDensity::Spacious), 4);
|
||||
assert!(
|
||||
composer_max_height(ComposerDensity::Spacious)
|
||||
> composer_max_height(ComposerDensity::Compact)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_renders_only_without_transcript_activity() {
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-chat".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: true,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
let mut app = App::new(options, &Config::default());
|
||||
assert!(should_render_empty_state(&app));
|
||||
app.add_message(crate::tui::history::HistoryCell::User {
|
||||
content: "hello".to_string(),
|
||||
});
|
||||
assert!(!should_render_empty_state(&app));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +86,14 @@ Common settings keys:
|
||||
- `auto_compact` (on/off)
|
||||
- `show_thinking` (on/off)
|
||||
- `show_tool_details` (on/off)
|
||||
- `default_mode` (normal, agent, plan, yolo)
|
||||
- `default_mode` (agent, plan, yolo; legacy `normal` is accepted and normalized to `agent`)
|
||||
- `max_history` (number of input history entries)
|
||||
- `default_model` (model name override)
|
||||
|
||||
Only `agent`, `plan`, and `yolo` are visible modes in the UI. For compatibility,
|
||||
older settings files with `default_mode = "normal"` still load as `agent`, and
|
||||
the hidden `/normal` slash command switches to `Agent`.
|
||||
|
||||
Readability semantics:
|
||||
|
||||
- Selection uses a unified style across transcript, composer menus, and modals.
|
||||
@@ -103,6 +107,8 @@ If you are upgrading from older releases:
|
||||
New: `/links` (aliases: `/dashboard`, `/api`)
|
||||
- Old: `/set model deepseek-reasoner`
|
||||
New: `/config` and edit the `model` row to `deepseek-reasoner`
|
||||
- Old: visible `Normal` mode or `default_mode = "normal"`
|
||||
New: use `Agent` / `default_mode = "agent"`; legacy `normal` still maps to `agent`
|
||||
- Old: discover `/set` in slash UX/help
|
||||
New: use `/config` for editing and `/settings` for read-only inspection
|
||||
|
||||
|
||||
+8
-4
@@ -2,19 +2,23 @@
|
||||
|
||||
DeepSeek TUI has two related concepts:
|
||||
|
||||
- **TUI mode**: what kind of interaction you’re in (Normal/Plan/Agent/YOLO).
|
||||
- **TUI mode**: what kind of visible interaction you’re in (Plan/Agent/YOLO).
|
||||
- **Approval mode**: how aggressively the UI asks before executing tools.
|
||||
|
||||
## TUI Modes
|
||||
|
||||
Press `Tab` to cycle forward: **Normal → Agent → YOLO → Plan → Normal**.
|
||||
Press `Tab` to cycle through the visible modes: **Plan → Agent → YOLO → Plan**.
|
||||
Press `Shift+Tab` to cycle in reverse.
|
||||
|
||||
- **Normal**: chat-first. Approvals for file writes, shell, and paid tools.
|
||||
- **Plan**: design-first prompting. Approvals match Normal.
|
||||
- **Plan**: design-first prompting. Read-only investigation tools stay available, but shell and patch execution stay off.
|
||||
- **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt).
|
||||
- **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos.
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
- `/normal` is a hidden compatibility alias that switches to `Agent`.
|
||||
- Older settings files with `default_mode = "normal"` still load as `agent`; saving rewrites the normalized value.
|
||||
|
||||
## Escape Key Behavior
|
||||
|
||||
`Esc` is a cancel stack, not a mode switch.
|
||||
|
||||
Reference in New Issue
Block a user