feat(tui): ship the underwater interaction system
Translate the private HTML direction into a terminal-native ocean field, model-first route selection, Fleet-first launch hierarchy, calm streaming motion, top work strip, and distinct repository-law approvals. Add responsive and PTY coverage for the new visual state contracts.
This commit is contained in:
@@ -384,7 +384,7 @@
|
||||
"SetupStepTrustSandboxTitle": "Runtime posture",
|
||||
"SetupStepTrustSandboxWhy": "Review trust, sandbox, approval, shell, and network policy separately from constitutional guidance.",
|
||||
"SetupStepOperateFleetTitle": "Operate and Fleet",
|
||||
"SetupStepOperateFleetWhy": "Review whether durable multi-worker work can run with the current provider, runtime posture, roster, and concurrency settings. Plan-limit discovery stays explicit.",
|
||||
"SetupStepOperateFleetWhy": "Operate can use the built-in team immediately. Roles without a custom route use this session's model; Fleet setup is only for customization.",
|
||||
"SetupStepToolsMcpTitle": "Tools and MCP",
|
||||
"SetupStepToolsMcpWhy": "Inspect optional tool and MCP readiness without blocking the constitution checkpoint.",
|
||||
"SetupStepHotbarTitle": "Hotbar",
|
||||
@@ -537,7 +537,7 @@
|
||||
"AppModeAutoHint": "Shell enabled with automatic risk review",
|
||||
"AppModePlanHint": "Read-only research first — present a plan before acting",
|
||||
"AppModeYoloHint": "Compatibility only — Act + Full Access, not a visible mode",
|
||||
"AppModeOperateHint": "Orchestrate Fleet/subagents/workflows — spawn, wait, verify, hand off",
|
||||
"AppModeOperateHint": "Coordinate a Fleet for multi-step work.",
|
||||
"VimModeNormal": "-- NORMAL --",
|
||||
"VimModeInsert": "-- INSERT --",
|
||||
"VimModeVisual": "-- VISUAL --",
|
||||
@@ -744,9 +744,9 @@
|
||||
"SetupToolsMcpPreviewTitle": "Tools / MCP safe on-ramps",
|
||||
"SetupToolsMcpOnRampText": "Tools, MCP, Skills & Plugins — Safe On-Ramps\n\n/setup only reads local inventory. It never starts MCP servers, installs skills, runs plugins, or executes untrusted commands.\n\nCurrent inventory:\n- MCP: {mcp_result}\n- Skills: {skills_result}\n- Tools dir: {tools_result}\n- Plugins: {plugins_result}\n- Hotbar (shared adapters): {hotbar_result}\n\nPaths (redacted home):\n- MCP config: {mcp_path}\n- Skills: {skills_path}\n- Plugins: {plugins_path}\n\nSafe bootstrap (run yourself in a normal terminal or TUI command):\n- MCP: /mcp · codewhale mcp init · codewhale doctor\n- Skills: /skills · codewhale setup --skills · /skills install <spec>\n- Plugins: /plugin · codewhale setup --plugins\n- Tools dir: codewhale setup --tools\n\nSide-effectful actions always require explicit confirmation. Plugin commands stay distinct from slash commands; the Hotbar plugin source remains deferred until approval gates land.\n\nSee docs/MCP.md and docs/skills/README.md for what still needs manual external setup.",
|
||||
"HotbarActionModeOperateName": "Operate mode",
|
||||
"HotbarActionModeOperateDescription": "Manage Fleet workers, subagents, and workflow lanes.",
|
||||
"HomeOperateModeTip": "Operate — manage Fleet, subagents, and workflow lanes",
|
||||
"HomeOperateModeFleetTip": " /fleet inspects workers; waiting and failed work surface here",
|
||||
"HotbarActionModeOperateDescription": "Coordinate a Fleet for multi-step work.",
|
||||
"HomeOperateModeTip": "Operate — coordinate a Fleet for multi-step work",
|
||||
"HomeOperateModeFleetTip": " Built-in roles use this session's model; /fleet setup customizes them",
|
||||
"HelpSubtitle": "Concepts, commands, and keybindings",
|
||||
"CommandPaletteTitle": "Command",
|
||||
"CommandPaletteSubtitle": "Find and run one action",
|
||||
|
||||
@@ -1684,6 +1684,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.fancy_animations = settings.fancy_animations;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"ocean_treatment" | "treatment" | "background_treatment" => {
|
||||
app.ocean_treatment = settings.ocean_treatment.clone();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"bracketed_paste" | "paste" => {
|
||||
app.use_bracketed_paste = settings.bracketed_paste;
|
||||
app.needs_redraw = true;
|
||||
@@ -2713,7 +2717,7 @@ Parse error: permissions.toml at permissions.toml could not be parsed: expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_fancy_animations_obeys_ghostty_override() {
|
||||
fn config_fancy_animations_keeps_ghostty_frame_cap_without_disabling_motion() {
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"codewhale-tui-ghostty-fancy-config-test-{}",
|
||||
std::process::id()
|
||||
@@ -2727,18 +2731,19 @@ Parse error: permissions.toml at permissions.toml could not be parsed: expected
|
||||
}
|
||||
|
||||
let mut app = create_test_app();
|
||||
assert!(!app.fancy_animations);
|
||||
assert!(app.fancy_animations);
|
||||
assert!(app.constrained_frame_rate);
|
||||
|
||||
let result = set_config_value(&mut app, "fancy_animations", "true", false);
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert!(
|
||||
!app.fancy_animations,
|
||||
"Ghostty compatibility override must keep the water strip disabled"
|
||||
app.fancy_animations,
|
||||
"Ghostty compatibility must cap redraws without disabling motion"
|
||||
);
|
||||
assert_eq!(
|
||||
result.message.as_deref(),
|
||||
Some("fancy_animations = false (session only, add --save to persist)")
|
||||
Some("fancy_animations = true (session only, add --save to persist)")
|
||||
);
|
||||
|
||||
// Safety: cleanup under EnvGuard's lock.
|
||||
|
||||
@@ -52,6 +52,7 @@ pub struct SettingsSection {
|
||||
pub calm_mode: bool,
|
||||
pub low_motion: bool,
|
||||
pub fancy_animations: bool,
|
||||
pub ocean_treatment: OceanTreatmentValue,
|
||||
pub paste_burst_detection: bool,
|
||||
pub show_thinking: bool,
|
||||
pub show_tool_details: bool,
|
||||
@@ -196,6 +197,13 @@ pub enum UiThemeValue {
|
||||
Matrix,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OceanTreatmentValue {
|
||||
Ombre,
|
||||
Flat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComposerDensityValue {
|
||||
@@ -266,6 +274,7 @@ pub enum ReasoningEffortValue {
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StatusIndicatorValue {
|
||||
Cw,
|
||||
Whale,
|
||||
Dots,
|
||||
Off,
|
||||
@@ -334,6 +343,7 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
|
||||
calm_mode: settings.calm_mode,
|
||||
low_motion: settings.low_motion,
|
||||
fancy_animations: settings.fancy_animations,
|
||||
ocean_treatment: settings.ocean_treatment.as_str().into(),
|
||||
paste_burst_detection: settings.paste_burst_detection,
|
||||
show_thinking: settings.show_thinking,
|
||||
show_tool_details: settings.show_tool_details,
|
||||
@@ -496,6 +506,7 @@ pub fn apply_document(
|
||||
("calm_mode", bool_str(doc.settings.calm_mode)),
|
||||
("low_motion", bool_str(doc.settings.low_motion)),
|
||||
("fancy_animations", bool_str(doc.settings.fancy_animations)),
|
||||
("ocean_treatment", doc.settings.ocean_treatment.as_setting()),
|
||||
(
|
||||
"paste_burst_detection",
|
||||
bool_str(doc.settings.paste_burst_detection),
|
||||
@@ -780,6 +791,25 @@ impl UiThemeValue {
|
||||
}
|
||||
}
|
||||
|
||||
impl OceanTreatmentValue {
|
||||
fn as_setting(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ombre => "ombre",
|
||||
Self::Flat => "flat",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for OceanTreatmentValue {
|
||||
fn from(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("flat") {
|
||||
Self::Flat
|
||||
} else {
|
||||
Self::Ombre
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ComposerDensityValue {
|
||||
fn as_setting(self) -> &'static str {
|
||||
match self {
|
||||
@@ -962,6 +992,7 @@ impl From<&str> for DefaultModeValue {
|
||||
impl StatusIndicatorValue {
|
||||
fn as_setting(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cw => "cw",
|
||||
Self::Whale => "whale",
|
||||
Self::Dots => "dots",
|
||||
Self::Off => "off",
|
||||
@@ -995,12 +1026,11 @@ impl From<&str> for StatusIndicatorValue {
|
||||
// so a TOML file with `status_indicator = "🐳"` or `"none"`
|
||||
// resolves to the canonical enum variant.
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"cw" | "mark" | "text" => Self::Cw,
|
||||
"dots" | "dot" => Self::Dots,
|
||||
"off" | "none" | "hidden" | "false" => Self::Off,
|
||||
// Default to whale for "whale", aliases, and anything unknown
|
||||
// (we'd rather restore the historic indicator than silently
|
||||
// hide it on a typo).
|
||||
_ => Self::Whale,
|
||||
"whale" | "🐳" | "🐋" => Self::Whale,
|
||||
_ => Self::Cw,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ impl FleetRoster {
|
||||
(
|
||||
"scout",
|
||||
FleetSlot::Scout,
|
||||
FleetLoadout::Fast,
|
||||
"Fast read-only reconnaissance: find files, map code, gather evidence.",
|
||||
FleetLoadout::Inherit,
|
||||
"Read-only reconnaissance: find files, map code, gather evidence.",
|
||||
None,
|
||||
),
|
||||
(
|
||||
@@ -168,7 +168,7 @@ impl FleetRoster {
|
||||
(
|
||||
"synthesizer",
|
||||
FleetSlot::Summarizer,
|
||||
FleetLoadout::Fast,
|
||||
FleetLoadout::Inherit,
|
||||
"Read-only synthesis: merge findings into one coherent report.",
|
||||
None,
|
||||
),
|
||||
@@ -338,9 +338,9 @@ mod tests {
|
||||
}
|
||||
assert_eq!(members[0].profile.slot, FleetSlot::Manager);
|
||||
assert_eq!(members[1].profile.slot, FleetSlot::Operator);
|
||||
assert_eq!(members[2].profile.loadout, FleetLoadout::Fast);
|
||||
assert_eq!(members[2].profile.loadout, FleetLoadout::Inherit);
|
||||
assert_eq!(members[6].profile.slot, FleetSlot::Summarizer);
|
||||
assert_eq!(members[6].profile.loadout, FleetLoadout::Fast);
|
||||
assert_eq!(members[6].profile.loadout, FleetLoadout::Inherit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+101
-69
@@ -225,19 +225,19 @@ pub struct Settings {
|
||||
pub calm_mode: bool,
|
||||
/// Dense tool-run collapse mode: compact, expanded, or calm.
|
||||
pub tool_collapse_mode: String,
|
||||
/// Streaming pacing mode. `true` pins the chunker to one-character-per-
|
||||
/// commit-tick (typewriter); `false` drains the upstream cadence (each
|
||||
/// commit flushes everything queued, which matches V4-pro's burst pattern
|
||||
/// when the prefix cache is warm). Has no effect on the footer water-spout
|
||||
/// animation — that is gated independently by [`Self::fancy_animations`].
|
||||
/// Reduce decorative motion. This must never synthesize model text speed;
|
||||
/// streaming follows upstream deltas in both modes.
|
||||
pub low_motion: bool,
|
||||
/// Enable the footer water-spout animation strip during live turns. The
|
||||
/// strip's wave cadence is synchronized with the character-commit rate, so
|
||||
/// the visual flow matches whatever streaming pacing [`Self::low_motion`]
|
||||
/// selects: typewriter mode drips, upstream mode surges, tool calls /
|
||||
/// planning pauses freeze the surface. Set `false` to keep the gap as
|
||||
/// plain whitespace.
|
||||
/// Enable expressive live-state motion. This affects chrome and state
|
||||
/// affordances only; model text always follows upstream stream deltas.
|
||||
pub fancy_animations: bool,
|
||||
/// Background treatment: `ombre` paints the terminal-native water column;
|
||||
/// `flat` preserves all state marks on the theme's plain surface.
|
||||
pub ocean_treatment: String,
|
||||
/// Runtime-only 30 FPS cap for terminals that flicker at high redraw
|
||||
/// rates. Separate from accessibility motion and text delivery.
|
||||
#[serde(skip)]
|
||||
pub constrained_frame_rate: bool,
|
||||
/// Enable terminal bracketed-paste mode. Default true. Disable if your
|
||||
/// terminal mishandles the `\e[?2004h` escape (rare; some legacy
|
||||
/// terminals over SSH+screen multiplex without the cap).
|
||||
@@ -312,7 +312,8 @@ pub struct Settings {
|
||||
pub provider_models: Option<std::collections::HashMap<String, String>>,
|
||||
/// Header status indicator next to the effort chip. Cycles through a
|
||||
/// per-turn animation keyed off `App::turn_started_at`:
|
||||
/// - `"whale"` (default): historical `🐳 → 🐋` 12-frame sequence
|
||||
/// - `"cw"` (default): static typographic CodeWhale mark.
|
||||
/// - `"whale"`: historical `🐳 → 🐋` 12-frame sequence
|
||||
/// originally shipped in v0.3.5, removed in v0.8.x's "smoother TUI
|
||||
/// streaming" pass, restored in v0.8.30. Idle frame is a steady `🐳`.
|
||||
/// - `"dots"`: the 6-frame geometric sequence (`◍ ◉ ◌ ◌ ◉ ◍`) that
|
||||
@@ -381,8 +382,10 @@ impl Default for Settings {
|
||||
// #4095: default presentation is compact/calm; verbose detail is opt-in.
|
||||
calm_mode: true,
|
||||
tool_collapse_mode: "compact".to_string(),
|
||||
low_motion: true,
|
||||
fancy_animations: false,
|
||||
low_motion: false,
|
||||
fancy_animations: true,
|
||||
ocean_treatment: "ombre".to_string(),
|
||||
constrained_frame_rate: false,
|
||||
bracketed_paste: true,
|
||||
paste_burst_detection: true,
|
||||
mention_menu_limit: 128,
|
||||
@@ -399,8 +402,8 @@ impl Default for Settings {
|
||||
transcript_spacing: "compact".to_string(),
|
||||
default_mode: "agent".to_string(),
|
||||
sidebar_width_percent: 28,
|
||||
sidebar_focus: "pinned".to_string(),
|
||||
sidebar_auto_collapse_opt_in: false,
|
||||
sidebar_focus: "auto".to_string(),
|
||||
sidebar_auto_collapse_opt_in: true,
|
||||
context_panel: false,
|
||||
cost_currency: "usd".to_string(),
|
||||
max_input_history: 100,
|
||||
@@ -409,7 +412,7 @@ impl Default for Settings {
|
||||
reasoning_effort: None,
|
||||
permission_posture: None,
|
||||
provider_models: None,
|
||||
status_indicator: "whale".to_string(),
|
||||
status_indicator: "cw".to_string(),
|
||||
synchronized_output: "auto".to_string(),
|
||||
prefer_external_pdftotext: false,
|
||||
workspace_follow_symlinks: false,
|
||||
@@ -434,6 +437,14 @@ pub const CALM_PRESET_FIELDS: &[(&str, &str)] = &[
|
||||
("show_tool_details", "false"),
|
||||
];
|
||||
|
||||
fn normalize_ocean_treatment(value: &str) -> &'static str {
|
||||
if value.trim().eq_ignore_ascii_case("flat") {
|
||||
"flat"
|
||||
} else {
|
||||
"ombre"
|
||||
}
|
||||
}
|
||||
|
||||
/// The `(key, value)` fields a named preset applies, or `None` for an unknown
|
||||
/// name. Single source of truth shared by [`Settings::apply_preset`] and the
|
||||
/// `/config preset` command so the bundle is never defined twice.
|
||||
@@ -501,6 +512,7 @@ impl Settings {
|
||||
s.sidebar_focus = "pinned".to_string();
|
||||
}
|
||||
s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string();
|
||||
s.ocean_treatment = normalize_ocean_treatment(&s.ocean_treatment).to_string();
|
||||
s.synchronized_output =
|
||||
normalize_synchronized_output(&s.synchronized_output).to_string();
|
||||
s.locale = normalize_configured_locale(&s.locale)
|
||||
@@ -555,8 +567,9 @@ impl Settings {
|
||||
self.fancy_animations = false;
|
||||
}
|
||||
// VS Code (TERM_PROGRAM=vscode, #1356), Ghostty (#1445), and a few
|
||||
// VTE terminals (#1470) produce visible flicker at 120 FPS. Drop to
|
||||
// the 30 FPS low-motion cap for them automatically. Ghostty may report
|
||||
// VTE terminals (#1470) produce visible flicker at 120 FPS. Cap their
|
||||
// redraw rate without changing motion semantics or model text pacing.
|
||||
// Ghostty may report
|
||||
// either TERM_PROGRAM=Ghostty/ghostty or TERM=xterm-ghostty.
|
||||
// Like NO_ANIMATIONS above, this unconditionally overrides any
|
||||
// disk-loaded value — consistent precedence: env signals always win.
|
||||
@@ -566,13 +579,13 @@ impl Settings {
|
||||
let term = std::env::var("TERM")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let term_forces_low_motion =
|
||||
let term_constrains_frame_rate =
|
||||
matches!(term_program.as_str(), "vscode" | "ghostty") || term.contains("ghostty");
|
||||
let vte_env_forces_low_motion = std::env::var_os("TILIX_ID").is_some_and(|v| !v.is_empty())
|
||||
let vte_env_constrains_frame_rate = std::env::var_os("TILIX_ID")
|
||||
.is_some_and(|v| !v.is_empty())
|
||||
|| std::env::var_os("TERMINATOR_UUID").is_some_and(|v| !v.is_empty());
|
||||
if term_forces_low_motion || vte_env_forces_low_motion {
|
||||
self.low_motion = true;
|
||||
self.fancy_animations = false;
|
||||
if term_constrains_frame_rate || vte_env_constrains_frame_rate {
|
||||
self.constrained_frame_rate = true;
|
||||
}
|
||||
|
||||
// Termius (TERM_PROGRAM=Termius) and SSH sessions exhibit the
|
||||
@@ -697,6 +710,15 @@ impl Settings {
|
||||
"fancy_animations" | "fancy" | "animations" => {
|
||||
self.fancy_animations = parse_bool(value)?;
|
||||
}
|
||||
"ocean_treatment" | "treatment" | "background_treatment" => {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
if !matches!(normalized.as_str(), "ombre" | "flat") {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid ocean treatment '{value}'. Expected: ombre or flat."
|
||||
);
|
||||
}
|
||||
self.ocean_treatment = normalized;
|
||||
}
|
||||
"bracketed_paste" | "paste" => {
|
||||
self.bracketed_paste = parse_bool(value)?;
|
||||
}
|
||||
@@ -777,9 +799,9 @@ impl Settings {
|
||||
}
|
||||
"status_indicator" | "indicator" => {
|
||||
let normalized = normalize_status_indicator(value);
|
||||
if !["whale", "dots", "off"].contains(&normalized) {
|
||||
if !["cw", "whale", "dots", "off"].contains(&normalized) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid status indicator '{value}'. Expected: whale, dots, off."
|
||||
"Failed to update setting: invalid status indicator '{value}'. Expected: cw, whale, dots, off."
|
||||
);
|
||||
}
|
||||
self.status_indicator = normalized.to_string();
|
||||
@@ -940,6 +962,7 @@ impl Settings {
|
||||
lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode));
|
||||
lines.push(format!(" low_motion: {}", self.low_motion));
|
||||
lines.push(format!(" fancy_animations: {}", self.fancy_animations));
|
||||
lines.push(format!(" ocean_treatment: {}", self.ocean_treatment));
|
||||
lines.push(format!(" bracketed_paste: {}", self.bracketed_paste));
|
||||
lines.push(format!(
|
||||
" paste_burst_detect: {}",
|
||||
@@ -1029,11 +1052,12 @@ impl Settings {
|
||||
),
|
||||
(
|
||||
"low_motion",
|
||||
"Streaming pacing: on = typewriter (one char/tick), off = upstream cadence",
|
||||
"Reduce decorative motion without changing model text delivery: on/off",
|
||||
),
|
||||
("fancy_animations", "Expressive live-state motion: on/off"),
|
||||
(
|
||||
"fancy_animations",
|
||||
"Footer water-spout strip (wave synced to typing speed): on/off",
|
||||
"ocean_treatment",
|
||||
"Transcript background treatment: ombre/flat (independent of motion)",
|
||||
),
|
||||
(
|
||||
"bracketed_paste",
|
||||
@@ -1088,7 +1112,7 @@ impl Settings {
|
||||
),
|
||||
(
|
||||
"status_indicator",
|
||||
"Header status indicator next to effort chip: whale, dots, off",
|
||||
"Header status indicator next to effort chip: cw, whale, dots, off",
|
||||
),
|
||||
(
|
||||
"synchronized_output",
|
||||
@@ -1427,6 +1451,7 @@ fn normalize_tool_collapse_mode(value: &str) -> &str {
|
||||
/// in `update_setting` can surface a clear error.
|
||||
fn normalize_status_indicator(value: &str) -> &str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"cw" | "mark" | "text" => "cw",
|
||||
"whale" | "🐳" | "🐋" => "whale",
|
||||
"dots" | "dot" => "dots",
|
||||
"off" | "none" | "hidden" | "false" => "off",
|
||||
@@ -1557,6 +1582,20 @@ fn env_truthy(name: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ocean_treatment_is_appearance_not_motion() {
|
||||
let mut settings = Settings::default();
|
||||
assert_eq!(settings.ocean_treatment, "ombre");
|
||||
assert!(!settings.low_motion);
|
||||
|
||||
settings.set("ocean_treatment", "flat").unwrap();
|
||||
assert_eq!(settings.ocean_treatment, "flat");
|
||||
assert!(!settings.low_motion, "appearance must not change motion");
|
||||
|
||||
let err = settings.set("ocean_treatment", "kelp").unwrap_err();
|
||||
assert!(err.to_string().contains("ombre or flat"));
|
||||
}
|
||||
|
||||
/// Explicit animated baseline for env-force tests (#4095 flipped defaults to calm).
|
||||
fn animated_settings() -> Settings {
|
||||
Settings {
|
||||
@@ -1572,7 +1611,7 @@ mod tests {
|
||||
#[test]
|
||||
fn apply_preset_calm_sets_bundle_and_preserves_evidence() {
|
||||
let mut settings = Settings::default();
|
||||
// Defaults are already the calm/compact posture (#4095).
|
||||
// Density is calm by default; motion is an independent axis.
|
||||
assert!(settings.calm_mode);
|
||||
assert!(settings.show_thinking);
|
||||
|
||||
@@ -1604,8 +1643,8 @@ mod tests {
|
||||
let settings = Settings::default();
|
||||
assert!(settings.calm_mode);
|
||||
assert!(!settings.show_tool_details);
|
||||
assert!(settings.low_motion);
|
||||
assert!(!settings.fancy_animations);
|
||||
assert!(!settings.low_motion);
|
||||
assert!(settings.fancy_animations);
|
||||
assert_eq!(settings.transcript_spacing, "compact");
|
||||
assert_eq!(settings.tool_collapse_mode, "compact");
|
||||
// Thinking stays visible — compact is not "hide evidence".
|
||||
@@ -1656,22 +1695,23 @@ mod tests {
|
||||
fn default_settings_show_footer_water_strip() {
|
||||
let settings = Settings::default();
|
||||
assert!(
|
||||
!settings.fancy_animations,
|
||||
"default presentation is calm (#4095)"
|
||||
settings.fancy_animations,
|
||||
"underwater presentation is the default"
|
||||
);
|
||||
assert!(!settings.low_motion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_settings_keep_sidebar_pinned() {
|
||||
fn default_settings_keep_the_water_field_open_until_inspection_is_needed() {
|
||||
let settings = Settings::default();
|
||||
assert_eq!(settings.sidebar_focus, "pinned");
|
||||
assert!(!settings.sidebar_auto_collapse_opt_in);
|
||||
assert_eq!(settings.sidebar_focus, "auto");
|
||||
assert!(settings.sidebar_auto_collapse_opt_in);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_auto_opt_in_marker_is_serialized_only_when_enabled() {
|
||||
let default_body = toml::to_string_pretty(&Settings::default()).expect("serialize");
|
||||
assert!(!default_body.contains("sidebar_auto_collapse_opt_in"));
|
||||
assert!(default_body.contains("sidebar_auto_collapse_opt_in = true"));
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings
|
||||
@@ -2121,7 +2161,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vscode_term_program_forces_low_motion_on() {
|
||||
fn vscode_caps_redraws_without_disabling_motion_or_text_cadence() {
|
||||
let _g = term_program_test_guard();
|
||||
let prev = std::env::var_os("TERM_PROGRAM");
|
||||
// SAFETY: serialised by the guard.
|
||||
@@ -2131,13 +2171,11 @@ mod tests {
|
||||
let mut settings = animated_settings();
|
||||
assert!(!settings.low_motion, "default is animated");
|
||||
settings.apply_env_overrides();
|
||||
assert!(!settings.low_motion);
|
||||
assert!(settings.fancy_animations);
|
||||
assert!(
|
||||
settings.low_motion,
|
||||
"TERM_PROGRAM=vscode must enable low_motion to prevent flickering (#1356)"
|
||||
);
|
||||
assert!(
|
||||
!settings.fancy_animations,
|
||||
"TERM_PROGRAM=vscode must disable fancy_animations"
|
||||
settings.constrained_frame_rate,
|
||||
"TERM_PROGRAM=vscode should cap redraws without changing animation semantics"
|
||||
);
|
||||
// SAFETY: cleanup under the guard.
|
||||
unsafe {
|
||||
@@ -2149,7 +2187,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghostty_term_program_forces_low_motion_on() {
|
||||
fn ghostty_term_program_caps_redraws_without_disabling_motion() {
|
||||
let _g = term_program_test_guard();
|
||||
let prev = std::env::var_os("TERM_PROGRAM");
|
||||
// SAFETY: serialised by the guard.
|
||||
@@ -2159,14 +2197,9 @@ mod tests {
|
||||
let mut settings = animated_settings();
|
||||
assert!(!settings.low_motion, "default is animated");
|
||||
settings.apply_env_overrides();
|
||||
assert!(
|
||||
settings.low_motion,
|
||||
"TERM_PROGRAM=Ghostty must enable low_motion to prevent flickering (#1445)"
|
||||
);
|
||||
assert!(
|
||||
!settings.fancy_animations,
|
||||
"TERM_PROGRAM=Ghostty must disable fancy_animations"
|
||||
);
|
||||
assert!(!settings.low_motion);
|
||||
assert!(settings.fancy_animations);
|
||||
assert!(settings.constrained_frame_rate);
|
||||
// SAFETY: cleanup under the guard.
|
||||
unsafe {
|
||||
match prev {
|
||||
@@ -2177,7 +2210,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghostty_term_fallback_forces_low_motion_on() {
|
||||
fn ghostty_term_fallback_caps_redraws_without_disabling_motion() {
|
||||
let _g = term_program_test_guard();
|
||||
let prev_program = std::env::var_os("TERM_PROGRAM");
|
||||
let prev_term = std::env::var_os("TERM");
|
||||
@@ -2188,14 +2221,9 @@ mod tests {
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
settings.apply_env_overrides();
|
||||
assert!(
|
||||
settings.low_motion,
|
||||
"TERM=xterm-ghostty must enable low_motion when TERM_PROGRAM is absent"
|
||||
);
|
||||
assert!(
|
||||
!settings.fancy_animations,
|
||||
"TERM=xterm-ghostty must disable fancy_animations"
|
||||
);
|
||||
assert!(!settings.low_motion);
|
||||
assert!(settings.fancy_animations);
|
||||
assert!(settings.constrained_frame_rate);
|
||||
// SAFETY: cleanup under the guard.
|
||||
unsafe {
|
||||
match prev_program {
|
||||
@@ -2277,7 +2305,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilix_and_terminator_env_force_low_motion_on() {
|
||||
fn tilix_and_terminator_cap_redraws_without_disabling_motion() {
|
||||
let _g = term_program_test_guard();
|
||||
let prev_term_program = std::env::var_os("TERM_PROGRAM");
|
||||
let prev_tilix_id = std::env::var_os("TILIX_ID");
|
||||
@@ -2298,12 +2326,16 @@ mod tests {
|
||||
assert!(!settings.low_motion, "default is animated");
|
||||
settings.apply_env_overrides();
|
||||
assert!(
|
||||
settings.low_motion,
|
||||
"{var} must enable low_motion to prevent VTE flicker (#1470)"
|
||||
settings.constrained_frame_rate,
|
||||
"{var} must cap redraws to prevent VTE flicker (#1470)"
|
||||
);
|
||||
assert!(
|
||||
!settings.fancy_animations,
|
||||
"{var} must disable fancy_animations"
|
||||
!settings.low_motion,
|
||||
"{var} must not change motion semantics"
|
||||
);
|
||||
assert!(
|
||||
settings.fancy_animations,
|
||||
"{var} must not disable the ocean treatment"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3768,7 +3768,7 @@ impl ToolSpec for AgentTool {
|
||||
"model_strength": {
|
||||
"type": "string",
|
||||
"enum": ["same", "faster"],
|
||||
"description": "Optional child model strength. Use same when the child should be as capable as the current model. Use faster for type=explore, read-only lookup/search, status, or other low-risk tasks that can run on a smaller/faster same-family sibling; CodeWhale maps known families such as DeepSeek V4 Pro to Flash and GLM-5.2 to GLM-5-Turbo. type=explore defaults to faster unless you pass model_strength or model explicitly. No hidden auto-downgrade happens."
|
||||
"description": "Optional child model strength. Children inherit the active model by default, including type=explore. Choose faster explicitly for read-only lookup/search, status, or other low-risk tasks that can run on a smaller/faster same-family sibling; CodeWhale maps known families such as DeepSeek V4 Pro to Flash and GLM-5.2 to GLM-5-Turbo. No hidden auto-downgrade happens."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
@@ -6411,20 +6411,10 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
|
||||
.map(SubAgentModelStrength::parse)
|
||||
.transpose()?;
|
||||
let model_strength_explicit = explicit_model_strength.is_some();
|
||||
let model_strength = explicit_model_strength.unwrap_or_else(|| {
|
||||
// Default model strength. `type: "explore"` defaults to Faster for
|
||||
// bounded read-only lookup/search/status work — the cheap, fast
|
||||
// same-family sibling is exactly the lossy-breadth job a child
|
||||
// should run. Every other role (and any call that supplies an
|
||||
// explicit `model`) stays conservative at Same. Explicit
|
||||
// model_strength above already wins via .parse(); explicit `model`
|
||||
// wins downstream in assignment_model_route regardless of strength.
|
||||
if agent_type == SubAgentType::Explore && model.is_none() {
|
||||
SubAgentModelStrength::Faster
|
||||
} else {
|
||||
SubAgentModelStrength::Same
|
||||
}
|
||||
});
|
||||
// Fleet is predictable before setup: every role inherits the active model.
|
||||
// A cheaper sibling is an explicit routing choice through model_strength,
|
||||
// a saved Fleet profile, or a concrete model override.
|
||||
let model_strength = explicit_model_strength.unwrap_or(SubAgentModelStrength::Same);
|
||||
let thinking = optional_input_str(input, &["thinking", "reasoning_effort", "reasoningEffort"])
|
||||
.map(SubAgentThinking::parse)
|
||||
.transpose()?
|
||||
|
||||
@@ -966,16 +966,16 @@ fn test_parse_spawn_request_accepts_model_strength() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explore_subagent_defaults_to_faster_model_strength() {
|
||||
// type: "explore" with no model_strength and no model defaults to Faster:
|
||||
// bounded read-only lookup is exactly the cheap-sibling job.
|
||||
fn explore_subagent_inherits_active_model_by_default() {
|
||||
// Role names never silently change the model. A Fleet without custom
|
||||
// routing should behave exactly like the active session.
|
||||
let input = json!({
|
||||
"prompt": "find every caller of normalize_model_name",
|
||||
"type": "explore"
|
||||
});
|
||||
let parsed = parse_spawn_request(&input).expect("spawn request should parse");
|
||||
assert_eq!(parsed.agent_type, SubAgentType::Explore);
|
||||
assert_eq!(parsed.model_strength, SubAgentModelStrength::Faster);
|
||||
assert_eq!(parsed.model_strength, SubAgentModelStrength::Same);
|
||||
|
||||
// Explicit model_strength: "same" wins for explore too.
|
||||
let input = json!({
|
||||
@@ -988,7 +988,7 @@ fn explore_subagent_defaults_to_faster_model_strength() {
|
||||
assert_eq!(parsed.model_strength, SubAgentModelStrength::Same);
|
||||
|
||||
// An explicit model pins the child (downstream Fixed route) and disables
|
||||
// the explore→faster default, so model_strength falls back to Same.
|
||||
// any strength hint, so model_strength remains Same.
|
||||
let input = json!({
|
||||
"prompt": "explore on a specific model",
|
||||
"type": "explore",
|
||||
@@ -1234,7 +1234,7 @@ fn test_apply_spawn_profile_accepts_agreeing_explicit_type() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_spawn_profile_scout_yields_explore_type_and_faster_route() {
|
||||
fn test_apply_spawn_profile_scout_yields_explore_type_and_inherits_route() {
|
||||
let roster = FleetRoster::built_ins_only();
|
||||
let mut request = parse_spawn_request(&json!({"prompt": "map the parser", "profile": "scout"}))
|
||||
.expect("parse");
|
||||
@@ -1246,10 +1246,10 @@ fn test_apply_spawn_profile_scout_yields_explore_type_and_faster_route() {
|
||||
.expect("scout model selection");
|
||||
assert_eq!(
|
||||
selected.model_route,
|
||||
ModelRoute::Faster,
|
||||
"scout's fast loadout routes to the faster sibling"
|
||||
ModelRoute::Inherit,
|
||||
"without Fleet setup the scout inherits the active session model"
|
||||
);
|
||||
assert_eq!(selected.source, SpawnRouteSource::AgentProfileLoadout);
|
||||
assert_eq!(selected.source, SpawnRouteSource::RunModel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1811,8 +1811,9 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() {
|
||||
assert!(agent_schema["properties"].get("max_depth").is_some());
|
||||
let model_strength = schema_property_description(&agent_schema, "model_strength");
|
||||
assert!(
|
||||
model_strength.contains("type=explore") && model_strength.contains("faster"),
|
||||
"model_strength description should teach explore/faster routing: {model_strength}"
|
||||
model_strength.contains("inherit the active model")
|
||||
&& model_strength.contains("Choose faster explicitly"),
|
||||
"model_strength description should teach predictable default routing: {model_strength}"
|
||||
);
|
||||
let thinking = schema_property_description(&agent_schema, "thinking");
|
||||
assert!(
|
||||
|
||||
+35
-28
@@ -1096,9 +1096,7 @@ impl AppMode {
|
||||
}
|
||||
AppMode::Yolo => "Act mode with Full Access (legacy YOLO permission shorthand)",
|
||||
AppMode::Plan => "Plan mode - research and design before implementing",
|
||||
AppMode::Operate => {
|
||||
"Operate mode - manage Fleet, subagents, and workflow lanes (spawn, wait, verify, hand off)"
|
||||
}
|
||||
AppMode::Operate => "Operate mode - coordinate a Fleet for multi-step work",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1779,10 +1777,14 @@ pub struct App {
|
||||
pub auto_compact_threshold_percent: f64,
|
||||
pub calm_mode: bool,
|
||||
pub low_motion: bool,
|
||||
pub constrained_frame_rate: bool,
|
||||
pub ocean_started_at: Instant,
|
||||
/// Pending #61 (animated working strip). Set from config but not read
|
||||
/// until the footer widget consumes it.
|
||||
#[allow(dead_code)]
|
||||
pub fancy_animations: bool,
|
||||
/// `ombre` or `flat`; appearance is independent from motion settings.
|
||||
pub ocean_treatment: String,
|
||||
/// Whether the renderer should wrap each frame in DEC mode 2026
|
||||
/// synchronized output. Resolved from `Settings::synchronized_output`
|
||||
/// at construction; `auto`/`on` → `true`, `off` → `false`. The Ptyxis
|
||||
@@ -1791,10 +1793,10 @@ pub struct App {
|
||||
/// the draw loop the decision is already made. See the
|
||||
/// `Settings::synchronized_output` doc for the user-facing knob.
|
||||
pub synchronized_output_enabled: bool,
|
||||
/// Header status-indicator chip mode. One of `"whale"` (default, cycles
|
||||
/// 🐳→🐋 frames keyed off `turn_started_at`), `"dots"` (geometric ◌
|
||||
/// frames), or `"off"` (chip hidden entirely). Loaded from settings;
|
||||
/// changed via `/config status_indicator <whale|dots|off>`.
|
||||
/// Header status-indicator chip mode. `"cw"` is the static default;
|
||||
/// `"whale"` and `"dots"` preserve the animated legacy choices, while
|
||||
/// `"off"` hides the chip. Loaded from settings and changed via
|
||||
/// `/config status_indicator <cw|whale|dots|off>`.
|
||||
pub status_indicator: String,
|
||||
pub show_thinking: bool,
|
||||
pub verbose_transcript: bool,
|
||||
@@ -2536,7 +2538,9 @@ impl App {
|
||||
let auto_compact_threshold_percent = settings.auto_compact_threshold_percent;
|
||||
let calm_mode = settings.calm_mode;
|
||||
let low_motion = settings.low_motion;
|
||||
let constrained_frame_rate = settings.constrained_frame_rate;
|
||||
let fancy_animations = settings.fancy_animations;
|
||||
let ocean_treatment = settings.ocean_treatment.clone();
|
||||
let synchronized_output_enabled = settings.synchronized_output_enabled();
|
||||
let status_indicator = settings.status_indicator.clone();
|
||||
let show_thinking = settings.show_thinking;
|
||||
@@ -2837,7 +2841,10 @@ impl App {
|
||||
auto_compact_threshold_percent,
|
||||
calm_mode,
|
||||
low_motion,
|
||||
constrained_frame_rate,
|
||||
ocean_started_at: Instant::now(),
|
||||
fancy_animations,
|
||||
ocean_treatment,
|
||||
synchronized_output_enabled,
|
||||
status_indicator,
|
||||
show_thinking,
|
||||
@@ -3096,13 +3103,10 @@ impl App {
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Show the one-time first-run follow-up nudge. Idempotent and
|
||||
/// gated by a persisted `Settings::feature_intro_shown` flag, so it appears
|
||||
/// exactly once per install: after first-run setup handoff when no
|
||||
/// constitution checkpoint is due, and on the next launch for returning
|
||||
/// users who haven't seen it (called from `run_tui` after `App::new`).
|
||||
/// Plain copy, no marketing language. Stays silent while onboarding is
|
||||
/// still in progress.
|
||||
/// Mark the first-run follow-up as seen without inserting a transcript
|
||||
/// message. The empty underwater launch surface owns setup guidance; a
|
||||
/// synthetic history cell would hide that surface before the user sends
|
||||
/// anything.
|
||||
pub fn maybe_show_feature_intro(&mut self) {
|
||||
if self.onboarding != OnboardingState::None {
|
||||
return;
|
||||
@@ -3122,23 +3126,12 @@ impl App {
|
||||
self.status_message = Some(format!("Failed to save feature-intro flag: {err}"));
|
||||
// Still show the nudge; the flag write may simply retry next launch.
|
||||
}
|
||||
self.add_message(HistoryCell::System {
|
||||
content: Self::feature_intro_content(),
|
||||
});
|
||||
self.status_message = Some(
|
||||
"Fleet is ready · /fleet opens roles · /fleet setup customizes routes".to_string(),
|
||||
);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// The one-time first-run follow-up copy. Plain language, no
|
||||
/// marketing. Pure so it can be unit-tested without touching disk or env.
|
||||
pub(crate) fn feature_intro_content() -> String {
|
||||
"Your CodeWhale setup is ready.\n\n\
|
||||
• Constitution — review or personalize standing guidance with `/constitution`; run `/setup` for the full checkpoint any time.\n\
|
||||
• Provider and model — adjust the active route later with `/provider` or `/model`.\n\
|
||||
• Optional later — use `/hotbar` for Hotbar shortcuts (`/hotbar off` hides it) and `/fleet setup` for Fleet loadouts.\n\n\
|
||||
This tip won't show again."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Apply a locale tag selected from the onboarding language picker (#566).
|
||||
/// Persists the value to settings.toml and immediately
|
||||
/// re-resolves `ui_locale` so the rest of onboarding renders in the new
|
||||
@@ -3407,6 +3400,20 @@ impl App {
|
||||
.any(|task| matches!(task.status.as_str(), "queued" | "running"))
|
||||
}
|
||||
|
||||
/// Whether the interface is asking the user to make a decision. Ambient
|
||||
/// motion yields across the whole frame while this is true; freezing one
|
||||
/// task marker still leaves distracting movement in peripheral vision.
|
||||
#[must_use]
|
||||
pub fn attention_hold_active(&self) -> bool {
|
||||
!self.view_stack.is_empty()
|
||||
|| self.pending_user_input_prompt.is_some()
|
||||
|| self.plan_prompt_pending
|
||||
|| self
|
||||
.task_panel
|
||||
.iter()
|
||||
.any(|task| matches!(task.status.as_str(), "waiting" | "needs_user"))
|
||||
}
|
||||
|
||||
pub fn mark_approval_policy_locked(&mut self) {
|
||||
self.approval_policy_locked = true;
|
||||
}
|
||||
|
||||
@@ -43,19 +43,6 @@ fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::
|
||||
std::os::windows::fs::symlink_dir(target, link)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_intro_content_centers_constitution_follow_up() {
|
||||
let content = App::feature_intro_content();
|
||||
assert!(content.contains("Your CodeWhale setup is ready."));
|
||||
assert!(content.contains("Constitution"));
|
||||
assert!(content.contains("/constitution"));
|
||||
assert!(content.contains("/setup"));
|
||||
assert!(content.contains("/provider") && content.contains("/model"));
|
||||
assert!(content.contains("Optional later"));
|
||||
assert!(content.contains("/hotbar") && content.contains("/hotbar off"));
|
||||
assert!(content.contains("Fleet") && content.contains("/fleet setup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_intro_is_silent_while_onboarding_is_in_progress() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
@@ -104,22 +91,11 @@ fn feature_intro_shows_once_persists_then_is_idempotent() {
|
||||
let before = app.history.len();
|
||||
|
||||
app.maybe_show_feature_intro();
|
||||
assert_eq!(
|
||||
app.history.len(),
|
||||
before + 1,
|
||||
"intro should be added on the first call"
|
||||
);
|
||||
let content = match app.history.last() {
|
||||
Some(HistoryCell::System { content }) => content.clone(),
|
||||
other => panic!("expected a System intro cell, got {other:?}"),
|
||||
};
|
||||
assert_eq!(app.history.len(), before, "intro must not hide empty state");
|
||||
assert!(
|
||||
content.contains("Hotbar") && content.contains("/hotbar off"),
|
||||
"intro should explain Hotbar + the disable path: {content:?}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("Fleet") && content.contains("/fleet setup"),
|
||||
"intro should explain Fleet setup: {content:?}"
|
||||
app.status_message
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("Fleet") && message.contains("/fleet setup"))
|
||||
);
|
||||
|
||||
// Persisted flag now set → a second call is a no-op.
|
||||
@@ -132,7 +108,7 @@ fn feature_intro_shows_once_persists_then_is_idempotent() {
|
||||
app.maybe_show_feature_intro();
|
||||
assert_eq!(
|
||||
app.history.len(),
|
||||
before + 1,
|
||||
before,
|
||||
"intro must not repeat once the flag is persisted"
|
||||
);
|
||||
|
||||
@@ -2171,7 +2147,13 @@ fn set_mode_captures_agent_edits_as_the_durable_baseline() {
|
||||
|
||||
#[test]
|
||||
fn yolo_start_with_default_config_restores_interactive_agent_shell_baseline() {
|
||||
let mut app = App::new(test_options(true), &Config::default());
|
||||
let _env_lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
|
||||
let mut options = test_options(true);
|
||||
options.config_path = Some(config_path);
|
||||
let mut app = App::new(options, &Config::default());
|
||||
// --yolo starts in Agent mode with the full-access compat shim (M6).
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
assert!(app.yolo);
|
||||
@@ -2190,12 +2172,18 @@ fn yolo_start_with_default_config_restores_interactive_agent_shell_baseline() {
|
||||
|
||||
#[test]
|
||||
fn leaving_yolo_after_startup_restores_baseline_policies() {
|
||||
let _env_lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
|
||||
let config = Config {
|
||||
allow_shell: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut app = App::new(test_options(true), &config);
|
||||
let mut options = test_options(true);
|
||||
options.config_path = Some(config_path);
|
||||
let mut app = App::new(options, &config);
|
||||
// --yolo starts in Agent mode with the full-access compat shim (M6).
|
||||
assert_eq!(app.mode, AppMode::Agent);
|
||||
assert!(app.yolo);
|
||||
|
||||
@@ -181,6 +181,15 @@ impl AskRuleSavePreview {
|
||||
const ASK_RULE_SAVE_PREVIEW_MAX_ENTRIES: usize = 4;
|
||||
|
||||
impl ApprovalRequest {
|
||||
/// Mechanical repo-law asks are a distinct authority boundary, not an
|
||||
/// ordinary risk prompt. The engine stamps this stable prefix when a
|
||||
/// `.codewhale/constitution.json` ask rule forces review.
|
||||
#[must_use]
|
||||
pub fn is_repo_law_prompt(&self) -> bool {
|
||||
self.description.starts_with("Repo law holds this write:")
|
||||
&& self.description.contains(".codewhale/constitution.json")
|
||||
}
|
||||
|
||||
/// Presentation stakes for this request (see [`ApprovalStakes`]).
|
||||
#[must_use]
|
||||
pub fn stakes(&self) -> ApprovalStakes {
|
||||
|
||||
@@ -53,10 +53,9 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
})
|
||||
});
|
||||
|
||||
// Drive every cluster from the user's configured `status_items`. Mode
|
||||
// and Model are always rendered by `FooterProps` itself (their position
|
||||
// is structural — cluster gating is handled by the widget), so we only
|
||||
// gate the optional clusters here. If a variant is missing from
|
||||
// Drive every cluster from the user's configured `status_items`. The
|
||||
// header owns mode and model; the footer only owns turn state, cost, and
|
||||
// stable session/action chips. If a variant is missing from
|
||||
// `status_items`, its span vec stays empty and the footer hides it.
|
||||
let mut props = render_footer_from(app, &app.status_items, toast);
|
||||
// FooterProps is mut so the working-strip animation can layer on top.
|
||||
@@ -295,7 +294,7 @@ pub(crate) fn footer_working_label_frame(now_ms: u64, fancy_animations: bool) ->
|
||||
mod tests {
|
||||
use super::{
|
||||
active_subagent_status_label, footer_state_label, footer_working_label_frame,
|
||||
one_line_summary,
|
||||
one_line_summary, render_footer_from,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
@@ -406,6 +405,15 @@ mod tests {
|
||||
assert_eq!(label, "idle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_footer_does_not_repeat_header_model_or_mode() {
|
||||
let app = create_test_app();
|
||||
let props = render_footer_from(&app, &app.status_items, None);
|
||||
assert!(props.model.is_empty());
|
||||
assert!(props.mode_label.is_empty());
|
||||
assert_eq!(props.state_label, "idle");
|
||||
}
|
||||
|
||||
// #3189: provider-wait reason thresholds
|
||||
|
||||
#[test]
|
||||
@@ -684,11 +692,9 @@ pub(crate) fn one_line_summary(text: &str, max_width: usize) -> String {
|
||||
|
||||
/// Build [`FooterProps`] from a user-configured `status_items` slice.
|
||||
///
|
||||
/// Variants are routed to their structural cluster: `Mode` and `Model` are
|
||||
/// always emitted (the widget needs them to lay out the line correctly even
|
||||
/// when the user toggled them off the picker — we honour the toggle by
|
||||
/// blanking their visible content rather than collapsing the layout).
|
||||
/// `Cost` and `Status` belong in the left cluster; the rest in the right.
|
||||
/// Variants are routed to their structural cluster. Header-owned `Mode` and
|
||||
/// `Model` remain blank here; `Cost` and `Status` belong in the left cluster,
|
||||
/// and the rest in the right.
|
||||
///
|
||||
/// A variant absent from `items` produces an empty span vec, which the
|
||||
/// footer widget already hides cleanly. This keeps the renderer fully
|
||||
@@ -741,9 +747,8 @@ pub(crate) fn render_footer_from(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Build the props; `Mode` and `Model` toggles modulate downstream by
|
||||
// blanking the rendered text rather than restructuring the widget — the
|
||||
// user is opting out of the chip, not destroying the bar.
|
||||
// Build the props, then remove header-owned facts so the footer cannot
|
||||
// repeat them even when an older status_items list still contains Model.
|
||||
let mut props = FooterProps::from_app(
|
||||
app,
|
||||
toast,
|
||||
@@ -755,10 +760,7 @@ pub(crate) fn render_footer_from(
|
||||
cost,
|
||||
balance,
|
||||
);
|
||||
if !has(S::Model) {
|
||||
props.model.clear();
|
||||
}
|
||||
// Header owns the mode chip — footer keeps model/cost/status only.
|
||||
props.model.clear();
|
||||
props.mode_label = "";
|
||||
|
||||
// Shell-running chip: visible whenever foreground or background shell work
|
||||
|
||||
@@ -1821,8 +1821,16 @@ fn render_cycle_boundary(content: &str, width: u16) -> Vec<Line<'static>> {
|
||||
lines
|
||||
}
|
||||
|
||||
fn status_symbol(started_at: Option<Instant>, status: ToolStatus, low_motion: bool) -> String {
|
||||
fn status_symbol(
|
||||
started_at: Option<Instant>,
|
||||
status: ToolStatus,
|
||||
low_motion: bool,
|
||||
family: crate::tui::widgets::tool_card::ToolFamily,
|
||||
) -> String {
|
||||
match status {
|
||||
ToolStatus::Running if family == crate::tui::widgets::tool_card::ToolFamily::Verify => {
|
||||
crate::tui::spinner::verification_tick_frame(started_at, low_motion).to_string()
|
||||
}
|
||||
ToolStatus::Running => {
|
||||
crate::tui::spinner::braille_spinner_frame(started_at, low_motion).to_string()
|
||||
}
|
||||
@@ -1951,7 +1959,7 @@ fn render_tool_header_with_family_and_summary(
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!("{} ", status_symbol(started_at, status, low_motion)),
|
||||
format!("{} ", status_symbol(started_at, status, low_motion, family)),
|
||||
Style::default().fg(tool_state_color(status)),
|
||||
),
|
||||
Span::styled(
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(super) const TOOL_HEADER_SUMMARY_LIMIT: usize = 56;
|
||||
pub(super) const TOOL_OUTPUT_HEAD_LINES: usize = 2;
|
||||
pub(super) const TOOL_OUTPUT_TAIL_LINES: usize = 2;
|
||||
#[cfg(test)]
|
||||
pub(super) const TOOL_RUNNING_SYMBOLS: [&str; 12] = crate::tui::spinner::BRAILLE_SPINNER_FRAMES;
|
||||
pub(super) const TOOL_RUNNING_SYMBOLS: [&str; 8] = crate::tui::spinner::BRAILLE_SPINNER_FRAMES;
|
||||
#[cfg(test)]
|
||||
pub(super) const TOOL_STATUS_SYMBOL_MS: u64 = crate::tui::spinner::BRAILLE_SPINNER_FRAME_MS;
|
||||
/// Visual marker for the user role at the start of their message line. Solid
|
||||
@@ -22,7 +22,7 @@ pub(super) const ASSISTANT_GLYPH: &str = "\u{25CF}"; // ●
|
||||
/// competing with content.
|
||||
pub(super) const TRANSCRIPT_RAIL: &str = "\u{258F} "; // ▏ + space
|
||||
pub(super) const TOOL_CARD_SUMMARY_LINES: usize = 4;
|
||||
pub(super) const TOOL_DONE_SYMBOL: &str = "•";
|
||||
pub(super) const TOOL_FAILED_SYMBOL: &str = "•";
|
||||
pub(super) const TOOL_DONE_SYMBOL: &str = "✓";
|
||||
pub(super) const TOOL_FAILED_SYMBOL: &str = "✕";
|
||||
/// Compact Ctrl+B affordance for foreground shell waits in the live transcript.
|
||||
pub(super) const FOREGROUND_SHELL_WAIT_HINT: &str = "Ctrl+B → /jobs";
|
||||
|
||||
@@ -1029,7 +1029,12 @@ fn tool_lines_with_options_respects_low_motion_in_default_path() {
|
||||
// platforms with coarse timer resolution (Windows ≈ 15.6 ms) and
|
||||
// gives several frame intervals of headroom before the index could
|
||||
// wrap back to 0.
|
||||
let started_at = Some(Instant::now() - Duration::from_millis(TOOL_STATUS_SYMBOL_MS * 2));
|
||||
let started_at = Some(
|
||||
Instant::now()
|
||||
- Duration::from_millis(
|
||||
crate::tui::spinner::LIVE_MARKER_DELAY_MS + TOOL_STATUS_SYMBOL_MS * 2,
|
||||
),
|
||||
);
|
||||
let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
|
||||
command: "echo hi".to_string(),
|
||||
status: ToolStatus::Running,
|
||||
@@ -1058,8 +1063,9 @@ fn tool_lines_with_options_respects_low_motion_in_default_path() {
|
||||
let animated_symbol = animated[0].spans[1].content.trim();
|
||||
let low_motion_symbol = low_motion[0].spans[1].content.trim();
|
||||
|
||||
// low_motion always pins to the first (static) frame.
|
||||
assert_eq!(low_motion_symbol, TOOL_RUNNING_SYMBOLS[0]);
|
||||
// Reduced motion freezes at a filled, legible bubble rather than an
|
||||
// invisible blank braille cell.
|
||||
assert_eq!(low_motion_symbol, "⣤");
|
||||
// The animated path should be on a different frame (index 2).
|
||||
assert_ne!(animated_symbol, TOOL_RUNNING_SYMBOLS[0]);
|
||||
}
|
||||
|
||||
@@ -585,7 +585,7 @@ impl HotbarActionSource for BuiltinHotbarActionSource {
|
||||
"mode.operate",
|
||||
"operate",
|
||||
"Operate mode",
|
||||
"Manage Fleet workers, subagents, and workflow lanes.",
|
||||
"Coordinate a Fleet for multi-step work.",
|
||||
AppHotbarKind::Mode(AppMode::Operate),
|
||||
));
|
||||
registry.register(AppHotbarAction::new(
|
||||
|
||||
@@ -46,6 +46,7 @@ mod mcp_routing;
|
||||
pub mod model_picker;
|
||||
pub mod mouse_ui;
|
||||
pub mod notifications;
|
||||
pub mod ocean;
|
||||
pub mod onboarding;
|
||||
pub mod osc8;
|
||||
pub mod output_rows_cache;
|
||||
|
||||
@@ -24,7 +24,7 @@ use codewhale_config::pricing::OfferingPricing;
|
||||
use crate::codex_model_cache::{
|
||||
self, CodexModelCacheFreshness, CodexModelMetadata, CodexModelRoster,
|
||||
};
|
||||
use crate::config::{ApiProvider, Config};
|
||||
use crate::config::{ApiProvider, Config, DEEPSEEK_ALIAS_REPLACEMENT};
|
||||
use crate::model_profile::{
|
||||
CapabilityOverride, SupportState, resolved_capability_profile_with_overrides,
|
||||
};
|
||||
@@ -58,9 +58,11 @@ const AUTO_MODEL_PICKER_EFFORTS: &[ReasoningEffort] = &[ReasoningEffort::Auto];
|
||||
|
||||
/// `/model` catalog views (#4115).
|
||||
///
|
||||
/// Configured stays the conservative default. Discoverability views (Recent /
|
||||
/// Coding / Cheap / Long context) never auto-select a surprising route — the
|
||||
/// active model remains the selection until the operator moves.
|
||||
/// Configured stays the calm default. Typing searches every provider and a
|
||||
/// cross-provider selection switches its route transactionally, so `/provider`
|
||||
/// is never a prerequisite. Discoverability views (Recent / Coding / Cheap /
|
||||
/// Long context) never auto-select a surprising route — the active model
|
||||
/// remains the selection until the operator moves.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModelListView {
|
||||
Configured,
|
||||
@@ -139,6 +141,10 @@ enum Pane {
|
||||
|
||||
pub struct ModelPickerView {
|
||||
initial_model: String,
|
||||
/// Exact runtime value before the picker opened. Keep this raw so choosing
|
||||
/// the canonical replacement for a retired alias performs a real migration
|
||||
/// instead of being misclassified as "unchanged".
|
||||
previous_model: String,
|
||||
initial_provider: ApiProvider,
|
||||
initial_effort: ReasoningEffort,
|
||||
active_accepts_custom_model_ids: bool,
|
||||
@@ -197,6 +203,11 @@ impl ModelPickerView {
|
||||
pub fn new(app: &App, config: &Config) -> Self {
|
||||
let initial_model = if app.auto_model {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
picker_visible_model_id(app.api_provider, &app.model).to_string()
|
||||
};
|
||||
let previous_model = if app.auto_model {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
app.model.clone()
|
||||
};
|
||||
@@ -236,6 +247,7 @@ impl ModelPickerView {
|
||||
|
||||
let mut view = Self {
|
||||
initial_model,
|
||||
previous_model,
|
||||
initial_provider: app.api_provider,
|
||||
initial_effort,
|
||||
active_accepts_custom_model_ids: app.accepts_custom_model_ids(),
|
||||
@@ -514,7 +526,7 @@ impl ModelPickerView {
|
||||
model: self.resolved_model(),
|
||||
provider,
|
||||
effort: self.resolved_effort(),
|
||||
previous_model: self.initial_model.clone(),
|
||||
previous_model: self.previous_model.clone(),
|
||||
previous_effort: self.initial_effort,
|
||||
}
|
||||
}
|
||||
@@ -683,7 +695,7 @@ fn fit_text(text: &str, width: usize) -> String {
|
||||
#[cfg(test)]
|
||||
fn picker_model_ids_for_provider(provider: ApiProvider) -> Vec<String> {
|
||||
let mut models = vec!["auto".to_string()];
|
||||
for id in all_catalog_models_for_provider(provider) {
|
||||
for id in provider_catalog_model_ids(provider) {
|
||||
if id != "auto" && !models.iter().any(|m| m.eq_ignore_ascii_case(&id)) {
|
||||
models.push(id);
|
||||
}
|
||||
@@ -713,7 +725,10 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
|
||||
.map(|model| model.trim())
|
||||
.filter(|model| !model.is_empty())
|
||||
{
|
||||
push_model_id(&mut models, model);
|
||||
push_model_id(
|
||||
&mut models,
|
||||
picker_visible_model_id(app.api_provider, model),
|
||||
);
|
||||
}
|
||||
models
|
||||
} else {
|
||||
@@ -743,7 +758,7 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
|
||||
.map(|model| model.trim())
|
||||
.filter(|model| !model.is_empty())
|
||||
{
|
||||
push_model_id(&mut model_ids, model);
|
||||
push_model_id(&mut model_ids, picker_visible_model_id(provider, model));
|
||||
}
|
||||
push_provider_model_rows(
|
||||
&mut rows,
|
||||
@@ -796,7 +811,11 @@ fn push_provider_model_rows(
|
||||
}
|
||||
|
||||
fn provider_catalog_model_ids(provider: ApiProvider) -> Vec<String> {
|
||||
all_catalog_models_for_provider(provider)
|
||||
let mut models = Vec::new();
|
||||
for id in all_catalog_models_for_provider(provider) {
|
||||
push_model_id(&mut models, picker_visible_model_id(provider, &id));
|
||||
}
|
||||
models
|
||||
}
|
||||
|
||||
fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) -> Vec<String> {
|
||||
@@ -804,7 +823,7 @@ fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) ->
|
||||
// separate custom/current-model row.
|
||||
let mut models = Vec::new();
|
||||
push_model_id(&mut models, "auto");
|
||||
for id in all_catalog_models_for_provider(app.api_provider) {
|
||||
for id in provider_catalog_model_ids(app.api_provider) {
|
||||
push_model_id(&mut models, &id);
|
||||
}
|
||||
|
||||
@@ -814,11 +833,17 @@ fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) ->
|
||||
.map(|model| model.trim())
|
||||
.filter(|model| !model.is_empty())
|
||||
{
|
||||
push_model_id(&mut models, model);
|
||||
push_model_id(
|
||||
&mut models,
|
||||
picker_visible_model_id(app.api_provider, model),
|
||||
);
|
||||
}
|
||||
|
||||
if include_current_model && !app.auto_model {
|
||||
push_model_id(&mut models, app.model.trim());
|
||||
push_model_id(
|
||||
&mut models,
|
||||
picker_visible_model_id(app.api_provider, app.model.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
models
|
||||
@@ -837,6 +862,20 @@ fn push_model_id(models: &mut Vec<String>, model: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep temporary DeepSeek compatibility aliases callable without presenting
|
||||
/// them as current model choices. This is deliberately provider-scoped:
|
||||
/// `deepseek-reasoner` is a native wire id for providers such as Wanjie Ark.
|
||||
fn picker_visible_model_id(provider: ApiProvider, model: &str) -> &str {
|
||||
if provider == ApiProvider::Deepseek
|
||||
&& (model.eq_ignore_ascii_case("deepseek-chat")
|
||||
|| model.eq_ignore_ascii_case("deepseek-reasoner"))
|
||||
{
|
||||
DEEPSEEK_ALIAS_REPLACEMENT
|
||||
} else {
|
||||
model
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_query_splits(query: &str) -> Vec<(&str, &str)> {
|
||||
let trimmed = query.trim();
|
||||
let mut splits = Vec::new();
|
||||
@@ -899,17 +938,34 @@ fn model_row_matches_query(
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let provider_matches = row.provider.is_some_and(|provider| {
|
||||
provider.as_str().to_ascii_lowercase().contains(&query)
|
||||
|| provider
|
||||
.display_name()
|
||||
.to_ascii_lowercase()
|
||||
.contains(&query)
|
||||
});
|
||||
let normalized_query = normalize_picker_search_text(&query);
|
||||
let matches = |candidate: &str| {
|
||||
let candidate = candidate.to_ascii_lowercase();
|
||||
candidate.contains(&query)
|
||||
|| normalize_picker_search_text(&candidate).contains(&normalized_query)
|
||||
};
|
||||
let provider_matches = row
|
||||
.provider
|
||||
.is_some_and(|provider| matches(provider.as_str()) || matches(provider.display_name()));
|
||||
provider_matches
|
||||
|| row.id.to_ascii_lowercase().contains(&query)
|
||||
|| matches(&row.id)
|
||||
|| ((row.provider.is_none() || row.provider == Some(initial_provider))
|
||||
&& row.hint.to_ascii_lowercase().contains(&query))
|
||||
&& matches(&row.hint))
|
||||
}
|
||||
|
||||
fn normalize_picker_search_text(text: &str) -> String {
|
||||
text.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn model_row_label(row: &ModelPickerRow, initial_provider: ApiProvider) -> String {
|
||||
@@ -1396,11 +1452,10 @@ impl ModelPickerView {
|
||||
let inner = outer.inner(popup_area);
|
||||
outer.render(popup_area, buf);
|
||||
|
||||
// Keep the long-standing "browse all" label on Configured so footer
|
||||
// discoverability tests and muscle memory stay intact; other views
|
||||
// preview the next named view (#4115).
|
||||
// Say what the action does in model language. Provider changes are an
|
||||
// implementation detail of applying a cross-provider model row.
|
||||
let view_action = match self.view {
|
||||
ModelListView::Configured => "browse all",
|
||||
ModelListView::Configured => "browse catalog",
|
||||
other => other.next().title_label(),
|
||||
};
|
||||
let content = render_modal_footer(
|
||||
@@ -1409,7 +1464,7 @@ impl ModelPickerView {
|
||||
&[
|
||||
ActionHint::new("↑↓", "move"),
|
||||
ActionHint::new("Tab", "switch"),
|
||||
ActionHint::new("Type", "filter"),
|
||||
ActionHint::new("Type", "search any model"),
|
||||
ActionHint::new("Enter", "apply"),
|
||||
ActionHint::new("A", view_action),
|
||||
ActionHint::new("Esc", "cancel"),
|
||||
@@ -2052,6 +2107,78 @@ mod tests {
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muse_session_can_select_deepseek_flash_without_provider_first() {
|
||||
let (mut app, config, _lock) = create_test_app();
|
||||
app.api_provider = crate::config::ApiProvider::Meta;
|
||||
app.model = "muse-spark-1.1".to_string();
|
||||
app.auto_model = false;
|
||||
|
||||
let mut view = ModelPickerView::new(&app, &config);
|
||||
assert_eq!(view.view, ModelListView::Configured);
|
||||
type_model_query(&mut view, "deepseek v4 flash");
|
||||
let flash = view
|
||||
.visible_model_rows()
|
||||
.iter()
|
||||
.position(|row| {
|
||||
row.id == "deepseek-v4-flash"
|
||||
&& row.provider == Some(crate::config::ApiProvider::Deepseek)
|
||||
})
|
||||
.expect("typing a model name searches every provider");
|
||||
view.selected_model_idx = flash;
|
||||
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
|
||||
assert_eq!(
|
||||
view.resolved_provider(),
|
||||
Some(crate::config::ApiProvider::Deepseek)
|
||||
);
|
||||
assert!(matches!(
|
||||
view.build_event(),
|
||||
ViewEvent::ModelPickerApplied {
|
||||
provider: Some(crate::config::ApiProvider::Deepseek),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_deepseek_alias_is_migrated_out_of_picker_choices() {
|
||||
let (mut app, config, _lock) = create_test_app();
|
||||
app.api_provider = crate::config::ApiProvider::Deepseek;
|
||||
app.model = "deepseek-reasoner".to_string();
|
||||
app.auto_model = false;
|
||||
app.provider_models
|
||||
.insert("deepseek".to_string(), "deepseek-reasoner".to_string());
|
||||
|
||||
let view = ModelPickerView::new(&app, &config);
|
||||
let ids = view.visible_model_ids();
|
||||
assert!(ids.contains(&"deepseek-v4-flash"));
|
||||
assert!(!ids.contains(&"deepseek-chat"));
|
||||
assert!(!ids.contains(&"deepseek-reasoner"));
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
|
||||
assert!(matches!(
|
||||
view.build_event(),
|
||||
ViewEvent::ModelPickerApplied {
|
||||
model,
|
||||
previous_model,
|
||||
..
|
||||
} if model == "deepseek-v4-flash" && previous_model == "deepseek-reasoner"
|
||||
));
|
||||
|
||||
let completions = provider_scoped_model_completion_ids(&app);
|
||||
assert!(completions.iter().any(|id| id == "deepseek-v4-flash"));
|
||||
assert!(!completions.iter().any(|id| id == "deepseek-chat"));
|
||||
assert!(!completions.iter().any(|id| id == "deepseek-reasoner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_native_reasoner_id_is_not_globally_rewritten() {
|
||||
assert_eq!(
|
||||
picker_visible_model_id(crate::config::ApiProvider::WanjieArk, "deepseek-reasoner"),
|
||||
"deepseek-reasoner"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_initial_selection_matches_auto_state() {
|
||||
let (mut app, config, _lock) = create_test_app();
|
||||
@@ -2453,6 +2580,21 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_row_query_treats_hyphens_like_human_word_breaks() {
|
||||
let row = ModelPickerRow {
|
||||
id: "deepseek-v4-flash".to_string(),
|
||||
provider: Some(ApiProvider::Deepseek),
|
||||
hint: String::new(),
|
||||
metadata: EffectivePickerMetadata::default(),
|
||||
};
|
||||
assert!(model_row_matches_query(
|
||||
&row,
|
||||
"deepseek v4 flash",
|
||||
ApiProvider::Meta
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_row_query_no_field_match_returns_false() {
|
||||
let row = cross_provider_row();
|
||||
@@ -3149,7 +3291,14 @@ mod tests {
|
||||
let text = rows.join("\n");
|
||||
|
||||
// Footer keeps every action (it wraps instead of clipping).
|
||||
for label in ["move", "switch", "filter", "apply", "browse all", "cancel"] {
|
||||
for label in [
|
||||
"move",
|
||||
"switch",
|
||||
"search any model",
|
||||
"apply",
|
||||
"browse catalog",
|
||||
"cancel",
|
||||
] {
|
||||
assert!(text.contains(label), "{w}x{h}: missing '{label}' hint");
|
||||
}
|
||||
// The shared list/detail layout keeps both picker panes visible;
|
||||
|
||||
@@ -269,13 +269,7 @@ pub fn clear_taskbar_progress() {
|
||||
set_taskbar_progress(0, None);
|
||||
}
|
||||
|
||||
/// Animation frame characters for the terminal title.
|
||||
/// Uses the DeepSeek whale emoji (🐳 spouting, 🐋 resting) to match the
|
||||
/// existing header status indicator in the TUI.
|
||||
const TITLE_FRAMES: &[&str] = &["🐳", "🐋", "🐳", "🐋"];
|
||||
const TITLE_ANIMATION_INTERVAL: Duration = Duration::from_millis(800);
|
||||
|
||||
/// Shared flag controlling the title animation loop. Set to `true` by
|
||||
/// Shared flag controlling the title activity marker. Set to `true` by
|
||||
/// `start_title_animation()`, cleared by `stop_title_animation()`.
|
||||
static TITLE_ANIMATION_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -287,58 +281,30 @@ fn set_terminal_title(title: &str) {
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
|
||||
/// Tracks whether the ✅ completion marker was set, so
|
||||
/// Tracks whether the completion marker was set, so
|
||||
/// `reset_title_on_interaction()` can skip redundant writes.
|
||||
static COMPLETION_MARKER_SHOWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Start an animated terminal title spinner.
|
||||
///
|
||||
/// Cycles the terminal title between 🐳→🐋 every 800ms while processing,
|
||||
/// matching the whale status indicator in the TUI header, so alt-tabbed
|
||||
/// users can see activity.
|
||||
///
|
||||
/// The animation runs in a background tokio task that checks
|
||||
/// `TITLE_ANIMATION_RUNNING`. Each call restarts the animation with the
|
||||
/// given `original` base title — safe to call on every turn start.
|
||||
/// Mark the terminal title as active. Window chrome stays static so an
|
||||
/// alt-tabbed session communicates state without another competing spinner.
|
||||
pub fn start_title_animation(original: &str) {
|
||||
// Signal any existing animation loop to exit, then start fresh.
|
||||
TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst);
|
||||
let base = original.to_string();
|
||||
tokio::spawn(async move {
|
||||
let mut frame = 0usize;
|
||||
while TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
|
||||
// Yield once per frame so a racing stop_title_animation()
|
||||
// can observe the cleared flag and apply the completion
|
||||
// marker before the next frame write. Without this yield
|
||||
// the background task could overwrite the ✅ marker with
|
||||
// the next whale frame.
|
||||
tokio::task::yield_now().await;
|
||||
if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let spinner = TITLE_FRAMES[frame % TITLE_FRAMES.len()];
|
||||
set_terminal_title(&format!("{spinner} {base}"));
|
||||
frame += 1;
|
||||
tokio::time::sleep(TITLE_ANIMATION_INTERVAL).await;
|
||||
}
|
||||
// Don't restore title here — stop_title_animation() handles
|
||||
// what to show on completion (e.g. ✅ marker).
|
||||
});
|
||||
set_terminal_title(&format!("› {original}"));
|
||||
}
|
||||
|
||||
/// Stop the title animation and show a completion marker.
|
||||
///
|
||||
/// Sets the title to `✅ <base>` so alt-tabbed users see at a glance
|
||||
/// Sets the title to `✓ <base>` so alt-tabbed users see at a glance
|
||||
/// that processing finished. The marker is overwritten on the next turn
|
||||
/// by [`start_title_animation`].
|
||||
pub fn stop_title_animation() {
|
||||
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
|
||||
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
|
||||
// Show ✅ marker only for beep mode. Bell mode already has its own
|
||||
// Show a completion marker only for beep mode. Bell mode already has its own
|
||||
// terminal-level visual indicator (flash/icon).
|
||||
let mode = COMPLETION_SOUND_MODE.load(Ordering::SeqCst);
|
||||
if mode == 1 {
|
||||
set_terminal_title("✅ CodeWhale");
|
||||
set_terminal_title("✓ CodeWhale");
|
||||
}
|
||||
play_completion_sound();
|
||||
}
|
||||
@@ -353,7 +319,7 @@ pub fn stop_title_animation_quietly() {
|
||||
set_terminal_title("CodeWhale");
|
||||
}
|
||||
|
||||
/// Clear the ✅ completion marker from the title when the user interacts.
|
||||
/// Clear the completion marker from the title when the user interacts.
|
||||
///
|
||||
/// Call this on every user input event (key press, mouse click) so the
|
||||
/// marker doesn't persist once the user is back at the terminal.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Terminal-native underwater field for the CodeWhale transcript.
|
||||
//!
|
||||
//! The field is atmosphere, never content: callers paint it only into cells
|
||||
//! outside occupied transcript text. Reduced motion freezes the field but does
|
||||
//! not remove it, so choosing an underwater treatment always has a visible
|
||||
//! result.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::palette::{PaletteMode, UiTheme};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OceanRamp {
|
||||
pub surface: Color,
|
||||
pub middle: Color,
|
||||
pub deep: Color,
|
||||
pub ambient: Color,
|
||||
}
|
||||
|
||||
impl OceanRamp {
|
||||
#[must_use]
|
||||
pub fn for_theme(theme: &UiTheme) -> Option<Self> {
|
||||
let base = rgb(theme.surface_bg)?;
|
||||
let seafoam = rgb(theme.accent_secondary).unwrap_or((79, 209, 197));
|
||||
|
||||
let (surface, middle, deep) = match theme.mode {
|
||||
PaletteMode::Light | PaletteMode::SolarizedLight => (
|
||||
mix(base, seafoam, 0.07),
|
||||
mix(base, seafoam, 0.13),
|
||||
mix(base, (70, 139, 196), 0.18),
|
||||
),
|
||||
PaletteMode::Dark | PaletteMode::Grayscale => (
|
||||
mix(base, (30, 71, 103), 0.24),
|
||||
mix(base, (7, 30, 54), 0.40),
|
||||
mix(base, (2, 9, 24), 0.64),
|
||||
),
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
surface: color(surface),
|
||||
middle: color(middle),
|
||||
deep: color(deep),
|
||||
ambient: color(mix(seafoam, base, 0.42)),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn color_at(self, row: u16, height: u16) -> Color {
|
||||
if height <= 1 {
|
||||
return self.surface;
|
||||
}
|
||||
let position = f32::from(row.min(height - 1)) / f32::from(height - 1);
|
||||
if position <= 0.42 {
|
||||
mix_colors(self.surface, self.middle, position / 0.42)
|
||||
} else {
|
||||
mix_colors(self.middle, self.deep, (position - 0.42) / 0.58)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn color_at_phase(self, row: u16, height: u16, elapsed_ms: u128) -> Color {
|
||||
let base = self.color_at(row, height);
|
||||
let depth = if height <= 1 {
|
||||
0.0
|
||||
} else {
|
||||
f32::from(row.min(height - 1)) / f32::from(height - 1)
|
||||
};
|
||||
let cycle = (elapsed_ms % 18_000) as f32 / 18_000.0;
|
||||
let breath = (cycle * std::f32::consts::TAU).sin() * 0.5 + 0.5;
|
||||
mix_colors(base, self.ambient, breath * 0.045 * (1.0 - depth))
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn rgb(value: Color) -> Option<(u8, u8, u8)> {
|
||||
match value {
|
||||
Color::Rgb(r, g, b) => Some((r, g, b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn color((r, g, b): (u8, u8, u8)) -> Color {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn mix_colors(from: Color, to: Color, amount: f32) -> Color {
|
||||
match (rgb(from), rgb(to)) {
|
||||
(Some(from), Some(to)) => color(mix(from, to, amount)),
|
||||
_ => from,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn mix(from: (u8, u8, u8), to: (u8, u8, u8), amount: f32) -> (u8, u8, u8) {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
let channel = |a: u8, b: u8| {
|
||||
(f32::from(a) + (f32::from(b) - f32::from(a)) * amount)
|
||||
.round()
|
||||
.clamp(0.0, 255.0) as u8
|
||||
};
|
||||
(
|
||||
channel(from.0, to.0),
|
||||
channel(from.1, to.1),
|
||||
channel(from.2, to.2),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn distance(a: Color, b: Color) -> u16 {
|
||||
let (ar, ag, ab) = rgb(a).expect("RGB color");
|
||||
let (br, bg, bb) = rgb(b).expect("RGB color");
|
||||
ar.abs_diff(br) as u16 + ag.abs_diff(bg) as u16 + ab.abs_diff(bb) as u16
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_ramp_is_perceptibly_deep_not_merely_non_equal() {
|
||||
let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
|
||||
assert!(
|
||||
distance(ramp.surface, ramp.deep) >= 32,
|
||||
"the selected underwater treatment must read at a glance"
|
||||
);
|
||||
assert_ne!(ramp.color_at(0, 20), ramp.color_at(19, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_theme_stays_light_enough_for_light_theme_text() {
|
||||
let ramp = OceanRamp::for_theme(&crate::palette::LIGHT_UI_THEME).expect("RGB theme");
|
||||
let (r, g, b) = rgb(ramp.deep).expect("RGB color");
|
||||
assert!(u16::from(r) + u16::from(g) + u16::from(b) > 420);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_terminal_background_reports_no_ramp() {
|
||||
let mut theme = crate::palette::UI_THEME;
|
||||
theme.surface_bg = Color::Reset;
|
||||
assert_eq!(OceanRamp::for_theme(&theme), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shimmer_is_subtle_and_concentrated_near_the_surface() {
|
||||
let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
|
||||
let surface_a = ramp.color_at_phase(0, 20, 0);
|
||||
let surface_b = ramp.color_at_phase(0, 20, 4_500);
|
||||
let deep_a = ramp.color_at_phase(19, 20, 0);
|
||||
let deep_b = ramp.color_at_phase(19, 20, 4_500);
|
||||
|
||||
let surface_shift = distance(surface_a, surface_b);
|
||||
assert!(
|
||||
(1..=12).contains(&surface_shift),
|
||||
"surface shift was {surface_shift}"
|
||||
);
|
||||
assert_eq!(
|
||||
deep_a, deep_b,
|
||||
"the floor should stay perceptually anchored"
|
||||
);
|
||||
}
|
||||
}
|
||||
+332
-27
@@ -20,6 +20,7 @@ use ratatui::{
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Paragraph, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::deepseek_theme::Theme;
|
||||
use crate::palette;
|
||||
@@ -109,6 +110,221 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &mut App, config: &Config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the transcript-top Tasks / To-do strip from the underwater TUI
|
||||
/// action spec. It collapses completely when neither durable background work
|
||||
/// nor current work state exists, preserving the full launch water field.
|
||||
pub(crate) fn top_work_strip_height(app: &mut App, width: u16) -> u16 {
|
||||
let has_tasks = app
|
||||
.task_panel
|
||||
.iter()
|
||||
.any(|task| task.kind == TaskPanelEntryKind::Background);
|
||||
let has_todo = sidebar_work_summary(app).has_useful_content();
|
||||
if !has_tasks && !has_todo {
|
||||
0
|
||||
} else if width >= 88 {
|
||||
4
|
||||
} else {
|
||||
7
|
||||
}
|
||||
}
|
||||
|
||||
/// Render durable Tasks and the current To-do above the transcript. This is a
|
||||
/// compact proof strip; dense telemetry remains in the inspectors.
|
||||
pub(crate) fn render_top_work_strip(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
Block::default()
|
||||
.style(Style::default().bg(app.ui_theme.surface_bg))
|
||||
.render(area, f.buffer_mut());
|
||||
|
||||
let summary = sidebar_work_summary(app);
|
||||
let has_tasks = app
|
||||
.task_panel
|
||||
.iter()
|
||||
.any(|task| task.kind == TaskPanelEntryKind::Background);
|
||||
let has_todo = summary.has_useful_content();
|
||||
if has_tasks && !has_todo {
|
||||
render_top_tasks(
|
||||
f,
|
||||
Rect {
|
||||
height: area.height.saturating_sub(1),
|
||||
..area
|
||||
},
|
||||
app,
|
||||
);
|
||||
render_top_strip_rule(f, area, app);
|
||||
return;
|
||||
}
|
||||
if has_todo && !has_tasks {
|
||||
render_top_todo(
|
||||
f,
|
||||
Rect {
|
||||
height: area.height.saturating_sub(1),
|
||||
..area
|
||||
},
|
||||
&summary,
|
||||
app,
|
||||
);
|
||||
render_top_strip_rule(f, area, app);
|
||||
return;
|
||||
}
|
||||
let wide = area.width >= 88;
|
||||
let sections = Layout::default()
|
||||
.direction(if wide {
|
||||
Direction::Horizontal
|
||||
} else {
|
||||
Direction::Vertical
|
||||
})
|
||||
.constraints(if wide {
|
||||
vec![Constraint::Percentage(50), Constraint::Percentage(50)]
|
||||
} else {
|
||||
vec![Constraint::Length(3), Constraint::Length(3)]
|
||||
})
|
||||
.split(Rect {
|
||||
height: area.height.saturating_sub(1),
|
||||
..area
|
||||
});
|
||||
|
||||
render_top_tasks(f, sections[0], app);
|
||||
render_top_todo(f, sections[1], &summary, app);
|
||||
|
||||
render_top_strip_rule(f, area, app);
|
||||
}
|
||||
|
||||
fn render_top_strip_rule(f: &mut Frame, area: Rect, app: &App) {
|
||||
let rule_y = area.bottom().saturating_sub(1);
|
||||
for x in area.left()..area.right() {
|
||||
f.buffer_mut()[(x, rule_y)]
|
||||
.set_symbol("─")
|
||||
.set_fg(app.ui_theme.border)
|
||||
.set_bg(app.ui_theme.surface_bg);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_top_tasks(f: &mut Frame, area: Rect, app: &App) {
|
||||
let tasks = app
|
||||
.task_panel
|
||||
.iter()
|
||||
.filter(|task| task.kind == TaskPanelEntryKind::Background)
|
||||
.collect::<Vec<_>>();
|
||||
let waiting_for_user = tasks
|
||||
.iter()
|
||||
.any(|task| matches!(task.status.as_str(), "waiting" | "needs_user"));
|
||||
let width = usize::from(area.width.saturating_sub(2)).max(1);
|
||||
let mut lines = vec![Line::from(vec![
|
||||
Span::styled(
|
||||
"▾ Tasks",
|
||||
Style::default().fg(app.ui_theme.accent_primary).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", tasks.len()),
|
||||
Style::default().fg(app.ui_theme.text_muted),
|
||||
),
|
||||
])];
|
||||
for task in tasks.into_iter().take(2) {
|
||||
let (mark, mark_color, label_color) = match task.status.as_str() {
|
||||
"waiting" | "needs_user" => ("◆", app.ui_theme.error_fg, app.ui_theme.error_fg),
|
||||
"running" => (
|
||||
background_task_spinner_prefix(task, app.low_motion || waiting_for_user)
|
||||
.unwrap_or("›"),
|
||||
if waiting_for_user {
|
||||
app.ui_theme.text_dim
|
||||
} else {
|
||||
app.ui_theme.status_working
|
||||
},
|
||||
if waiting_for_user {
|
||||
app.ui_theme.text_dim
|
||||
} else {
|
||||
app.ui_theme.text_body
|
||||
},
|
||||
),
|
||||
"queued" => ("☐", app.ui_theme.text_muted, app.ui_theme.text_muted),
|
||||
"completed" | "success" => ("✓", app.ui_theme.success, app.ui_theme.text_muted),
|
||||
"failed" => ("✕", app.ui_theme.error_fg, app.ui_theme.text_body),
|
||||
"canceled" => ("✕", app.ui_theme.text_dim, app.ui_theme.text_dim),
|
||||
_ => ("·", app.ui_theme.text_muted, app.ui_theme.text_muted),
|
||||
};
|
||||
let duration = task.duration_ms.map(format_duration_ms).unwrap_or_default();
|
||||
let duration_width = UnicodeWidthStr::width(duration.as_str());
|
||||
let summary_width = width
|
||||
.saturating_sub(3 + UnicodeWidthStr::width(mark) + duration_width + 2)
|
||||
.max(1);
|
||||
let summary = truncate_line_to_width(&task.prompt_summary, summary_width);
|
||||
let used = 3 + UnicodeWidthStr::width(mark) + UnicodeWidthStr::width(summary.as_str());
|
||||
let gap = if duration.is_empty() {
|
||||
0
|
||||
} else {
|
||||
width.saturating_sub(used + duration_width).max(2)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(mark, Style::default().fg(mark_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(summary, Style::default().fg(label_color)),
|
||||
Span::raw(" ".repeat(gap)),
|
||||
Span::styled(duration, Style::default().fg(app.ui_theme.text_hint)),
|
||||
]));
|
||||
}
|
||||
Paragraph::new(lines).render(area, f.buffer_mut());
|
||||
}
|
||||
|
||||
fn render_top_todo(f: &mut Frame, area: Rect, summary: &SidebarWorkSummary, app: &App) {
|
||||
let total = summary.checklist_items.len();
|
||||
let completed = summary
|
||||
.checklist_items
|
||||
.iter()
|
||||
.filter(|item| item.status == TodoStatus::Completed)
|
||||
.count();
|
||||
let width = usize::from(area.width.saturating_sub(2)).max(1);
|
||||
let mut lines = vec![Line::from(vec![
|
||||
Span::styled(
|
||||
"▾ To-do",
|
||||
Style::default().fg(app.ui_theme.accent_primary).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
if total == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {completed}/{total}")
|
||||
},
|
||||
Style::default().fg(app.ui_theme.text_muted),
|
||||
),
|
||||
])];
|
||||
|
||||
let active = summary
|
||||
.checklist_items
|
||||
.iter()
|
||||
.find(|item| item.status == TodoStatus::InProgress)
|
||||
.or_else(|| {
|
||||
summary
|
||||
.checklist_items
|
||||
.iter()
|
||||
.find(|item| item.status == TodoStatus::Pending)
|
||||
});
|
||||
if let Some(item) = active {
|
||||
let mark = if item.status == TodoStatus::InProgress {
|
||||
"▸"
|
||||
} else {
|
||||
"☐"
|
||||
};
|
||||
let color = if item.status == TodoStatus::InProgress {
|
||||
app.ui_theme.accent_primary
|
||||
} else {
|
||||
app.ui_theme.text_muted
|
||||
};
|
||||
let label = truncate_line_to_width(&format!(" {mark} {}", item.content), width);
|
||||
lines.push(Line::from(Span::styled(label, Style::default().fg(color))));
|
||||
} else if let Some(goal) = summary.goal_objective.as_deref() {
|
||||
let label = truncate_line_to_width(&format!(" ◆ {goal}"), width);
|
||||
lines.push(Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(app.ui_theme.text_body),
|
||||
)));
|
||||
}
|
||||
Paragraph::new(lines).render(area, f.buffer_mut());
|
||||
}
|
||||
|
||||
fn split_sidebar_hotbar_area(area: Rect, show_hotbar: bool) -> (Rect, Option<Rect>) {
|
||||
// Hide the Hotbar entirely when the user disabled it (`hotbar = []`) or when
|
||||
// the sidebar is too short to fit it; give the main panel the full area.
|
||||
@@ -1518,7 +1734,7 @@ fn task_panel_rows(
|
||||
.map(format_duration_ms)
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let (label, detail) = background_task_labels(task, &duration);
|
||||
let label = background_task_spinner_prefix(task)
|
||||
let label = background_task_spinner_prefix(task, app.low_motion)
|
||||
.map(|prefix| format!("{prefix} {label}"))
|
||||
.unwrap_or(label);
|
||||
let (show_action, detail_action) = background_task_click_actions(task);
|
||||
@@ -1658,7 +1874,7 @@ fn task_panel_hover_texts(app: &App, row_sets: &TaskPanelRowSets, max_rows: usiz
|
||||
.map(format_duration_ms)
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let (label, detail) = background_task_labels(task, &duration);
|
||||
let label = background_task_spinner_prefix(task)
|
||||
let label = background_task_spinner_prefix(task, app.low_motion)
|
||||
.map(|prefix| format!("{prefix} {label}"))
|
||||
.unwrap_or(label);
|
||||
texts.push(label);
|
||||
@@ -1848,13 +2064,13 @@ fn background_task_is_live(task: &TaskPanelEntry) -> bool {
|
||||
&& matches!(task.status.as_str(), "queued" | "running")
|
||||
}
|
||||
|
||||
fn background_task_spinner_prefix(task: &TaskPanelEntry) -> Option<&'static str> {
|
||||
fn background_task_spinner_prefix(task: &TaskPanelEntry, low_motion: bool) -> Option<&'static str> {
|
||||
if task.status != "running" {
|
||||
return None;
|
||||
}
|
||||
Some(braille_spinner_frame_for_duration_ms(
|
||||
task.duration_ms.unwrap_or_default(),
|
||||
false,
|
||||
low_motion,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -3610,11 +3826,11 @@ mod tests {
|
||||
agent_row_hover_text, auto_sidebar_panels, background_task_spinner_prefix,
|
||||
context_panel_cost_line, editorial_tool_rows, hotbar_panel_enabled,
|
||||
hotbar_panel_hover_texts, hotbar_panel_lines, hotbar_panel_slots, is_hotbar_disabled,
|
||||
normalize_activity_text, render_sidebar, sidebar_agent_rows, sidebar_hover_rows,
|
||||
sidebar_work_summary, sort_sidebar_agent_rows_as_tree, subagent_output_handle,
|
||||
subagent_panel_hover_texts, subagent_panel_lines, subagent_panel_rows,
|
||||
task_panel_hover_texts, task_panel_lines, task_panel_row_sets, task_panel_rows,
|
||||
work_panel_empty_hint, work_panel_hover_texts, work_panel_lines,
|
||||
normalize_activity_text, render_sidebar, render_top_work_strip, sidebar_agent_rows,
|
||||
sidebar_hover_rows, sidebar_work_summary, sort_sidebar_agent_rows_as_tree,
|
||||
subagent_output_handle, subagent_panel_hover_texts, subagent_panel_lines,
|
||||
subagent_panel_rows, task_panel_hover_texts, task_panel_lines, task_panel_row_sets,
|
||||
task_panel_rows, work_panel_empty_hint, work_panel_hover_texts, work_panel_lines,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::localization::Locale;
|
||||
@@ -3630,7 +3846,10 @@ mod tests {
|
||||
use crate::tui::history::{
|
||||
ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,
|
||||
};
|
||||
use crate::tui::spinner::BRAILLE_SPINNER_FRAME_MS;
|
||||
use crate::tui::spinner::{
|
||||
BRAILLE_SPINNER_FRAME_MS, BRAILLE_SPINNER_STILL_FRAME, LIVE_MARKER_DELAY_MS,
|
||||
LIVE_STATIC_MARKER,
|
||||
};
|
||||
use ratatui::{Terminal, backend::TestBackend, text::Line};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -3664,6 +3883,88 @@ mod tests {
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_work_strip_places_tasks_and_todo_above_transcript_contract() {
|
||||
let mut app = create_test_app();
|
||||
app.task_panel.push(TaskPanelEntry {
|
||||
id: "task-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
prompt_summary: "verify the underwater layout".to_string(),
|
||||
duration_ms: Some(1_200),
|
||||
kind: TaskPanelEntryKind::Background,
|
||||
stale: false,
|
||||
elapsed_since_output_ms: None,
|
||||
owner_agent_id: None,
|
||||
owner_agent_name: None,
|
||||
});
|
||||
{
|
||||
let mut todos = app.todos.try_lock().expect("todos lock");
|
||||
todos.add(
|
||||
"move work state to the top".to_string(),
|
||||
TodoStatus::InProgress,
|
||||
);
|
||||
}
|
||||
|
||||
let backend = TestBackend::new(100, 4);
|
||||
let mut terminal = Terminal::new(backend).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| render_top_work_strip(frame, frame.area(), &mut app))
|
||||
.expect("draw top work strip");
|
||||
let rendered = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(rendered.contains("Tasks"));
|
||||
assert!(rendered.contains("verify the underwater layout"));
|
||||
assert!(rendered.contains("1.2s"));
|
||||
assert!(rendered.contains("To-do"));
|
||||
assert!(rendered.contains("move work state to the top"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_task_freezes_other_motion_and_owns_attention() {
|
||||
let mut app = create_test_app();
|
||||
for (id, status) in [("run", "running"), ("ask", "waiting")] {
|
||||
app.task_panel.push(TaskPanelEntry {
|
||||
id: id.to_string(),
|
||||
status: status.to_string(),
|
||||
prompt_summary: format!("{id} task"),
|
||||
duration_ms: Some(2_000),
|
||||
kind: TaskPanelEntryKind::Background,
|
||||
stale: false,
|
||||
elapsed_since_output_ms: None,
|
||||
owner_agent_id: None,
|
||||
owner_agent_name: None,
|
||||
});
|
||||
}
|
||||
|
||||
let backend = TestBackend::new(100, 4);
|
||||
let mut terminal = Terminal::new(backend).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| render_top_work_strip(frame, frame.area(), &mut app))
|
||||
.expect("draw top work strip");
|
||||
let rendered = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(
|
||||
rendered.contains("◆"),
|
||||
"waiting row keeps a still coral mark"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains(BRAILLE_SPINNER_STILL_FRAME),
|
||||
"other live rows freeze while the user owns the next move"
|
||||
);
|
||||
}
|
||||
|
||||
fn sidebar_tool_row(name: &str, status: ToolStatus) -> SidebarToolRow {
|
||||
SidebarToolRow {
|
||||
name: name.to_string(),
|
||||
@@ -5090,6 +5391,7 @@ mod tests {
|
||||
#[test]
|
||||
fn tasks_panel_puts_background_shell_command_on_primary_row() {
|
||||
let mut app = create_test_app();
|
||||
app.low_motion = false;
|
||||
app.task_panel.push(TaskPanelEntry {
|
||||
id: "shell_33a08c3c".to_string(),
|
||||
status: "running".to_string(),
|
||||
@@ -5111,10 +5413,8 @@ mod tests {
|
||||
"background shell headline should show the command, not only the shell id: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.iter().any(|line| line.contains(&format!(
|
||||
"{} Bash running",
|
||||
crate::tui::spinner::BRAILLE_SPINNER_FRAMES[0]
|
||||
))),
|
||||
text.iter()
|
||||
.any(|line| line.contains(&format!("{} Bash running", LIVE_STATIC_MARKER))),
|
||||
"running background shell should show a braille spinner prefix: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -5165,19 +5465,25 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
background_task_spinner_prefix(&task),
|
||||
background_task_spinner_prefix(&task, false),
|
||||
Some(LIVE_STATIC_MARKER)
|
||||
);
|
||||
|
||||
task.duration_ms = Some(LIVE_MARKER_DELAY_MS - 1);
|
||||
assert_eq!(
|
||||
background_task_spinner_prefix(&task, false),
|
||||
Some(LIVE_STATIC_MARKER)
|
||||
);
|
||||
|
||||
task.duration_ms = Some(LIVE_MARKER_DELAY_MS);
|
||||
assert_eq!(
|
||||
background_task_spinner_prefix(&task, false),
|
||||
Some(crate::tui::spinner::BRAILLE_SPINNER_FRAMES[0])
|
||||
);
|
||||
|
||||
task.duration_ms = Some(BRAILLE_SPINNER_FRAME_MS - 1);
|
||||
task.duration_ms = Some(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS);
|
||||
assert_eq!(
|
||||
background_task_spinner_prefix(&task),
|
||||
Some(crate::tui::spinner::BRAILLE_SPINNER_FRAMES[0])
|
||||
);
|
||||
|
||||
task.duration_ms = Some(BRAILLE_SPINNER_FRAME_MS);
|
||||
assert_eq!(
|
||||
background_task_spinner_prefix(&task),
|
||||
background_task_spinner_prefix(&task, false),
|
||||
Some(crate::tui::spinner::BRAILLE_SPINNER_FRAMES[1])
|
||||
);
|
||||
}
|
||||
@@ -5218,6 +5524,7 @@ mod tests {
|
||||
#[test]
|
||||
fn tasks_panel_auto_mode_shows_only_live_background_jobs() {
|
||||
let mut app = create_test_app();
|
||||
app.low_motion = false;
|
||||
app.sidebar_focus = SidebarFocus::Auto;
|
||||
app.runtime_turn_id = Some("turn_abcdef123456".to_string());
|
||||
app.runtime_turn_status = Some("in_progress".to_string());
|
||||
@@ -5277,10 +5584,8 @@ mod tests {
|
||||
"auto Tasks should keep live background jobs visible: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.iter().any(|line| line.contains(&format!(
|
||||
"{} Bash running",
|
||||
crate::tui::spinner::BRAILLE_SPINNER_FRAMES[0]
|
||||
))),
|
||||
text.iter()
|
||||
.any(|line| line.contains(&format!("{} Bash running", LIVE_STATIC_MARKER))),
|
||||
"auto Tasks should animate running background jobs: {text:?}"
|
||||
);
|
||||
for hidden in [
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Braille "whale spout" frames used for running tools and background jobs.
|
||||
///
|
||||
/// The cycle climbs, crests, and falls instead of using the stock clock-wise
|
||||
/// dots. At the shared repaint cadence it reads as a continuous spray plume.
|
||||
pub(crate) const BRAILLE_SPINNER_FRAMES: [&str; 12] = [
|
||||
"\u{2840}", "\u{2844}", "\u{2846}", "\u{28C6}", "\u{28E6}", "\u{28F6}", "\u{28F2}", "\u{28B2}",
|
||||
"\u{2832}", "\u{2830}", "\u{2820}", "\u{2810}",
|
||||
];
|
||||
/// Braille bubble frames used for running tools and background jobs. Dots fill
|
||||
/// upward, then release. Eight distinct states at roughly five hertz stay
|
||||
/// readable in peripheral vision without becoming a high-frequency spinner.
|
||||
pub(crate) const BRAILLE_SPINNER_FRAMES: [&str; 8] = ["⠀", "⢀", "⣀", "⣄", "⣤", "⣦", "⣶", "⣿"];
|
||||
pub(crate) const VERIFY_TICK_FRAMES: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
|
||||
|
||||
/// Match the live UI repaint cadence so running glyphs advance on every tick.
|
||||
pub(crate) const BRAILLE_SPINNER_FRAME_MS: u64 = 50;
|
||||
/// A motion marker is earned only after work survives the eye's quick-event
|
||||
/// window. Faster work should simply land as a receipt.
|
||||
pub(crate) const LIVE_MARKER_DELAY_MS: u64 = 400;
|
||||
pub(crate) const LIVE_STATIC_MARKER: &str = "›";
|
||||
pub(crate) const BRAILLE_SPINNER_STILL_FRAME: &str = "⣤";
|
||||
|
||||
/// Five stepped states per second: enough change to register peripherally,
|
||||
/// slow enough that a monospace braille cell reads as shape instead of flicker.
|
||||
pub(crate) const BRAILLE_SPINNER_FRAME_MS: u64 = 200;
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn braille_spinner_frame_for_elapsed_ms(
|
||||
@@ -23,9 +27,13 @@ pub(crate) fn braille_spinner_frame_for_elapsed_ms(
|
||||
low_motion: bool,
|
||||
) -> &'static str {
|
||||
if low_motion {
|
||||
return BRAILLE_SPINNER_FRAMES[0];
|
||||
return BRAILLE_SPINNER_STILL_FRAME;
|
||||
}
|
||||
if elapsed_ms < u128::from(LIVE_MARKER_DELAY_MS) {
|
||||
return LIVE_STATIC_MARKER;
|
||||
}
|
||||
let idx = elapsed_ms
|
||||
.saturating_sub(u128::from(LIVE_MARKER_DELAY_MS))
|
||||
.checked_div(u128::from(BRAILLE_SPINNER_FRAME_MS))
|
||||
.map_or(0, |frame| frame % BRAILLE_SPINNER_FRAMES.len() as u128);
|
||||
BRAILLE_SPINNER_FRAMES[usize::try_from(idx).unwrap_or_default()]
|
||||
@@ -41,15 +49,37 @@ pub(crate) fn braille_spinner_frame_for_duration_ms(
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn braille_spinner_frame(started_at: Option<Instant>, low_motion: bool) -> &'static str {
|
||||
let elapsed_ms = started_at.map_or_else(
|
||||
braille_spinner_frame_for_elapsed_ms(marker_elapsed_ms(started_at), low_motion)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn verification_tick_frame(
|
||||
started_at: Option<Instant>,
|
||||
low_motion: bool,
|
||||
) -> &'static str {
|
||||
if low_motion {
|
||||
return VERIFY_TICK_FRAMES[4];
|
||||
}
|
||||
let elapsed_ms = marker_elapsed_ms(started_at);
|
||||
if elapsed_ms < u128::from(LIVE_MARKER_DELAY_MS) {
|
||||
return LIVE_STATIC_MARKER;
|
||||
}
|
||||
let idx = elapsed_ms
|
||||
.saturating_sub(u128::from(LIVE_MARKER_DELAY_MS))
|
||||
.checked_div(u128::from(BRAILLE_SPINNER_FRAME_MS))
|
||||
.map_or(0, |frame| frame % VERIFY_TICK_FRAMES.len() as u128);
|
||||
VERIFY_TICK_FRAMES[usize::try_from(idx).unwrap_or_default()]
|
||||
}
|
||||
|
||||
fn marker_elapsed_ms(started_at: Option<Instant>) -> u128 {
|
||||
started_at.map_or_else(
|
||||
|| {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_millis())
|
||||
},
|
||||
|started| started.elapsed().as_millis(),
|
||||
);
|
||||
braille_spinner_frame_for_elapsed_ms(elapsed_ms, low_motion)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -62,14 +92,21 @@ mod tests {
|
||||
// glyphs so the whale-spout pattern can be retuned without churn here.
|
||||
assert_eq!(
|
||||
braille_spinner_frame_for_elapsed_ms(0, false),
|
||||
LIVE_STATIC_MARKER
|
||||
);
|
||||
assert_eq!(
|
||||
braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS) - 1, false),
|
||||
LIVE_STATIC_MARKER
|
||||
);
|
||||
assert_eq!(
|
||||
braille_spinner_frame_for_elapsed_ms(u128::from(LIVE_MARKER_DELAY_MS), false),
|
||||
BRAILLE_SPINNER_FRAMES[0]
|
||||
);
|
||||
assert_eq!(
|
||||
braille_spinner_frame_for_elapsed_ms(u128::from(BRAILLE_SPINNER_FRAME_MS) - 1, false),
|
||||
BRAILLE_SPINNER_FRAMES[0]
|
||||
);
|
||||
assert_eq!(
|
||||
braille_spinner_frame_for_elapsed_ms(u128::from(BRAILLE_SPINNER_FRAME_MS), false),
|
||||
braille_spinner_frame_for_elapsed_ms(
|
||||
u128::from(LIVE_MARKER_DELAY_MS + BRAILLE_SPINNER_FRAME_MS),
|
||||
false,
|
||||
),
|
||||
BRAILLE_SPINNER_FRAMES[1]
|
||||
);
|
||||
}
|
||||
@@ -78,7 +115,21 @@ mod tests {
|
||||
fn braille_spinner_respects_low_motion() {
|
||||
assert_eq!(
|
||||
braille_spinner_frame_for_elapsed_ms(u128::from(BRAILLE_SPINNER_FRAME_MS) * 3, true),
|
||||
BRAILLE_SPINNER_FRAMES[0]
|
||||
BRAILLE_SPINNER_STILL_FRAME
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_tick_is_distinct_and_freezes_legibly() {
|
||||
let start = Instant::now() - std::time::Duration::from_millis(LIVE_MARKER_DELAY_MS);
|
||||
assert_eq!(
|
||||
verification_tick_frame(Some(start), false),
|
||||
VERIFY_TICK_FRAMES[0]
|
||||
);
|
||||
assert_eq!(
|
||||
verification_tick_frame(Some(start), true),
|
||||
VERIFY_TICK_FRAMES[4]
|
||||
);
|
||||
assert_ne!(VERIFY_TICK_FRAMES[0], BRAILLE_SPINNER_FRAMES[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
//! - [`ChunkingMode::Smooth`]: normal pressure.
|
||||
//! - [`ChunkingMode::CatchUp`]: elevated pressure.
|
||||
//!
|
||||
//! Normal-motion callers drain all currently available chunks so the display
|
||||
//! follows the upstream SSE delta cadence. Low-motion callers stay in Smooth
|
||||
//! and drain one chunk per tick to reduce visual churn.
|
||||
//! Every caller drains all currently available chunks so the display follows
|
||||
//! the upstream SSE delta cadence. Low motion affects decorative animation and
|
||||
//! redraw frequency, never the apparent speed of model text.
|
||||
//!
|
||||
//! # Hysteresis
|
||||
//!
|
||||
@@ -130,15 +130,16 @@ impl AdaptiveChunkingPolicy {
|
||||
|
||||
/// Computes a drain decision from the current queue snapshot.
|
||||
pub fn decide(&mut self, snapshot: QueueSnapshot, now: Instant) -> ChunkingDecision {
|
||||
// In low-motion mode, always use Smooth pacing regardless of queue
|
||||
// pressure — the user asked for a calm, steady display.
|
||||
// Low motion stays in Smooth mode, but text still follows upstream
|
||||
// cadence. Dripping one grapheme per redraw creates an artificial
|
||||
// typewriter followed by a large final flush.
|
||||
if self.low_motion {
|
||||
self.mode = ChunkingMode::Smooth;
|
||||
self.below_exit_threshold_since = None;
|
||||
return ChunkingDecision {
|
||||
mode: self.mode,
|
||||
entered_catch_up: false,
|
||||
drain_plan: DrainPlan::Single,
|
||||
drain_plan: DrainPlan::Available,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -410,7 +411,7 @@ mod tests {
|
||||
let d1 = policy.decide(snap(ENTER_QUEUE_DEPTH_LINES + 80, 10), t0);
|
||||
assert_eq!(d1.mode, ChunkingMode::Smooth);
|
||||
assert!(!d1.entered_catch_up);
|
||||
assert_eq!(d1.drain_plan, DrainPlan::Single);
|
||||
assert_eq!(d1.drain_plan, DrainPlan::Available);
|
||||
|
||||
// Oldest age far above ENTER threshold.
|
||||
let d2 = policy.decide(
|
||||
@@ -419,7 +420,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(d2.mode, ChunkingMode::Smooth);
|
||||
assert!(!d2.entered_catch_up);
|
||||
assert_eq!(d2.drain_plan, DrainPlan::Single);
|
||||
assert_eq!(d2.drain_plan, DrainPlan::Available);
|
||||
|
||||
// Severe backlog — still Smooth.
|
||||
let d3 = policy.decide(
|
||||
@@ -430,7 +431,7 @@ mod tests {
|
||||
t0 + Duration::from_millis(200),
|
||||
);
|
||||
assert_eq!(d3.mode, ChunkingMode::Smooth);
|
||||
assert_eq!(d3.drain_plan, DrainPlan::Single);
|
||||
assert_eq!(d3.drain_plan, DrainPlan::Available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! [`run_commit_tick`] on every commit beat to obtain text to flush to the
|
||||
//! transcript on this beat. Normal motion drains all text received since the
|
||||
//! prior tick so the display follows the upstream delta cadence. Low-motion
|
||||
//! mode keeps the old one-grapheme drip to reduce visual churn.
|
||||
//! mode may coalesce redraws, but never fabricates a one-grapheme typewriter.
|
||||
//!
|
||||
//! The chunker is the unit of streaming — one per active block (assistant /
|
||||
//! thinking). Tool output is unbuffered and bypasses this path.
|
||||
@@ -215,7 +215,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_motion_keeps_smooth_micro_chunk_pacing() {
|
||||
fn low_motion_preserves_upstream_delta_pacing() {
|
||||
let mut chunker = StreamChunker::new();
|
||||
let mut policy = AdaptiveChunkingPolicy::new();
|
||||
policy.set_low_motion(true);
|
||||
@@ -223,11 +223,11 @@ mod tests {
|
||||
|
||||
chunker.push_delta("hello world");
|
||||
let out = run_commit_tick(&mut policy, &mut chunker, now);
|
||||
assert_eq!(out.committed_text, "h");
|
||||
assert!(!chunker.is_idle(), "low motion should keep dripping");
|
||||
assert_eq!(out.committed_text, "hello world");
|
||||
assert!(chunker.is_idle());
|
||||
|
||||
let out = run_commit_tick(&mut policy, &mut chunker, now + Duration::from_millis(20));
|
||||
assert_eq!(out.committed_text, "e");
|
||||
assert_eq!(out.committed_text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -255,9 +255,9 @@ mod tests {
|
||||
|
||||
chunker.push_delta("e\u{301}x");
|
||||
let out1 = run_commit_tick(&mut policy, &mut chunker, t0);
|
||||
assert_eq!(out1.committed_text, "e\u{301}");
|
||||
assert_eq!(out1.committed_text, "e\u{301}x");
|
||||
let out2 = run_commit_tick(&mut policy, &mut chunker, t0 + Duration::from_millis(20));
|
||||
assert_eq!(out2.committed_text, "x");
|
||||
assert_eq!(out2.committed_text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -734,9 +734,8 @@ mod tests {
|
||||
state.start_text(0, None);
|
||||
state.set_low_motion(true);
|
||||
state.push_content(0, "abc");
|
||||
assert_eq!(state.commit_text(0), "a");
|
||||
|
||||
assert_eq!(state.finalize_block_text(0), "bc");
|
||||
assert_eq!(state.commit_text(0), "abc");
|
||||
assert_eq!(state.finalize_block_text(0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,11 +33,20 @@ pub struct ThemePickerView {
|
||||
/// so the per-frame render doesn't re-invoke `UiTheme::detect()` (which
|
||||
/// reads `COLORFGBG`) on every keystroke.
|
||||
system_ui_theme: UiTheme,
|
||||
/// Effective session treatment, reported separately from theme so the
|
||||
/// picker never claims an ombre is active under Terminal or Flat.
|
||||
ocean_treatment: String,
|
||||
}
|
||||
|
||||
impl ThemePickerView {
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn new(original_name: String) -> Self {
|
||||
Self::new_with_treatment(original_name, "ombre".to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_treatment(original_name: String, ocean_treatment: String) -> Self {
|
||||
// If the persisted name matches one of the entries, start there;
|
||||
// otherwise fall back to "System" so the cursor lands on a valid row.
|
||||
let selected = SELECTABLE_THEMES
|
||||
@@ -48,6 +57,7 @@ impl ThemePickerView {
|
||||
selected,
|
||||
original_name,
|
||||
system_ui_theme: UiTheme::detect(),
|
||||
ocean_treatment,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +173,7 @@ impl ModalView for ThemePickerView {
|
||||
// 1 title + 1 spacer + N rows + spacer + the in-body action footer.
|
||||
// centered_modal_area clamps strictly to `area`, so the modal always
|
||||
// fits even on tiny or split-pane terminals.
|
||||
let needed_height = (SELECTABLE_THEMES.len() as u16).saturating_add(9);
|
||||
let needed_height = (SELECTABLE_THEMES.len() as u16).saturating_add(10);
|
||||
let popup_area = centered_modal_area(area, 78, needed_height, 44, 8);
|
||||
|
||||
// The live theme has already been swapped under us via ConfigUpdated,
|
||||
@@ -208,6 +218,19 @@ impl ModalView for ThemePickerView {
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let treatment = if matches!(self.current(), ThemeId::Terminal) {
|
||||
"Treatment Ombre unavailable — Terminal owns the background"
|
||||
} else if self.ocean_treatment.eq_ignore_ascii_case("flat") {
|
||||
"Treatment Flat — active"
|
||||
} else {
|
||||
"Treatment Ombre — active"
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
treatment,
|
||||
Style::default().fg(live.text_hint),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (idx, id) in SELECTABLE_THEMES.iter().enumerate() {
|
||||
let id = *id;
|
||||
let is_selected = idx == self.selected;
|
||||
@@ -408,6 +431,33 @@ mod tests {
|
||||
v.render(area, &mut buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treatment_report_names_effective_appearance() {
|
||||
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
|
||||
|
||||
let flat = ThemePickerView::new_with_treatment("dark".to_string(), "flat".to_string());
|
||||
let mut flat_buf = ratatui::buffer::Buffer::empty(area);
|
||||
flat.render(area, &mut flat_buf);
|
||||
let flat_text = flat_buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
assert!(flat_text.contains("Treatment Flat — active"));
|
||||
|
||||
let terminal =
|
||||
ThemePickerView::new_with_treatment("terminal".to_string(), "ombre".to_string());
|
||||
let mut terminal_buf = ratatui::buffer::Buffer::empty(area);
|
||||
terminal.render(area, &mut terminal_buf);
|
||||
let terminal_text = terminal_buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
assert!(terminal_text.contains("Ombre unavailable"));
|
||||
assert!(terminal_text.contains("Terminal owns the background"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_semantically_truncates_taglines_at_narrow_width() {
|
||||
let v = ThemePickerView::new("system".to_string());
|
||||
|
||||
+60
-21
@@ -1563,6 +1563,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
tools: config.tools.clone(),
|
||||
workspace_follow_symlinks: app.workspace_follow_symlinks,
|
||||
exec_policy_engine: config.exec_policy_engine.clone(),
|
||||
terminal_chrome_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3609,13 +3610,32 @@ async fn run_event_loop(
|
||||
maybe_throttled_recovery_snapshot(app, Instant::now(), &mut last_recovery_snapshot_at);
|
||||
let history_has_live_motion = history_has_live_motion(&app.history);
|
||||
let active_cell_has_live_motion = active_cell_has_live_motion(app);
|
||||
if should_tick_status_animation(
|
||||
let underwater_idle_motion = !app.low_motion
|
||||
&& app.fancy_animations
|
||||
&& app.ocean_treatment == "ombre"
|
||||
&& crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some()
|
||||
&& app.onboarding == OnboardingState::None
|
||||
&& !app.attention_hold_active()
|
||||
&& app.history.is_empty()
|
||||
&& app.input.trim().is_empty()
|
||||
&& app
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.is_none_or(crate::tui::active_cell::ActiveCell::is_empty)
|
||||
&& !app.is_loading;
|
||||
let status_motion = should_tick_status_animation(
|
||||
app,
|
||||
has_running_agents,
|
||||
history_has_live_motion,
|
||||
active_cell_has_live_motion,
|
||||
) && last_status_frame.elapsed()
|
||||
>= Duration::from_millis(status_animation_interval_ms(app))
|
||||
);
|
||||
let animation_interval_ms = if status_motion {
|
||||
status_animation_interval_ms(app)
|
||||
} else {
|
||||
125
|
||||
};
|
||||
if (status_motion || underwater_idle_motion)
|
||||
&& last_status_frame.elapsed() >= Duration::from_millis(animation_interval_ms)
|
||||
{
|
||||
if streaming_thinking::animate_pending_translation(
|
||||
app,
|
||||
@@ -3694,7 +3714,7 @@ async fn run_event_loop(
|
||||
// Sync low-motion flag into the frame-rate limiter and streaming
|
||||
// chunking policy. Low-motion mode drops the frame cap to 30 FPS
|
||||
// and forces Smooth-only chunking so the display stays calm.
|
||||
frame_rate_limiter.set_low_motion(app.low_motion);
|
||||
frame_rate_limiter.set_low_motion(app.low_motion || app.constrained_frame_rate);
|
||||
app.streaming_state.set_low_motion(app.low_motion);
|
||||
|
||||
let draw_wait = if app.needs_redraw {
|
||||
@@ -8609,8 +8629,12 @@ async fn apply_command_result(
|
||||
// Avoids re-reading settings.toml from disk on every
|
||||
// `/theme` invocation.
|
||||
let original = app.theme_id.name().to_string();
|
||||
app.view_stack
|
||||
.push(crate::tui::theme_picker::ThemePickerView::new(original));
|
||||
app.view_stack.push(
|
||||
crate::tui::theme_picker::ThemePickerView::new_with_treatment(
|
||||
original,
|
||||
app.ocean_treatment.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
AppAction::OpenFleetRoster => {
|
||||
@@ -9656,6 +9680,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
|
||||
}
|
||||
let context_usage = context_usage_snapshot(app);
|
||||
let top_work_strip_height = super::sidebar::top_work_strip_height(app, size.width);
|
||||
|
||||
// Defensive two-pass layout: pin the header to the absolute top row,
|
||||
// then split the remaining body area for chat / preview / composer /
|
||||
@@ -9673,7 +9698,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
|
||||
let body_height = body_area.height;
|
||||
let composer_max_height = body_height
|
||||
.saturating_sub(MIN_CHAT_HEIGHT + footer_height)
|
||||
.saturating_sub(MIN_CHAT_HEIGHT + footer_height + top_work_strip_height)
|
||||
.max(MIN_COMPOSER_HEIGHT);
|
||||
let composer_height = {
|
||||
let composer_widget = ComposerWidget::new(
|
||||
@@ -9704,6 +9729,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
.direction(Direction::Vertical)
|
||||
.flex(ratatui::layout::Flex::Start)
|
||||
.constraints([
|
||||
Constraint::Length(top_work_strip_height), // Tasks + To-do above transcript
|
||||
Constraint::Min(1), // Chat area
|
||||
Constraint::Length(workflow_panel_height), // Workflow panel (#4121)
|
||||
Constraint::Length(preview_height), // Pending input preview (0 if empty)
|
||||
@@ -9712,6 +9738,10 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
])
|
||||
.split(body_area);
|
||||
|
||||
if top_work_strip_height > 0 {
|
||||
super::sidebar::render_top_work_strip(f, body_chunks[0], app);
|
||||
}
|
||||
|
||||
// Render header
|
||||
{
|
||||
let sanitized_context_window =
|
||||
@@ -9807,19 +9837,19 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
// resize) don't retain stale content from a previous frame.
|
||||
Block::default()
|
||||
.style(Style::default().bg(app.ui_theme.surface_bg))
|
||||
.render(body_chunks[0], f.buffer_mut());
|
||||
.render(body_chunks[1], f.buffer_mut());
|
||||
|
||||
let mut sidebar_area = None;
|
||||
|
||||
// When the file-tree pane is visible and the terminal is wide
|
||||
// enough, reserve the left ~25% for the file tree.
|
||||
let mut chat_area =
|
||||
if app.file_tree.is_some() && body_chunks[0].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
|
||||
if app.file_tree.is_some() && body_chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
|
||||
app.file_tree_visible = true;
|
||||
let split = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
|
||||
.split(body_chunks[0]);
|
||||
.split(body_chunks[1]);
|
||||
let tree_area = split[0];
|
||||
let remaining = split[1];
|
||||
|
||||
@@ -9831,7 +9861,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
remaining
|
||||
} else {
|
||||
app.file_tree_visible = false;
|
||||
body_chunks[0]
|
||||
body_chunks[1]
|
||||
};
|
||||
|
||||
// Auto-reveal: in Auto focus mode, collapse the sidebar to a
|
||||
@@ -9976,7 +10006,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
// Workflow panel between chat and pending-input preview (#4121).
|
||||
if workflow_panel_height > 0 {
|
||||
if let Some(panel) = app.workflow_panel.as_ref() {
|
||||
let area = body_chunks[1];
|
||||
let area = body_chunks[2];
|
||||
app.viewport.last_workflow_panel_area = Some(area);
|
||||
let buf = f.buffer_mut();
|
||||
panel.render(area, buf);
|
||||
@@ -9988,7 +10018,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
// Render pending-input preview (queued/steered messages, if any).
|
||||
if preview_height > 0 {
|
||||
let buf = f.buffer_mut();
|
||||
pending_preview.render(body_chunks[2], buf);
|
||||
pending_preview.render(body_chunks[3], buf);
|
||||
}
|
||||
|
||||
// Render composer
|
||||
@@ -10000,17 +10030,26 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
&mention_menu_entries,
|
||||
);
|
||||
let buf = f.buffer_mut();
|
||||
composer_widget.render(body_chunks[3], buf);
|
||||
composer_widget.cursor_pos(body_chunks[3])
|
||||
composer_widget.render(body_chunks[4], buf);
|
||||
composer_widget.cursor_pos(body_chunks[4])
|
||||
};
|
||||
app.viewport.last_composer_area = Some(body_chunks[3]);
|
||||
app.viewport.last_composer_area = Some(body_chunks[4]);
|
||||
{
|
||||
let area = body_chunks[3];
|
||||
let has_panel = app.composer_border && area.height >= 3 && area.width >= 12;
|
||||
let inner = if has_panel {
|
||||
let area = body_chunks[4];
|
||||
let composer_widget = ComposerWidget::new(
|
||||
app,
|
||||
composer_max_height,
|
||||
&slash_menu_entries,
|
||||
&mention_menu_entries,
|
||||
);
|
||||
let inner = if composer_widget.has_panel(area) {
|
||||
ratatui::widgets::Block::default()
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.inner(area)
|
||||
} else if area.height >= 2 {
|
||||
ratatui::widgets::Block::default()
|
||||
.borders(ratatui::widgets::Borders::TOP)
|
||||
.inner(area)
|
||||
} else {
|
||||
area
|
||||
};
|
||||
@@ -10057,11 +10096,11 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
}
|
||||
|
||||
// Render footer
|
||||
render_footer(f, body_chunks[4], app);
|
||||
render_footer(f, body_chunks[5], app);
|
||||
// Toast stack overlay (#439): when multiple status toasts are queued,
|
||||
// surface the older ones as a 1-2 line strip above the footer so a
|
||||
// burst of events isn't collapsed to a single visible message.
|
||||
render_toast_stack_overlay(f, size, body_chunks[3], body_chunks[4], app);
|
||||
render_toast_stack_overlay(f, size, body_chunks[4], body_chunks[5], app);
|
||||
|
||||
// Decision card overlay (v0.8.43 truth-surface). When a decision card is
|
||||
// active, render it centered on top of the transcript.
|
||||
|
||||
@@ -11864,14 +11864,14 @@ fn build_pending_input_preview_includes_current_context_chips() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_footer_from_with_default_items_renders_model_without_mode() {
|
||||
// Header owns mode; footer shows model/cost/status only.
|
||||
fn render_footer_from_with_default_items_leaves_header_owned_facts_out() {
|
||||
// Header owns model and mode; footer shows cost/status only.
|
||||
let mut app = create_test_app();
|
||||
app.session.session_cost = 0.00005;
|
||||
let items = crate::config::StatusItem::default_footer();
|
||||
let props = render_footer_from(&app, &items, None);
|
||||
assert!(props.mode_label.is_empty(), "footer should not repeat mode");
|
||||
assert!(!props.model.is_empty(), "footer should show a model name");
|
||||
assert!(props.model.is_empty(), "footer should not repeat model");
|
||||
// Tiny but real costs should render instead of disappearing as "$0.00".
|
||||
assert!(!props.cost.is_empty());
|
||||
assert_eq!(spans_text(&props.cost), "<$0.0001");
|
||||
@@ -11977,7 +11977,7 @@ fn render_footer_from_drops_only_unselected_clusters() {
|
||||
.collect();
|
||||
let props = render_footer_from(&app, &items, None);
|
||||
assert!(props.mode_label.is_empty());
|
||||
assert!(!props.model.is_empty(), "footer should show a model name");
|
||||
assert!(props.model.is_empty(), "footer should not repeat model");
|
||||
assert!(
|
||||
props.cost.is_empty(),
|
||||
"cost cluster should be empty when Cost is disabled"
|
||||
|
||||
@@ -653,13 +653,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(member_routing(&reviewer), "inherit session route");
|
||||
|
||||
// Built-in scout: fast route preset is the routing truth.
|
||||
// Built-in scout: no setup means the session route, just like every
|
||||
// other built-in role.
|
||||
let scout = FleetRoster::built_ins_only().get("scout").unwrap().clone();
|
||||
assert_eq!(
|
||||
member_posture(&scout),
|
||||
"explore worker · read-only · shell read-only"
|
||||
);
|
||||
assert_eq!(member_routing(&scout), "route preset fast");
|
||||
assert_eq!(member_routing(&scout), "inherit session route");
|
||||
|
||||
// Builder writes with full shell.
|
||||
let builder = FleetRoster::built_ins_only()
|
||||
|
||||
@@ -1159,6 +1159,13 @@ impl ConfigView {
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
section: ConfigSection::Display,
|
||||
key: "ocean_treatment".to_string(),
|
||||
value: settings.ocean_treatment.clone(),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
section: ConfigSection::Display,
|
||||
key: "calm_mode".to_string(),
|
||||
@@ -1841,6 +1848,7 @@ fn config_label_for_key(key: &str) -> String {
|
||||
"theme" => "Theme",
|
||||
"locale" => "Language",
|
||||
"background_color" => "Background",
|
||||
"ocean_treatment" => "Ocean treatment",
|
||||
"calm_mode" => "Calm mode",
|
||||
"low_motion" => "Low motion",
|
||||
"fancy_animations" => "Animations",
|
||||
@@ -1916,6 +1924,7 @@ fn config_hint_for_key(key: &str) -> &'static str {
|
||||
"theme" => "system | dark | light | grayscale",
|
||||
"locale" => "auto | en | ja | zh-Hans | pt-BR",
|
||||
"background_color" => "#RRGGBB | default",
|
||||
"ocean_treatment" => "ombre | flat",
|
||||
"base_url" => "global DeepSeek/root fallback; e.g. https://api.deepseek.com/beta",
|
||||
"provider_url" => {
|
||||
"current provider endpoint; Xiaomi: token-plan | pay-as-you-go | custom URL"
|
||||
|
||||
@@ -252,6 +252,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operate_copy_explains_the_user_benefit_at_eighty_columns() {
|
||||
let (buf, area) = render_at(80, 24);
|
||||
let text = rows(&buf, area).join("\n");
|
||||
assert!(
|
||||
text.contains("Coordinate a Fleet for multi-step work."),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!text.contains("spawn, wait, verify"), "{text}");
|
||||
assert!(!text.contains("subagents/workflows"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_keys_select_modes() {
|
||||
// Visible roster: 1 Act, 2 Plan, 3 Operate. No Multitask / YOLO / gap.
|
||||
|
||||
@@ -425,13 +425,9 @@ impl FooterWidget {
|
||||
|
||||
/// Build the left status line with priority-ordered hint dropping.
|
||||
///
|
||||
/// Priority order (highest to lowest — last to drop):
|
||||
/// 1. Model name (always visible; then truncated mid-word once all hints are gone)
|
||||
/// 2. Balance chip — drops third (account balance is more actionable than session cost)
|
||||
/// 3. Cost chip — drops fourth
|
||||
/// 4. Status label (e.g. "working", "draft") — drops first when space is tight
|
||||
///
|
||||
/// Mode lives in the header only; the footer never repeats it.
|
||||
/// Production leaves mode/model blank because the header owns them. The
|
||||
/// generic widget still supports callers that supply them, while the
|
||||
/// header-owned path prioritizes state, then cost and balance.
|
||||
fn status_line_spans(&self, max_width: usize) -> Vec<Span<'static>> {
|
||||
if max_width == 0 {
|
||||
return Vec::new();
|
||||
@@ -447,6 +443,36 @@ impl FooterWidget {
|
||||
let balance_text = spans_text(&self.props.balance);
|
||||
let show_balance = !balance_text.is_empty();
|
||||
|
||||
if mode_label.is_empty() && model.is_empty() {
|
||||
let mut spans = Vec::new();
|
||||
if show_status {
|
||||
spans.push(Span::styled(
|
||||
truncate_to_width(status_label, max_width),
|
||||
Style::default().fg(self.props.state_color),
|
||||
));
|
||||
}
|
||||
for (text, color) in [
|
||||
(cost_text.as_str(), self.props.text_muted_color),
|
||||
(balance_text.as_str(), self.props.text_muted_color),
|
||||
] {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut candidate = spans.clone();
|
||||
if !candidate.is_empty() {
|
||||
candidate.push(Span::styled(
|
||||
sep.to_string(),
|
||||
Style::default().fg(self.props.text_dim_color),
|
||||
));
|
||||
}
|
||||
candidate.push(Span::styled(text.to_string(), Style::default().fg(color)));
|
||||
if span_width(&candidate) <= max_width {
|
||||
spans = candidate;
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
let mode_w = mode_label.width();
|
||||
let sep_w = sep.width();
|
||||
let model_w = UnicodeWidthStr::width(model);
|
||||
|
||||
@@ -53,11 +53,18 @@ pub fn header_status_indicator_frame(
|
||||
turn_started_at: Option<Instant>,
|
||||
mode: &str,
|
||||
) -> Option<&'static str> {
|
||||
if matches!(
|
||||
mode.trim().to_ascii_lowercase().as_str(),
|
||||
"cw" | "mark" | "text"
|
||||
) {
|
||||
return Some("cw");
|
||||
}
|
||||
let frames: &[&str] = match mode.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "none" | "hidden" | "false" => return None,
|
||||
"dots" | "dot" => STATUS_INDICATOR_DOT_FRAMES,
|
||||
// "whale" + aliases + unknown → whale (intentional default).
|
||||
_ => STATUS_INDICATOR_WHALE_FRAMES,
|
||||
"whale" | "🐳" | "🐋" => STATUS_INDICATOR_WHALE_FRAMES,
|
||||
// Unknown values keep the owned typographic mark visible.
|
||||
_ => return Some("cw"),
|
||||
};
|
||||
let elapsed_ms = turn_started_at
|
||||
.map(|t| t.elapsed().as_millis())
|
||||
@@ -71,7 +78,6 @@ 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,
|
||||
/// Total tokens used in this session (cumulative, for display).
|
||||
pub total_tokens: u32,
|
||||
@@ -105,14 +111,13 @@ impl<'a> HeaderData<'a> {
|
||||
mode: AppMode,
|
||||
model: &'a str,
|
||||
workspace_name: &'a str,
|
||||
is_streaming: bool,
|
||||
_is_streaming: bool,
|
||||
background: ratatui::style::Color,
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
workspace_name,
|
||||
mode,
|
||||
is_streaming,
|
||||
background,
|
||||
total_tokens: 0,
|
||||
context_window: None,
|
||||
@@ -120,7 +125,7 @@ impl<'a> HeaderData<'a> {
|
||||
last_prompt_tokens: None,
|
||||
reasoning_effort_label: None,
|
||||
provider_label: None,
|
||||
status_indicator_frame: None,
|
||||
status_indicator_frame: Some("cw"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,14 +290,18 @@ impl<'a> HeaderWidget<'a> {
|
||||
let Some(frame) = self.data.status_indicator_frame else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Color matches the rest of the live-status cluster (sky), keeping
|
||||
// the chip visually grouped with `● Live` and the effort label.
|
||||
let color = if frame == "cw" {
|
||||
palette::WHALE_ACCENT_PRIMARY
|
||||
} else {
|
||||
palette::WHALE_INFO
|
||||
};
|
||||
vec![Span::styled(
|
||||
frame.to_string(),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
)]
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn provider_chip_spans(&self) -> Vec<Span<'static>> {
|
||||
let Some(label) = self.data.provider_label else {
|
||||
return Vec::new();
|
||||
@@ -309,6 +318,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
)]
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn effort_chip_spans(&self, include_prefix: bool) -> Vec<Span<'static>> {
|
||||
let Some(label) = self.data.reasoning_effort_label else {
|
||||
return Vec::new();
|
||||
@@ -338,59 +348,10 @@ impl<'a> HeaderWidget<'a> {
|
||||
|
||||
fn status_variant(
|
||||
&self,
|
||||
show_stream_label: bool,
|
||||
_show_stream_label: bool,
|
||||
show_percent: bool,
|
||||
show_signal: bool,
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let provider_spans = self.provider_chip_spans();
|
||||
let has_provider = !provider_spans.is_empty();
|
||||
if has_provider {
|
||||
spans.extend(provider_spans);
|
||||
}
|
||||
|
||||
// Status indicator chip (whale 🐳/🐋 or dots ◌/◉ depending on
|
||||
// `status_indicator` setting). Sits immediately before the effort
|
||||
// chip so the layout reads e.g. `🐳.. ◆ max` — the chip cluster
|
||||
// users associate with "where the whale used to be."
|
||||
let indicator_spans = self.status_indicator_spans();
|
||||
let has_indicator = !indicator_spans.is_empty();
|
||||
if has_indicator {
|
||||
if has_provider {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.extend(indicator_spans);
|
||||
}
|
||||
|
||||
let effort_spans = self.effort_chip_spans(true);
|
||||
let has_effort = !effort_spans.is_empty();
|
||||
if has_effort {
|
||||
if has_provider || has_indicator {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.extend(effort_spans);
|
||||
}
|
||||
|
||||
if self.data.is_streaming {
|
||||
if has_effort || has_provider {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
"●",
|
||||
Style::default()
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if show_stream_label {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
"Live",
|
||||
Style::default().fg(palette::TEXT_SOFT),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let context_spans = if show_signal {
|
||||
self.context_signal_spans(show_percent)
|
||||
} else if show_percent {
|
||||
@@ -399,13 +360,9 @@ impl<'a> HeaderWidget<'a> {
|
||||
Vec::new()
|
||||
};
|
||||
if !context_spans.is_empty() {
|
||||
if !spans.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.extend(context_spans);
|
||||
return context_spans;
|
||||
}
|
||||
|
||||
spans
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Compile-time version tag (`v0.8.29`, …). Rendered in the header's
|
||||
@@ -457,6 +414,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn metadata_spans(&self, max_width: usize) -> Vec<Span<'static>> {
|
||||
let workspace = self.data.workspace_name.trim();
|
||||
let model = self.data.model.trim();
|
||||
@@ -529,38 +487,47 @@ impl<'a> HeaderWidget<'a> {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mode_label = Self::mode_name(self.data.mode);
|
||||
let mode_label = Self::mode_name(self.data.mode).to_ascii_lowercase();
|
||||
let mode_style = Style::default()
|
||||
.fg(Self::mode_color(self.data.mode))
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let mut spans = self.status_indicator_spans();
|
||||
let used = Self::span_width(&spans);
|
||||
|
||||
if max_width < mode_label.width() {
|
||||
let fallback = self
|
||||
.data
|
||||
.mode
|
||||
.label()
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_string();
|
||||
return vec![Span::styled(fallback, mode_style)];
|
||||
}
|
||||
|
||||
let mut spans = vec![Span::styled(mode_label.to_string(), mode_style)];
|
||||
let metadata_width = max_width
|
||||
.saturating_sub(mode_label.width())
|
||||
.saturating_sub(2);
|
||||
let metadata = if metadata_width >= 4 {
|
||||
self.metadata_spans(metadata_width)
|
||||
let provider = self.data.provider_label.unwrap_or("").trim();
|
||||
let model = self.data.model.trim();
|
||||
let route = if provider.is_empty() {
|
||||
model.to_string()
|
||||
} else {
|
||||
Vec::new()
|
||||
format!("{provider}:{model}")
|
||||
};
|
||||
let effort = self.data.reasoning_effort_label.unwrap_or("").trim();
|
||||
let fixed_width =
|
||||
3 + mode_label.width() + usize::from(!effort.is_empty()) * (3 + effort.width());
|
||||
let route_budget = max_width.saturating_sub(used + fixed_width + 1);
|
||||
let route = if route_budget >= 4 {
|
||||
Self::truncate_to_width(&route, route_budget)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if !metadata.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.extend(metadata);
|
||||
if !spans.is_empty() && !route.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
if !route.is_empty() {
|
||||
spans.push(Span::styled(route, Style::default().fg(palette::TEXT_HINT)));
|
||||
}
|
||||
if Self::span_width(&spans) + 3 + mode_label.width() <= max_width {
|
||||
spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
|
||||
spans.push(Span::styled(mode_label, mode_style));
|
||||
}
|
||||
if !effort.is_empty() && Self::span_width(&spans) + 3 + effort.width() <= max_width {
|
||||
spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
|
||||
spans.push(Span::styled(
|
||||
effort.to_string(),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
));
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
}
|
||||
@@ -627,8 +594,8 @@ mod tests {
|
||||
);
|
||||
|
||||
// Wave 7: the Agent mode chip reads "Act".
|
||||
assert!(rendered.contains("Act"));
|
||||
assert!(rendered.contains("codewhale-tui"));
|
||||
assert!(rendered.contains("cw"));
|
||||
assert!(rendered.contains("act"));
|
||||
assert!(rendered.contains("deepseek-v4-pro"));
|
||||
assert!(!rendered.contains("Plan"));
|
||||
assert!(!rendered.contains("Yolo"));
|
||||
@@ -678,7 +645,7 @@ mod tests {
|
||||
"version chip should drop under width pressure: {rendered:?}",
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Act") || rendered.contains('A'),
|
||||
rendered.contains("act") || rendered.contains('a'),
|
||||
"mode label must survive: {rendered:?}",
|
||||
);
|
||||
}
|
||||
@@ -697,7 +664,7 @@ mod tests {
|
||||
72,
|
||||
);
|
||||
|
||||
assert!(rendered.contains("Live"));
|
||||
assert!(!rendered.contains("Live"));
|
||||
assert!(rendered.contains("38%"));
|
||||
assert!(rendered.contains("▰"));
|
||||
}
|
||||
@@ -733,7 +700,8 @@ mod tests {
|
||||
|
||||
// YOLO renders as Act; under extreme width pressure only the first
|
||||
// glyph of the mode chip remains.
|
||||
assert!(rendered.trim_start().starts_with('A'));
|
||||
assert!(rendered.trim_start().starts_with("cw"));
|
||||
assert!(rendered.contains("act"));
|
||||
assert!(!rendered.contains("Plan"));
|
||||
assert!(!rendered.contains("Operate"));
|
||||
}
|
||||
@@ -816,6 +784,15 @@ mod tests {
|
||||
assert_eq!(frame, Some("🐳"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cw_indicator_is_static_and_typographic() {
|
||||
assert_eq!(super::header_status_indicator_frame(None, "cw"), Some("cw"));
|
||||
assert_eq!(
|
||||
super::header_status_indicator_frame(Some(std::time::Instant::now()), "cw"),
|
||||
Some("cw")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_indicator_advances_through_frames_then_breaches() {
|
||||
use std::thread::sleep;
|
||||
@@ -850,11 +827,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_indicator_mode_defaults_to_whale() {
|
||||
// We'd rather restore the whale on a typo than silently hide the
|
||||
// chip — matches `StatusIndicatorValue::from(&str)`.
|
||||
fn unknown_indicator_mode_defaults_to_cw_mark() {
|
||||
let frame = super::header_status_indicator_frame(None, "wahel-typo");
|
||||
assert_eq!(frame, Some("🐳"));
|
||||
assert_eq!(frame, Some("cw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -65,6 +65,10 @@ pub struct ChatWidget {
|
||||
scrollbar: Option<TranscriptScrollbar>,
|
||||
jump_to_latest_button: Option<Rect>,
|
||||
background: Color,
|
||||
ocean_ramp: Option<crate::tui::ocean::OceanRamp>,
|
||||
ocean_elapsed_ms: u128,
|
||||
ocean_animated: bool,
|
||||
ambient_life: bool,
|
||||
scroll_track: Color,
|
||||
scroll_thumb: Color,
|
||||
jump_border: Color,
|
||||
@@ -82,6 +86,13 @@ impl ChatWidget {
|
||||
pub fn new(app: &mut App, area: Rect) -> Self {
|
||||
let content_area = area;
|
||||
let background = app.ui_theme.surface_bg;
|
||||
let ocean_ramp = (app.ocean_treatment == "ombre")
|
||||
.then(|| crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme))
|
||||
.flatten();
|
||||
let ocean_elapsed_ms = app.ocean_started_at.elapsed().as_millis();
|
||||
let ocean_animated =
|
||||
!app.low_motion && app.fancy_animations && !app.attention_hold_active();
|
||||
let render_empty_state = should_render_empty_state(app);
|
||||
let scroll_track = app.ui_theme.border;
|
||||
let scroll_thumb = app.ui_theme.status_working;
|
||||
let jump_border = app.ui_theme.border;
|
||||
@@ -89,7 +100,7 @@ impl ChatWidget {
|
||||
let visible_lines = content_area.height as usize;
|
||||
let render_options = app.transcript_render_options();
|
||||
|
||||
if should_render_empty_state(app) {
|
||||
if render_empty_state {
|
||||
let lines = build_empty_state_lines(app, content_area);
|
||||
app.viewport.last_transcript_area = Some(content_area);
|
||||
app.viewport.last_transcript_top = 0;
|
||||
@@ -103,6 +114,10 @@ impl ChatWidget {
|
||||
scrollbar: None,
|
||||
jump_to_latest_button: None,
|
||||
background,
|
||||
ocean_ramp,
|
||||
ocean_elapsed_ms,
|
||||
ocean_animated,
|
||||
ambient_life: app.input.trim().is_empty() && !app.attention_hold_active(),
|
||||
scroll_track,
|
||||
scroll_thumb,
|
||||
jump_border,
|
||||
@@ -400,6 +415,10 @@ impl ChatWidget {
|
||||
scrollbar,
|
||||
jump_to_latest_button,
|
||||
background,
|
||||
ocean_ramp,
|
||||
ocean_elapsed_ms,
|
||||
ocean_animated,
|
||||
ambient_life: false,
|
||||
scroll_track,
|
||||
scroll_thumb,
|
||||
jump_border,
|
||||
@@ -506,6 +525,16 @@ impl Renderable for ChatWidget {
|
||||
Paragraph::new(self.lines.clone()).style(Style::default().bg(self.background));
|
||||
paragraph.render(area, buf);
|
||||
|
||||
render_underwater_field(
|
||||
area,
|
||||
buf,
|
||||
self.ocean_ramp,
|
||||
&self.lines,
|
||||
self.ambient_life,
|
||||
self.ocean_elapsed_ms,
|
||||
self.ocean_animated,
|
||||
);
|
||||
|
||||
// #3029: the transcript carries OSC 8 hyperlinks in-band inside span
|
||||
// content. Scan the rendered buffer for those payloads, blank the
|
||||
// payload cells (so no cell ever holds `\x1b`/`]8;;` — fixes the
|
||||
@@ -547,6 +576,150 @@ impl Renderable for ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_underwater_field(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
ramp: Option<crate::tui::ocean::OceanRamp>,
|
||||
lines: &[Line<'static>],
|
||||
ambient_life: bool,
|
||||
elapsed_ms: u128,
|
||||
animated: bool,
|
||||
) {
|
||||
let Some(ramp) = ramp else {
|
||||
return;
|
||||
};
|
||||
|
||||
for local_y in 0..area.height {
|
||||
let protected = lines
|
||||
.get(usize::from(local_y))
|
||||
.and_then(occupied_text_bounds);
|
||||
let row_bg = if animated {
|
||||
ramp.color_at_phase(local_y, area.height, elapsed_ms)
|
||||
} else {
|
||||
ramp.color_at(local_y, area.height)
|
||||
};
|
||||
for local_x in 0..area.width {
|
||||
let is_protected = protected.is_some_and(|(start, end)| {
|
||||
usize::from(local_x) >= start && usize::from(local_x) < end
|
||||
});
|
||||
if !is_protected {
|
||||
buf[(area.x + local_x, area.y + local_y)].set_bg(row_bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ambient_life {
|
||||
render_ambient_life(area, buf, ramp, lines, elapsed_ms, animated);
|
||||
}
|
||||
}
|
||||
|
||||
fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
|
||||
let text = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let leading = text
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_whitespace())
|
||||
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
|
||||
.sum::<usize>();
|
||||
let total = UnicodeWidthStr::width(text.as_str());
|
||||
let trailing = text
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|ch| ch.is_whitespace())
|
||||
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
|
||||
.sum::<usize>();
|
||||
Some((leading, total.saturating_sub(trailing)))
|
||||
}
|
||||
|
||||
fn render_ambient_life(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
ramp: crate::tui::ocean::OceanRamp,
|
||||
lines: &[Line<'static>],
|
||||
elapsed_ms: u128,
|
||||
animated: bool,
|
||||
) {
|
||||
if area.width < 68 || area.height < 15 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (drift_a, fish_a_forward) = if animated {
|
||||
ambient_ping_pong(elapsed_ms, 620, 11, 0)
|
||||
} else {
|
||||
(0, true)
|
||||
};
|
||||
let (drift_b, fish_b_forward) = if animated {
|
||||
ambient_ping_pong(elapsed_ms, 760, 8, 1_900)
|
||||
} else {
|
||||
(0, false)
|
||||
};
|
||||
let rise = if animated {
|
||||
u16::try_from((elapsed_ms / 1_100) % 4).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let marks = [
|
||||
(
|
||||
area.width / 9 + drift_a,
|
||||
area.height * 7 / 10,
|
||||
if fish_a_forward { "><>" } else { "<><" },
|
||||
),
|
||||
(
|
||||
(area.width * 4 / 5).saturating_sub(drift_b),
|
||||
area.height * 2 / 5,
|
||||
if fish_b_forward { "><>" } else { "<><" },
|
||||
),
|
||||
(
|
||||
area.width * 3 / 4,
|
||||
(area.height / 4).saturating_sub(rise),
|
||||
"°",
|
||||
),
|
||||
];
|
||||
for (local_x, local_y, mark) in marks {
|
||||
let protected = lines
|
||||
.get(usize::from(local_y))
|
||||
.and_then(occupied_text_bounds);
|
||||
let mark_width = UnicodeWidthStr::width(mark);
|
||||
let collides = protected.is_some_and(|(start, end)| {
|
||||
usize::from(local_x) < end && usize::from(local_x) + mark_width > start
|
||||
});
|
||||
if collides || local_x.saturating_add(mark_width as u16) > area.width {
|
||||
continue;
|
||||
}
|
||||
for (offset, ch) in mark.chars().enumerate() {
|
||||
buf[(area.x + local_x + offset as u16, area.y + local_y)]
|
||||
.set_symbol(&ch.to_string())
|
||||
.set_fg(ramp.ambient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discrete cells cannot use CSS easing, so continuity matters more than raw
|
||||
/// speed. This triangular path reverses instead of wrapping/teleporting, and
|
||||
/// per-fish cadence/phase keeps the empty field from looking synchronized.
|
||||
fn ambient_ping_pong(elapsed_ms: u128, step_ms: u128, span: u16, phase_ms: u128) -> (u16, bool) {
|
||||
if span == 0 || step_ms == 0 {
|
||||
return (0, true);
|
||||
}
|
||||
let period = u128::from(span) * 2;
|
||||
let phase = ((elapsed_ms + phase_ms) / step_ms) % period;
|
||||
if phase <= u128::from(span) {
|
||||
(u16::try_from(phase).unwrap_or(span), true)
|
||||
} else {
|
||||
(
|
||||
u16::try_from(period.saturating_sub(phase)).unwrap_or(0),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_to_latest_button_rect(area: Rect, has_scrollbar: bool) -> Option<Rect> {
|
||||
if area.width < JUMP_TO_LATEST_BUTTON_WIDTH + u16::from(has_scrollbar)
|
||||
|| area.height < JUMP_TO_LATEST_BUTTON_HEIGHT
|
||||
@@ -653,13 +826,22 @@ impl<'a> ComposerWidget<'a> {
|
||||
actual.max(usize::from(self.max_height_cap()))
|
||||
}
|
||||
|
||||
fn has_panel(&self, area: Rect) -> bool {
|
||||
self.app.composer_border && area.height >= 3 && area.width >= 12
|
||||
fn wants_enclosed_panel(&self) -> bool {
|
||||
self.app.composer_border
|
||||
&& (self.app.is_history_search_active()
|
||||
|| self.app.composer_display_input().contains('\n')
|
||||
|| self.active_menu_row_count() > 0)
|
||||
}
|
||||
|
||||
pub(crate) fn has_panel(&self, area: Rect) -> bool {
|
||||
self.wants_enclosed_panel() && area.height >= 3 && area.width >= 12
|
||||
}
|
||||
|
||||
fn inner_area(&self, area: Rect) -> Rect {
|
||||
if self.has_panel(area) {
|
||||
Block::default().borders(Borders::ALL).inner(area)
|
||||
} else if area.height >= 2 {
|
||||
Block::default().borders(Borders::TOP).inner(area)
|
||||
} else {
|
||||
area
|
||||
}
|
||||
@@ -827,6 +1009,15 @@ impl Renderable for ComposerWidget<'_> {
|
||||
block = block.title_bottom(hint_line);
|
||||
}
|
||||
block.render(area, buf);
|
||||
} else if area.height >= 2 {
|
||||
let mut block = Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::default().fg(self.app.ui_theme.border))
|
||||
.style(background);
|
||||
if let Some(chrome) = composer_top_right_chrome(self.app, area.width) {
|
||||
block = block.title_top(chrome.right_aligned());
|
||||
}
|
||||
block.render(area, buf);
|
||||
} else {
|
||||
Block::default().style(background).render(area, buf);
|
||||
}
|
||||
@@ -1163,6 +1354,19 @@ impl Renderable for ComposerWidget<'_> {
|
||||
.style(background)
|
||||
.wrap(Wrap { trim: false });
|
||||
paragraph.render(inner_area, buf);
|
||||
|
||||
// The quiet composer needs one unmistakable focus anchor. Keep the
|
||||
// reference's gold prompt only on a genuinely empty input row; once
|
||||
// text exists, the text itself owns attention.
|
||||
if input_text.is_empty()
|
||||
&& !self.app.is_history_search_active()
|
||||
&& inner_area.width >= 3
|
||||
&& let Some((cursor_x, cursor_y)) = self.cursor_pos(area)
|
||||
{
|
||||
buf[(cursor_x.saturating_sub(2), cursor_y)]
|
||||
.set_symbol("❯")
|
||||
.set_style(Style::default().fg(self.app.ui_theme.accent_primary));
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
@@ -1172,7 +1376,7 @@ impl Renderable for ComposerWidget<'_> {
|
||||
self.max_height.min(self.max_height_cap()),
|
||||
self.active_menu_reserved_rows(),
|
||||
self.app.composer_density,
|
||||
self.app.composer_border,
|
||||
self.wants_enclosed_panel(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1203,9 +1407,13 @@ impl Renderable for ComposerWidget<'_> {
|
||||
};
|
||||
let top_padding = composer_top_padding(visual_rows, input_rows_budget);
|
||||
|
||||
let idle_prompt_inset = u16::from(
|
||||
input_text.is_empty() && !self.app.is_history_search_active() && inner_area.width >= 3,
|
||||
) * 2;
|
||||
let cursor_x = area
|
||||
.x
|
||||
.saturating_add(inner_area.x.saturating_sub(area.x))
|
||||
.saturating_add(idle_prompt_inset)
|
||||
.saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX));
|
||||
let cursor_y = area
|
||||
.y
|
||||
@@ -1244,7 +1452,12 @@ impl<'a> ApprovalWidget<'a> {
|
||||
let risk = self.request.risk;
|
||||
let stakes = self.request.stakes();
|
||||
let locale = self.view.locale();
|
||||
let palette_colors = approval_palette(stakes);
|
||||
let repo_law = self.request.is_repo_law_prompt();
|
||||
let palette_colors = if repo_law {
|
||||
repo_law_approval_palette()
|
||||
} else {
|
||||
approval_palette(stakes)
|
||||
};
|
||||
let critical = matches!(stakes, crate::tui::approval::ApprovalStakes::Critical);
|
||||
|
||||
let mut body: Vec<Line<'static>> = Vec::with_capacity(16);
|
||||
@@ -1252,7 +1465,14 @@ impl<'a> ApprovalWidget<'a> {
|
||||
body.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!(" {} ", stakes_badge_text(stakes, locale)),
|
||||
format!(
|
||||
" {} ",
|
||||
if repo_law {
|
||||
Cow::Borrowed("REPO LAW")
|
||||
} else {
|
||||
stakes_badge_text(stakes, locale)
|
||||
}
|
||||
),
|
||||
Style::default()
|
||||
.fg(palette::WHALE_BG)
|
||||
.bg(palette_colors.accent)
|
||||
@@ -1260,13 +1480,43 @@ impl<'a> ApprovalWidget<'a> {
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
self.request.tool_name.clone(),
|
||||
if repo_law {
|
||||
format!("Repository constitution · {}", self.request.tool_name)
|
||||
} else {
|
||||
self.request.tool_name.clone()
|
||||
},
|
||||
Style::default()
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
|
||||
if repo_law {
|
||||
body.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"◆ ",
|
||||
Style::default()
|
||||
.fg(palette::STATUS_WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
"Repository law requires confirmation — even in Full Access.",
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ERROR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
body.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("Rule ", Style::default().fg(palette::TEXT_HINT)),
|
||||
Span::styled(
|
||||
self.request.description.clone(),
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Command / change preview FIRST — for an approval the thing being run
|
||||
// is the load-bearing content, so on a short terminal it is the
|
||||
// secondary context (about/impacts/category) that scrolls away, never
|
||||
@@ -1448,11 +1698,24 @@ impl Renderable for ApprovalWidget<'_> {
|
||||
Clear.render(bar_area, buf);
|
||||
|
||||
let stakes = self.request.stakes();
|
||||
let palette_colors = approval_palette(stakes);
|
||||
let repo_law = self.request.is_repo_law_prompt();
|
||||
let palette_colors = if repo_law {
|
||||
repo_law_approval_palette()
|
||||
} else {
|
||||
approval_palette(stakes)
|
||||
};
|
||||
let summary = format!(
|
||||
" {} — {} [Tab to expand] ",
|
||||
self.request.tool_name,
|
||||
stakes_badge_text(stakes, self.view.locale()),
|
||||
if repo_law {
|
||||
"Repository constitution"
|
||||
} else {
|
||||
self.request.tool_name.as_str()
|
||||
},
|
||||
if repo_law {
|
||||
Cow::Borrowed("REPO LAW")
|
||||
} else {
|
||||
stakes_badge_text(stakes, self.view.locale())
|
||||
},
|
||||
);
|
||||
let line = Line::from(Span::styled(
|
||||
summary,
|
||||
@@ -1469,7 +1732,12 @@ impl Renderable for ApprovalWidget<'_> {
|
||||
// analysis on shell commands); reuse it for the palette and the
|
||||
// left-rail gate instead of re-deriving per band.
|
||||
let stakes = self.request.stakes();
|
||||
let palette_colors = approval_palette(stakes);
|
||||
let repo_law = self.request.is_repo_law_prompt();
|
||||
let palette_colors = if repo_law {
|
||||
repo_law_approval_palette()
|
||||
} else {
|
||||
approval_palette(stakes)
|
||||
};
|
||||
let (body, controls) = self.build_inline_content(area);
|
||||
let region = inline_region_for(area, &body, &controls);
|
||||
if region.width == 0 || region.height == 0 {
|
||||
@@ -1548,7 +1816,7 @@ impl Renderable for ApprovalWidget<'_> {
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(control_rect, buf);
|
||||
|
||||
if matches!(stakes, crate::tui::approval::ApprovalStakes::Critical) {
|
||||
if repo_law || matches!(stakes, crate::tui::approval::ApprovalStakes::Critical) {
|
||||
paint_left_rail(region, buf, palette_colors.accent);
|
||||
}
|
||||
}
|
||||
@@ -1732,6 +2000,14 @@ fn approval_palette(stakes: crate::tui::approval::ApprovalStakes) -> ApprovalCol
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_law_approval_palette() -> ApprovalColors {
|
||||
ApprovalColors {
|
||||
border: palette::STATUS_WARNING,
|
||||
accent: palette::WHALE_ERROR,
|
||||
shortcut: palette::STATUS_WARNING,
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_selected_style() -> Style {
|
||||
Style::default()
|
||||
.fg(palette::SELECTION_TEXT)
|
||||
@@ -2556,6 +2832,40 @@ fn should_render_empty_state(app: &App) -> bool {
|
||||
&& !app.is_loading
|
||||
&& !app.is_compacting
|
||||
&& !app.is_purging
|
||||
&& !app.attention_hold_active()
|
||||
&& !app
|
||||
.task_panel
|
||||
.iter()
|
||||
.any(|task| task.kind == crate::tui::app::TaskPanelEntryKind::Background)
|
||||
&& crate::tui::sidebar::compact_work_indicator(app).is_none()
|
||||
}
|
||||
|
||||
const CW_WHALE_MARK_WIDTH: usize = 22;
|
||||
const CW_WHALE_MARK_MIN_HEIGHT: usize = 14;
|
||||
|
||||
fn centered_line(text: String, width: u16, style: Style) -> Line<'static> {
|
||||
let text = truncate_display_width(&text, usize::from(width));
|
||||
let inset =
|
||||
" ".repeat(usize::from(width).saturating_sub(UnicodeWidthStr::width(text.as_str())) / 2);
|
||||
Line::from(Span::styled(format!("{inset}{text}"), style))
|
||||
}
|
||||
|
||||
fn cw_whale_mark_lines(app: &App, width: u16) -> Vec<Line<'static>> {
|
||||
let gold = Style::default().fg(app.ui_theme.accent_primary);
|
||||
let seafoam = Style::default().fg(app.ui_theme.accent_secondary);
|
||||
let ivory = Style::default().fg(app.ui_theme.text_body);
|
||||
let inset = " ".repeat(usize::from(width).saturating_sub(CW_WHALE_MARK_WIDTH) / 2);
|
||||
|
||||
vec![
|
||||
Line::from(Span::styled(format!("{inset} ˚"), seafoam)),
|
||||
Line::from(Span::styled(format!("{inset} ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▖ ▚▞"), gold)),
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{inset}▐██"), gold),
|
||||
Span::styled("·", ivory),
|
||||
Span::styled("████████████▙▄▄▄▞", gold),
|
||||
]),
|
||||
Line::from(Span::styled(format!("{inset} ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▘"), gold)),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
|
||||
@@ -2564,40 +2874,41 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
|
||||
}
|
||||
|
||||
let workspace = crate::utils::display_path(&app.workspace);
|
||||
let title = format!(">_ codewhale (v{})", env!("CARGO_PKG_VERSION"));
|
||||
let model = format!("model: {} /model to switch", app.model);
|
||||
let directory = format!("directory: {workspace}");
|
||||
let block_width = [&title, &model, &directory]
|
||||
.into_iter()
|
||||
.map(|line| UnicodeWidthStr::width(line.as_str()))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let left_padding = usize::from(area.width).saturating_sub(block_width) / 2;
|
||||
let inset = " ".repeat(left_padding);
|
||||
let workspace_identity = crate::tui::workspace_context::identity_from_context(
|
||||
&app.workspace,
|
||||
app.workspace_context.as_deref(),
|
||||
);
|
||||
let repo_state = workspace_identity.branch.as_deref().unwrap_or("no git");
|
||||
let context = format!(
|
||||
"codewhale · {workspace} · {repo_state} · mcp {}",
|
||||
app.mcp_configured_count
|
||||
);
|
||||
let fleet = "Fleet /fleet setup".to_string();
|
||||
let routes = "Model /model Rules /constitution Setup /setup".to_string();
|
||||
let mark_fits = usize::from(area.width) >= CW_WHALE_MARK_WIDTH + 2
|
||||
&& usize::from(area.height) >= CW_WHALE_MARK_MIN_HEIGHT;
|
||||
|
||||
let body = vec![
|
||||
Line::from(Span::styled(
|
||||
format!("{inset}{title}"),
|
||||
Style::default().fg(palette::WHALE_ACCENT_PRIMARY).bold(),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
format!("{inset}{model}"),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("{inset}{directory}"),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
];
|
||||
|
||||
// Keep the welcome block near the top of the chat pane (header is separate).
|
||||
let top_padding = 2usize;
|
||||
let mut lines = Vec::new();
|
||||
for _ in 0..top_padding {
|
||||
let mut lines = vec![Line::from("")];
|
||||
if mark_fits {
|
||||
lines.extend(cw_whale_mark_lines(app, area.width));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(body);
|
||||
lines.push(centered_line(
|
||||
context,
|
||||
area.width,
|
||||
Style::default().fg(app.ui_theme.text_muted),
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(centered_line(
|
||||
fleet,
|
||||
area.width,
|
||||
Style::default().fg(app.ui_theme.accent_secondary).bold(),
|
||||
));
|
||||
lines.push(centered_line(
|
||||
routes,
|
||||
area.width,
|
||||
Style::default().fg(app.ui_theme.text_hint),
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -2672,6 +2983,8 @@ fn composer_height(
|
||||
let has_panel = show_panel && available_height >= 3 && width >= 12;
|
||||
let chrome_height = if has_panel {
|
||||
usize::from(COMPOSER_PANEL_HEIGHT)
|
||||
} else if available_height >= 2 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -2686,6 +2999,11 @@ fn composer_height(
|
||||
}
|
||||
if has_panel {
|
||||
line_count = line_count.max(composer_min_input_rows(density));
|
||||
} else if input.is_empty() && available_height >= 3 {
|
||||
// Quiet launch composer: rule + focused prompt + one explanatory hint.
|
||||
// Keeping the hint visible makes the surface self-explanatory and is
|
||||
// also the terminal-level readiness contract used by PTY QA.
|
||||
line_count = line_count.max(2);
|
||||
}
|
||||
line_count = line_count
|
||||
.saturating_add(extra_lines)
|
||||
@@ -3467,19 +3785,22 @@ fn line_spans_with_selection<'a>(
|
||||
mod tests {
|
||||
use super::{
|
||||
ACTIVE_REVISION_DOMAIN, ApprovalWidget, COMPOSER_PANEL_HEIGHT, COMPOSER_PLACEHOLDER,
|
||||
ChatWidget, ComposerWidget, Renderable, SlashMenuEntry, active_entry_revision,
|
||||
apply_detail_target_highlight, apply_selection_to_line, apply_send_flash,
|
||||
build_empty_state_lines, composer_height, composer_max_height, composer_min_input_rows,
|
||||
composer_top_padding, cursor_row_col, empty_composer_visual_rows, history_entry_revision,
|
||||
layout_input, pad_lines_to_bottom, placeholder_visual_lines, push_command_entry,
|
||||
revision_in_domain, should_render_empty_state, slash_completion_hints,
|
||||
tool_run_summary_revision, wrap_input_lines, wrap_input_lines_for_mouse, wrap_text,
|
||||
CW_WHALE_MARK_MIN_HEIGHT, ChatWidget, ComposerWidget, Renderable, SlashMenuEntry,
|
||||
active_entry_revision, ambient_ping_pong, apply_detail_target_highlight,
|
||||
apply_selection_to_line, apply_send_flash, build_empty_state_lines, composer_height,
|
||||
composer_max_height, composer_min_input_rows, composer_top_padding, cursor_row_col,
|
||||
empty_composer_visual_rows, history_entry_revision, layout_input, pad_lines_to_bottom,
|
||||
placeholder_visual_lines, push_command_entry, revision_in_domain,
|
||||
should_render_empty_state, slash_completion_hints, tool_run_summary_revision,
|
||||
wrap_input_lines, wrap_input_lines_for_mouse, wrap_text,
|
||||
};
|
||||
use crate::config::{ApiProvider, Config};
|
||||
use crate::localization::Locale;
|
||||
use crate::palette;
|
||||
use crate::tui::active_cell::ActiveCell;
|
||||
use crate::tui::app::{App, ComposerDensity, ToolCollapseMode, TuiOptions};
|
||||
use crate::tui::app::{
|
||||
App, ComposerDensity, TaskPanelEntry, TaskPanelEntryKind, ToolCollapseMode, TuiOptions,
|
||||
};
|
||||
use crate::tui::history::{
|
||||
ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus,
|
||||
};
|
||||
@@ -3490,7 +3811,10 @@ mod tests {
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
@@ -4584,12 +4908,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_height_skips_panel_chrome_when_border_disabled() {
|
||||
fn composer_height_uses_quiet_rule_when_panel_is_not_needed() {
|
||||
let with_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
|
||||
let without_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, false);
|
||||
|
||||
assert_eq!(with_border, 5);
|
||||
assert_eq!(without_border, 1);
|
||||
assert_eq!(without_border, 3);
|
||||
assert!(without_border < with_border);
|
||||
}
|
||||
|
||||
@@ -4620,17 +4944,17 @@ mod tests {
|
||||
height: 5,
|
||||
};
|
||||
|
||||
// inner_area: {x:1, y:1, w:38, h:3} (borders shrink by 1 each side)
|
||||
// input_rows_budget = 3
|
||||
// Normal one-line composition uses only the top rule, preserving the
|
||||
// reference's continuous water field instead of drawing a full box.
|
||||
// inner_area: {x:0, y:1, w:40, h:4}
|
||||
// input_rows_budget = 4
|
||||
// empty_composer_visual_rows = cursor row + one hint row = 2
|
||||
// top_padding = 3 - clamp(2, 1, 3) = 1
|
||||
// cursor_x = 0 + (1-0) + 0 = 1
|
||||
// cursor_y = 0 + (1-0) + (1+0) = 2
|
||||
// top_padding = 4 - clamp(2, 1, 4) = 2
|
||||
assert_eq!(
|
||||
empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 38, 3),
|
||||
empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 40, 4),
|
||||
2
|
||||
);
|
||||
assert_eq!(widget.cursor_pos(area), Some((1, 2)));
|
||||
assert_eq!(widget.cursor_pos(area), Some((2, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4649,19 +4973,17 @@ mod tests {
|
||||
height: 5,
|
||||
};
|
||||
|
||||
// inner_area: {x:1, y:1, w:12, h:3}
|
||||
// input_rows_budget = 3
|
||||
// placeholder_visual_lines(12) = 2 ("Write a task" / " or use /.")
|
||||
// inner_area: {x:0, y:1, w:14, h:4}
|
||||
// input_rows_budget = 4
|
||||
// placeholder_visual_lines(14) = 2
|
||||
// empty_composer_visual_rows = cursor row + two hint rows = 3
|
||||
// top_padding = 3 - clamp(3, 1, 3) = 0
|
||||
// cursor_x = 0 + (1-0) + 0 = 1
|
||||
// cursor_y = 0 + (1-0) + (0+0) = 1
|
||||
assert_eq!(placeholder_visual_lines(12), 2);
|
||||
// top_padding = 4 - clamp(3, 1, 4) = 1
|
||||
assert_eq!(placeholder_visual_lines(14), 2);
|
||||
assert_eq!(
|
||||
empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 12, 3),
|
||||
empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 14, 4),
|
||||
3
|
||||
);
|
||||
assert_eq!(widget.cursor_pos(area), Some((1, 1)));
|
||||
assert_eq!(widget.cursor_pos(area), Some((2, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4842,7 +5164,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_composer_cursor_uses_full_area_when_border_disabled() {
|
||||
fn empty_composer_cursor_follows_idle_prompt_when_border_disabled() {
|
||||
let mut app = create_test_app();
|
||||
app.composer_density = ComposerDensity::Comfortable;
|
||||
app.composer_border = false;
|
||||
@@ -4857,7 +5179,7 @@ mod tests {
|
||||
height: 3,
|
||||
};
|
||||
|
||||
assert_eq!(widget.cursor_pos(area), Some((0, 1)));
|
||||
assert_eq!(widget.cursor_pos(area), Some((2, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4909,11 +5231,43 @@ mod tests {
|
||||
assert!(!should_render_empty_state(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_tasks_suppress_the_launch_tableau() {
|
||||
let mut app = create_test_app();
|
||||
app.task_panel.push(TaskPanelEntry {
|
||||
id: "shell_1".to_string(),
|
||||
status: "running".to_string(),
|
||||
prompt_summary: "cargo test".to_string(),
|
||||
duration_ms: Some(100),
|
||||
kind: TaskPanelEntryKind::Background,
|
||||
stale: false,
|
||||
elapsed_since_output_ms: None,
|
||||
owner_agent_id: None,
|
||||
owner_agent_name: None,
|
||||
});
|
||||
|
||||
assert!(!should_render_empty_state(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attention_hold_freezes_the_whole_ocean_field() {
|
||||
let mut app = create_test_app();
|
||||
app.low_motion = false;
|
||||
app.fancy_animations = true;
|
||||
app.plan_prompt_pending = true;
|
||||
|
||||
let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20));
|
||||
|
||||
assert!(!widget.ocean_animated);
|
||||
assert!(!widget.ambient_life);
|
||||
assert!(!should_render_empty_state(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_shows_startup_context() {
|
||||
let mut app = create_test_app();
|
||||
app.workspace = PathBuf::from("/tmp/codewhale-test-workspace");
|
||||
app.model = "deepseek-v4-pro".to_string();
|
||||
app.mcp_configured_count = 2;
|
||||
|
||||
let lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20));
|
||||
let rendered = lines
|
||||
@@ -4927,16 +5281,16 @@ mod tests {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert!(rendered.contains(&format!(">_ codewhale (v{})", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(rendered.contains("model: deepseek-v4-pro /model to switch"));
|
||||
assert!(rendered.contains("directory: /tmp/codewhale-test-workspace"));
|
||||
assert!(rendered.contains("codewhale · /tmp/codewhale-test-workspace · no git · mcp 2"));
|
||||
assert!(rendered.contains("Fleet /fleet setup"));
|
||||
assert!(rendered.contains("Model /model"));
|
||||
assert!(rendered.contains("Rules /constitution"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_centers_startup_block_by_actual_text_width() {
|
||||
let mut app = create_test_app();
|
||||
app.workspace = PathBuf::from("/tmp/codewhale-test-workspace");
|
||||
app.model = "deepseek-v4-pro".to_string();
|
||||
|
||||
let lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20));
|
||||
let text_lines = lines
|
||||
@@ -4948,21 +5302,119 @@ mod tests {
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let title = format!(">_ codewhale (v{})", env!("CARGO_PKG_VERSION"));
|
||||
let model = "model: deepseek-v4-pro /model to switch";
|
||||
let directory = "directory: /tmp/codewhale-test-workspace";
|
||||
let block_width = [title.as_str(), model, directory]
|
||||
.into_iter()
|
||||
.map(UnicodeWidthStr::width)
|
||||
.max()
|
||||
.expect("startup block has lines");
|
||||
let expected_padding = (100usize - block_width) / 2;
|
||||
let actual_padding = text_lines[2].chars().take_while(|ch| *ch == ' ').count();
|
||||
let context = "codewhale · /tmp/codewhale-test-workspace · no git · mcp 0";
|
||||
let context_line = text_lines
|
||||
.iter()
|
||||
.find(|line| line.trim_start() == context)
|
||||
.expect("context line");
|
||||
let expected_padding = (100usize - UnicodeWidthStr::width(context)) / 2;
|
||||
let actual_padding = context_line.chars().take_while(|ch| *ch == ' ').count();
|
||||
|
||||
assert_eq!(actual_padding, expected_padding);
|
||||
assert_eq!(text_lines[2].trim_start(), title);
|
||||
assert_eq!(text_lines[4].trim_start(), model);
|
||||
assert_eq!(text_lines[5].trim_start(), directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underwater_launch_is_visibly_deep_and_preserves_text_cells() {
|
||||
let mut app = create_test_app();
|
||||
app.workspace = PathBuf::from("/tmp/codewhale-test-workspace");
|
||||
app.model = "deepseek-v4-pro".to_string();
|
||||
|
||||
let area = Rect::new(0, 0, 100, 20);
|
||||
let base = app.ui_theme.surface_bg;
|
||||
let mut buf = Buffer::empty(area);
|
||||
ChatWidget::new(&mut app, area).render(area, &mut buf);
|
||||
|
||||
assert_ne!(buf[(0, 0)].bg, buf[(0, 19)].bg);
|
||||
assert_eq!(buf[(11, 14)].symbol(), ">");
|
||||
|
||||
let context = "codewhale · /tmp/codewhale-test-workspace · no git · mcp 0";
|
||||
let context_x = ((100usize - UnicodeWidthStr::width(context)) / 2) as u16;
|
||||
assert_eq!(buf[(context_x, 6)].symbol(), "c");
|
||||
assert_eq!(buf[(context_x, 6)].bg, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_launch_keeps_fleet_setup_without_ambient_clutter() {
|
||||
let app = create_test_app();
|
||||
let rendered = build_empty_state_lines(&app, Rect::new(0, 0, 40, 12))
|
||||
.iter()
|
||||
.flat_map(|line| line.spans.iter())
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(rendered.contains("/fleet setup"));
|
||||
assert!(!rendered.contains("▗▄▄"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_hierarchy_survives_responsive_gate_sizes() {
|
||||
for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
|
||||
let mut app = create_test_app();
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
ChatWidget::new(&mut app, area).render(area, &mut buf);
|
||||
let rendered = buffer_text(&buf, area);
|
||||
|
||||
assert!(
|
||||
rendered.contains("Fleet") && rendered.contains("/fleet setup"),
|
||||
"Fleet must remain the launch priority at {width}x{height}:\n{rendered}"
|
||||
);
|
||||
if height < CW_WHALE_MARK_MIN_HEIGHT as u16 {
|
||||
assert!(
|
||||
!rendered.contains("▗▄▄"),
|
||||
"the decorative whale must yield before the Fleet action at {width}x{height}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_treatment_keeps_theme_surface_and_state_content() {
|
||||
let mut app = create_test_app();
|
||||
app.ocean_treatment = "flat".to_string();
|
||||
let area = Rect::new(0, 0, 100, 20);
|
||||
let base = app.ui_theme.surface_bg;
|
||||
let mut buf = Buffer::empty(area);
|
||||
ChatWidget::new(&mut app, area).render(area, &mut buf);
|
||||
|
||||
assert_eq!(buf[(0, 0)].bg, base);
|
||||
assert_eq!(buf[(0, 19)].bg, base);
|
||||
assert_eq!(buf[(11, 14)].symbol(), " ", "flat has no ambient fish");
|
||||
assert!(
|
||||
(0..area.height).any(|y| (0..area.width).any(|x| buf[(x, y)].symbol() == "F")),
|
||||
"Fleet setup remains available in flat mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_motion_freezes_the_ocean_without_removing_depth() {
|
||||
let mut app = create_test_app();
|
||||
app.low_motion = true;
|
||||
app.fancy_animations = true;
|
||||
let area = Rect::new(0, 0, 100, 20);
|
||||
app.ocean_started_at = Instant::now() - Duration::from_secs(2);
|
||||
let mut first = Buffer::empty(area);
|
||||
ChatWidget::new(&mut app, area).render(area, &mut first);
|
||||
|
||||
app.ocean_started_at = Instant::now() - Duration::from_secs(11);
|
||||
let mut second = Buffer::empty(area);
|
||||
ChatWidget::new(&mut app, area).render(area, &mut second);
|
||||
|
||||
assert_ne!(first[(0, 0)].bg, first[(0, 19)].bg);
|
||||
assert_eq!(first[(0, 0)].bg, second[(0, 0)].bg);
|
||||
assert_eq!(first[(11, 14)].symbol(), second[(11, 14)].symbol());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_path_reverses_without_teleporting() {
|
||||
let step = 620;
|
||||
let span = 11;
|
||||
assert_eq!(ambient_ping_pong(0, step, span, 0), (0, true));
|
||||
assert_eq!(ambient_ping_pong(step * 11, step, span, 0), (11, true));
|
||||
assert_eq!(ambient_ping_pong(step * 12, step, span, 0), (10, false));
|
||||
assert_eq!(ambient_ping_pong(step * 21, step, span, 0), (1, false));
|
||||
assert_eq!(ambient_ping_pong(step * 22, step, span, 0), (0, true));
|
||||
}
|
||||
|
||||
/// Probe: confirm `cell.lines_with_motion` returns no Line whose total
|
||||
@@ -5075,6 +5527,7 @@ mod tests {
|
||||
let mut app = create_test_app();
|
||||
let custom = ratatui::style::Color::Rgb(26, 27, 38);
|
||||
app.ui_theme = app.ui_theme.with_background_color(custom);
|
||||
app.ocean_treatment = "flat".to_string();
|
||||
app.add_message(HistoryCell::Assistant {
|
||||
content: "ready".to_string(),
|
||||
streaming: false,
|
||||
@@ -5352,6 +5805,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_law_approval_has_distinct_authority_grammar() {
|
||||
let request = crate::tui::approval::ApprovalRequest::new(
|
||||
"approval-law",
|
||||
"edit_file",
|
||||
"Repo law holds this write: \"manifest review\" protects Cargo.toml (matched Cargo.toml, .codewhale/constitution.json)",
|
||||
&serde_json::json!({ "path": "Cargo.toml", "old": "a", "new": "b" }),
|
||||
"edit_file:Cargo.toml",
|
||||
);
|
||||
assert!(request.is_repo_law_prompt());
|
||||
let view = crate::tui::approval::ApprovalView::new(request.clone());
|
||||
let widget = ApprovalWidget::new(&request, &view);
|
||||
let area = Rect::new(0, 0, 120, 30);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
widget.render(area, &mut buf);
|
||||
let rendered = buffer_text(&buf, area);
|
||||
assert!(rendered.contains("REPO LAW"), "{rendered}");
|
||||
assert!(rendered.contains("Repository constitution"), "{rendered}");
|
||||
assert!(rendered.contains("even in Full Access"), "{rendered}");
|
||||
assert!(rendered.contains("Cargo.toml"), "{rendered}");
|
||||
assert!((0..area.height).any(|y| {
|
||||
let cell = &buf[(1, y)];
|
||||
cell.symbol() == "┃" && cell.fg == palette::WHALE_ERROR
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_selected_destructive_option_uses_contrasting_highlight() {
|
||||
let request = crate::tui::approval::ApprovalRequest::new(
|
||||
|
||||
@@ -58,6 +58,10 @@ fn spawn_minimal(
|
||||
// Force a known base URL so the doctor / model probe never escapes
|
||||
// the box. 127.0.0.1:1 will refuse instantly.
|
||||
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
|
||||
// PTY scenarios assert state transitions, not animation cadence. Freeze
|
||||
// ambient motion so wait_for_idle measures product state instead of a
|
||||
// decorative ocean frame.
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
@@ -91,13 +95,14 @@ fn assert_viewport_starts_at_top(frame: &qa_harness::Frame) {
|
||||
first_row, 0,
|
||||
"viewport content drifted below row 0:\n{dump}"
|
||||
);
|
||||
let header = frame.row(0).to_ascii_lowercase();
|
||||
assert!(
|
||||
frame.row(0).contains("Plan")
|
||||
|| frame.row(0).contains("Act")
|
||||
|| frame.row(0).contains("Agent")
|
||||
|| frame.row(0).contains("Operate")
|
||||
|| frame.row(0).contains("Yolo")
|
||||
|| frame.row(0).contains("DeepSeek"),
|
||||
header.contains("plan")
|
||||
|| header.contains("act")
|
||||
|| header.contains("agent")
|
||||
|| header.contains("operate")
|
||||
|| header.contains("yolo")
|
||||
|| header.contains("deepseek"),
|
||||
"expected header content on row 0:\n{dump}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ visual motion and density for screen-reader and low-motion users.
|
||||
| Toggle | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `NO_ANIMATIONS=1` env var | unset | At startup, forces `low_motion = true` and `fancy_animations = false`. Overrides whatever's saved in `settings.toml`. |
|
||||
| `low_motion` setting | `false` | Uses calmer streaming pacing and a lower redraw cadence so cursor/status motion is less aggressive. The footer water strip is controlled separately by `fancy_animations`. |
|
||||
| `fancy_animations` setting | `true` | Footer water-spout strip and pulsing sub-agent counter. Set to `false` to keep live-turn chrome still. |
|
||||
| `status_indicator` setting | `whale` | Header status chip. Set to `dots` for the compact dot cycle or `off` to hide it. |
|
||||
| `calm_mode` setting | `false` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. |
|
||||
| `low_motion` setting | `false` | Freezes decorative and state animation without changing model text delivery. The footer water strip is controlled separately by `fancy_animations`. |
|
||||
| `fancy_animations` setting | `true` | Enables expressive live-state chrome. Set to `false` to keep live-turn chrome still. |
|
||||
| `ocean_treatment` setting | `ombre` | Chooses the background appearance: `ombre` paints the static water column; `flat` uses the theme surface. This is independent of motion settings. |
|
||||
| `status_indicator` setting | `cw` | Static typographic header mark. Set to `whale` or `dots` for the legacy animations, or `off` to hide it. |
|
||||
| `calm_mode` setting | `true` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. |
|
||||
| `show_thinking` setting | `true` | Set to `false` to hide model `reasoning_content` blocks entirely. |
|
||||
| `show_tool_details` setting | `true` | Set to `false` to render tool calls as one-liners without expanded payloads. |
|
||||
| `show_tool_details` setting | `false` | Set to `true` to expand tool calls inline; details remain available on demand either way. |
|
||||
|
||||
## Standard env-var surface
|
||||
|
||||
@@ -59,8 +60,8 @@ version renders cleanly.
|
||||
|
||||
## Notes for screen-reader users
|
||||
|
||||
* `low_motion` slows the idle redraw loop to ~120ms per frame so
|
||||
the cursor isn't constantly repositioned. Combined with
|
||||
* `low_motion` slows the idle redraw loop to ~120ms per frame and freezes state
|
||||
markers without synthesizing or throttling model text. Combined with
|
||||
`calm_mode`, the redraw rate stays low enough that VoiceOver /
|
||||
Orca announcements track linearly with model output instead of
|
||||
re-reading the whole screen on each tick.
|
||||
|
||||
Reference in New Issue
Block a user