fix(tui): focus live chrome on todos and agents

This commit is contained in:
Hunter B
2026-07-22 14:46:52 -07:00
parent fef8e8a0df
commit 5db74a09de
15 changed files with 608 additions and 162 deletions
+10
View File
@@ -78,6 +78,14 @@ surface.
### Changed
- Keep the top activity bar literal and actionable: active To-dos appear first,
followed by Sub-agents, while generic operations and coordination stay in
the detail surface. Completed-only bars auto-hide, and top/side layouts can
be resized by dragging their divider and retain the chosen size (#4700,
#4702).
- Use each theme's semantic colors for composer mode and permission rails, and
show a larger inline reasoning preview with clearer local/full expansion
affordances (#4699, #4701).
- Simplify the model-facing runtime around stable action tools (`File`, `Git`,
`Run`, deferred `Web`, and durable task and automation families), with legacy
spellings hidden for replay. Fresh sessions no longer reserve a Work surface
@@ -120,6 +128,8 @@ surface.
### Fixed
- Restore `uwu` theme config round-tripping and keep header permission colors
and authored idle-whale geometry aligned with the selected theme (#4696).
- Default canonical `Bash` runs with no explicit `cwd` to the active
`ToolContext.workspace`, including an isolated sub-agent worktree, instead of
falling through to the shared shell manager's parent workspace. The regression
@@ -307,6 +307,12 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
"work_surface_placement" | "work_surface" | "work_rail" => {
Some(app.work_surface.placement.as_setting().to_string())
}
"work_surface_top_height" | "work_top_height" => {
Some(app.work_surface.top_height.to_string())
}
"work_surface_side_width" | "work_side_width" => {
Some(app.work_surface.side_width.to_string())
}
"tool_collapse" | "tool_collapse_mode" | "collapse" => {
Some(app.tool_collapse_mode.as_setting().to_string())
}
@@ -1826,6 +1832,14 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.work_surface.last_area = None;
app.needs_redraw = true;
}
"work_surface_top_height" | "work_top_height" => {
app.work_surface.top_height = settings.work_surface_top_height;
app.needs_redraw = true;
}
"work_surface_side_width" | "work_side_width" => {
app.work_surface.side_width = settings.work_surface_side_width;
app.needs_redraw = true;
}
"bracketed_paste" | "paste" => {
app.use_bracketed_paste = settings.bracketed_paste;
app.needs_redraw = true;
+15
View File
@@ -63,6 +63,10 @@ pub struct SettingsSection {
pub fancy_animations: bool,
pub ocean_treatment: OceanTreatmentValue,
pub work_surface_placement: WorkSurfacePlacementValue,
#[schemars(range(min = 2, max = 16))]
pub work_surface_top_height: u16,
#[schemars(range(min = 26, max = 80))]
pub work_surface_side_width: u16,
pub paste_burst_detection: bool,
pub show_thinking: bool,
pub show_tool_details: bool,
@@ -378,6 +382,8 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
fancy_animations: settings.fancy_animations,
ocean_treatment: settings.ocean_treatment.as_str().into(),
work_surface_placement: settings.work_surface_placement.as_str().into(),
work_surface_top_height: settings.work_surface_top_height,
work_surface_side_width: settings.work_surface_side_width,
paste_burst_detection: settings.paste_burst_detection,
show_thinking: settings.show_thinking,
show_tool_details: settings.show_tool_details,
@@ -560,6 +566,14 @@ pub fn apply_document(
"work_surface_placement",
doc.settings.work_surface_placement.as_setting(),
),
(
"work_surface_top_height",
&doc.settings.work_surface_top_height.to_string(),
),
(
"work_surface_side_width",
&doc.settings.work_surface_side_width.to_string(),
),
(
"paste_burst_detection",
bool_str(doc.settings.paste_burst_detection),
@@ -912,6 +926,7 @@ impl UiThemeValue {
Some("dracula") => Ok(Self::Dracula),
Some("gruvbox-dark") => Ok(Self::GruvboxDark),
Some("matrix") => Ok(Self::Matrix),
Some("uwu") => Ok(Self::Uwu),
Some(other) => bail!("unsupported theme '{other}'"),
None => bail!("invalid theme '{value}'"),
}
+104 -40
View File
@@ -47,6 +47,12 @@ pub struct UiTheme {
pub mode_yolo: Color,
pub mode_plan: Color,
pub mode_operate: Color,
// Permission posture colors (Ask / Auto-Review / Full Access). These are
// explicit theme slots because the warm permission ramp is independent
// from the cool mode ramp.
pub permission_ask: Color,
pub permission_auto_review: Color,
pub permission_full_access: Color,
// Footer statusline colors
pub status_ready: Color,
pub status_working: Color,
@@ -113,6 +119,9 @@ pub const UI_THEME: UiTheme = UiTheme {
mode_yolo: MODE_YOLO,
mode_plan: MODE_PLAN,
mode_operate: MODE_OPERATE,
permission_ask: TEXT_REASONING,
permission_auto_review: WHALE_HUMAN,
permission_full_access: STATUS_WARNING,
status_ready: TEXT_MUTED,
status_working: WHALE_LIVE,
status_warning: STATUS_WARNING,
@@ -160,6 +169,9 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme {
mode_yolo: LIGHT_MODE_YOLO,
mode_plan: LIGHT_MODE_PLAN,
mode_operate: LIGHT_OPERATE,
permission_ask: Color::Rgb(146, 64, 14),
permission_auto_review: LIGHT_HUMAN,
permission_full_access: LIGHT_WARNING,
status_ready: LIGHT_TEXT_MUTED,
status_working: LIGHT_LIVE,
status_warning: LIGHT_WARNING,
@@ -203,6 +215,9 @@ pub const SOLARIZED_LIGHT_UI_THEME: UiTheme = UiTheme {
mode_yolo: Color::Rgb(0xDD, 0x32, 0x2F),
mode_plan: Color::Rgb(0xCC, 0x4B, 0x16),
mode_operate: Color::Rgb(0x6C, 0x71, 0xC4), // solarized violet
permission_ask: SOLARIZED_YELLOW,
permission_auto_review: SOLARIZED_ORANGE,
permission_full_access: SOLARIZED_RED,
status_ready: SOLARIZED_CYAN,
status_working: SOLARIZED_CYAN,
status_warning: SOLARIZED_YELLOW,
@@ -248,6 +263,9 @@ pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme {
// Near-white stays unmistakably distinct without claiming arbitrary pure
// white content as an already-resolved mode slot.
mode_operate: Color::Rgb(250, 250, 250),
permission_ask: Color::Rgb(181, 181, 181),
permission_auto_review: Color::Rgb(210, 210, 210),
permission_full_access: Color::Rgb(237, 237, 237),
status_ready: GRAYSCALE_TEXT_MUTED,
status_working: GRAYSCALE_TEXT_SOFT,
status_warning: GRAYSCALE_TEXT_BODY,
@@ -284,17 +302,20 @@ pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
error_surface: Color::Rgb(0x3a, 0x1f, 0x2a),
error_border: Color::Rgb(0xf3, 0x8b, 0xa8),
error_text: Color::Rgb(0xf5, 0xc2, 0xd0),
warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
success: Color::Rgb(0xa6, 0xe3, 0xa1), // green
info: Color::Rgb(0x89, 0xd9, 0xeb), // sky
mode_agent: Color::Rgb(0x8a, 0xb4, 0xfa), // blue
mode_yolo: Color::Rgb(0xf3, 0x8c, 0xa8), // red
mode_plan: Color::Rgb(0xfa, 0xb4, 0x87), // peach
mode_operate: Color::Rgb(0xcb, 0xa6, 0xf7), // mauve
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
diff_added_fg: Color::Rgb(0xa6, 0xe3, 0xa1), // green
warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
success: Color::Rgb(0xa6, 0xe3, 0xa1), // green
info: Color::Rgb(0x89, 0xd9, 0xeb), // sky
mode_agent: Color::Rgb(0x8a, 0xb4, 0xfa), // blue
mode_yolo: Color::Rgb(0xf3, 0x8c, 0xa8), // red
mode_plan: Color::Rgb(0xfa, 0xb4, 0x87), // peach
mode_operate: Color::Rgb(0xcb, 0xa6, 0xf7), // mauve
permission_ask: Color::Rgb(0xf9, 0xe2, 0xaf),
permission_auto_review: Color::Rgb(0xfa, 0xb3, 0x87),
permission_full_access: Color::Rgb(0xf3, 0x8b, 0xa8),
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
diff_added_fg: Color::Rgb(0xa6, 0xe3, 0xa1), // green
diff_deleted_fg: Color::Rgb(0xf3, 0x8b, 0xa8), // red
diff_added_bg: Color::Rgb(0x1f, 0x33, 0x29),
diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x2a),
@@ -327,17 +348,20 @@ pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
error_surface: Color::Rgb(0x33, 0x1c, 0x24),
error_border: Color::Rgb(0xf7, 0x76, 0x8e),
error_text: Color::Rgb(0xfa, 0xcc, 0xd4),
warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
success: Color::Rgb(0x9e, 0xce, 0x6a), // green
info: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
mode_agent: Color::Rgb(0x7b, 0xa2, 0xf7), // blue
mode_yolo: Color::Rgb(0xf7, 0x77, 0x8e), // red
mode_plan: Color::Rgb(0xff, 0x9f, 0x64), // orange
mode_operate: Color::Rgb(0xbb, 0x9a, 0xf7), // purple
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
diff_added_fg: Color::Rgb(0x9e, 0xce, 0x6a), // green
warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
success: Color::Rgb(0x9e, 0xce, 0x6a), // green
info: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
mode_agent: Color::Rgb(0x7b, 0xa2, 0xf7), // blue
mode_yolo: Color::Rgb(0xf7, 0x77, 0x8e), // red
mode_plan: Color::Rgb(0xff, 0x9f, 0x64), // orange
mode_operate: Color::Rgb(0xbb, 0x9a, 0xf7), // purple
permission_ask: Color::Rgb(0xe0, 0xaf, 0x68),
permission_auto_review: Color::Rgb(0xff, 0x9e, 0x64),
permission_full_access: Color::Rgb(0xf7, 0x76, 0x8e),
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
diff_added_fg: Color::Rgb(0x9e, 0xce, 0x6a), // green
diff_deleted_fg: Color::Rgb(0xf7, 0x76, 0x8e), // red
diff_added_bg: Color::Rgb(0x1b, 0x2b, 0x1f),
diff_deleted_bg: Color::Rgb(0x33, 0x1c, 0x24),
@@ -370,17 +394,20 @@ pub const DRACULA_UI_THEME: UiTheme = UiTheme {
error_surface: Color::Rgb(0x3a, 0x1f, 0x22),
error_border: Color::Rgb(0xff, 0x55, 0x55),
error_text: Color::Rgb(0xff, 0xbb, 0xbb),
warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
success: Color::Rgb(0x50, 0xfa, 0x7b), // green
info: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
mode_agent: Color::Rgb(0xbe, 0x93, 0xf9), // purple
mode_yolo: Color::Rgb(0xff, 0x56, 0x55), // red
mode_plan: Color::Rgb(0xff, 0xb9, 0x6c), // orange
mode_operate: Color::Rgb(0x8c, 0xe9, 0xfd), // cyan
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
diff_added_fg: Color::Rgb(0x50, 0xfa, 0x7b), // green
warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
success: Color::Rgb(0x50, 0xfa, 0x7b), // green
info: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
mode_agent: Color::Rgb(0xbe, 0x93, 0xf9), // purple
mode_yolo: Color::Rgb(0xff, 0x56, 0x55), // red
mode_plan: Color::Rgb(0xff, 0xb9, 0x6c), // orange
mode_operate: Color::Rgb(0x8c, 0xe9, 0xfd), // cyan
permission_ask: Color::Rgb(0xf1, 0xfa, 0x8c),
permission_auto_review: Color::Rgb(0xff, 0xb8, 0x6c),
permission_full_access: Color::Rgb(0xff, 0x55, 0x55),
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
diff_added_fg: Color::Rgb(0x50, 0xfa, 0x7b), // green
diff_deleted_fg: Color::Rgb(0xff, 0x55, 0x55), // red
diff_added_bg: Color::Rgb(0x21, 0x3a, 0x2a),
diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x22),
@@ -432,6 +459,9 @@ pub const TERMINAL_UI_THEME: UiTheme = UiTheme {
// render both in the status row.
mode_plan: Color::Magenta,
mode_operate: Color::LightCyan,
permission_ask: Color::Yellow,
permission_auto_review: Color::LightYellow,
permission_full_access: Color::LightRed,
// DarkGray gives "Ready" a low-contrast but still distinguishable hue
// versus default body text (which is `Color::Reset` on this theme).
status_ready: Color::DarkGray,
@@ -470,13 +500,16 @@ pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
error_surface: Color::Rgb(0x35, 0x1c, 0x18),
error_border: Color::Rgb(0xfb, 0x49, 0x34),
error_text: Color::Rgb(0xfc, 0xc4, 0xb8),
warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
success: Color::Rgb(0x8e, 0xc0, 0x7c), // green
info: Color::Rgb(0x83, 0xa5, 0x98), // blue
mode_agent: Color::Rgb(0x84, 0xa5, 0x98), // blue
mode_yolo: Color::Rgb(0xfb, 0x4a, 0x34), // red
mode_plan: Color::Rgb(0xfe, 0x81, 0x19), // orange
mode_operate: Color::Rgb(0xd3, 0x86, 0x9b), // purple
warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
success: Color::Rgb(0x8e, 0xc0, 0x7c), // green
info: Color::Rgb(0x83, 0xa5, 0x98), // blue
mode_agent: Color::Rgb(0x84, 0xa5, 0x98), // blue
mode_yolo: Color::Rgb(0xfb, 0x4a, 0x34), // red
mode_plan: Color::Rgb(0xfe, 0x81, 0x19), // orange
mode_operate: Color::Rgb(0xd3, 0x86, 0x9b), // purple
permission_ask: Color::Rgb(0xfa, 0xbd, 0x2f),
permission_auto_review: Color::Rgb(0xfe, 0x80, 0x19),
permission_full_access: Color::Rgb(0xfb, 0x49, 0x34),
status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray
status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
@@ -526,6 +559,9 @@ pub const CLAUDE_UI_THEME: UiTheme = UiTheme {
mode_yolo: Color::Rgb(0xc7, 0x45, 0x45), // red
mode_plan: Color::Rgb(0xe8, 0xa6, 0x5a), // amber
mode_operate: Color::Rgb(0x8a, 0x63, 0xd2), // violet
permission_ask: Color::Rgb(0xd4, 0xa0, 0x17),
permission_auto_review: Color::Rgb(0xe8, 0xa5, 0x5a),
permission_full_access: Color::Rgb(0xe0, 0x60, 0x60),
// Footer statusline
status_ready: Color::Rgb(0xa0, 0x9d, 0x96),
status_working: Color::Rgb(0x5d, 0xb8, 0xa6),
@@ -628,6 +664,9 @@ pub const MATRIX_UI_THEME: UiTheme = UiTheme {
mode_yolo: Color::Rgb(255, 100, 100),
mode_plan: Color::Rgb(255, 170, 60),
mode_operate: Color::Rgb(100, 255, 220),
permission_ask: Color::Rgb(204, 204, 0),
permission_auto_review: Color::Rgb(255, 170, 60),
permission_full_access: Color::Rgb(255, 100, 100),
status_ready: Color::Rgb(0, 85, 0),
status_working: Color::Rgb(
MATRIX_TEXT_BODY_RGB.0,
@@ -675,6 +714,9 @@ pub const UWU_UI_THEME: UiTheme = UiTheme {
mode_yolo: Color::Rgb(0xff, 0x55, 0x77),
mode_plan: Color::Rgb(0xff, 0xb4, 0xe8),
mode_operate: Color::Rgb(0x8a, 0xd4, 0xff),
permission_ask: Color::Rgb(0xff, 0xe0, 0x8a),
permission_auto_review: Color::Rgb(0xff, 0xd6, 0x9a),
permission_full_access: Color::Rgb(0xff, 0x6b, 0x8a),
status_ready: Color::Rgb(0x8a, 0x7c, 0x98),
status_working: Color::Rgb(0x9a, 0xec, 0xe0),
status_warning: Color::Rgb(0xff, 0xe0, 0x8a),
@@ -1026,6 +1068,28 @@ mod tests {
}
}
#[test]
fn every_selectable_theme_keeps_permission_lanes_distinct() {
for theme_id in SELECTABLE_THEMES {
let ui = theme_id.ui_theme();
let permissions = [
("ask", ui.permission_ask),
("auto-review", ui.permission_auto_review),
("full-access", ui.permission_full_access),
];
for (index, (name_a, color_a)) in permissions.iter().enumerate() {
for (name_b, color_b) in permissions.iter().skip(index + 1) {
assert_ne!(
color_a,
color_b,
"theme '{}' renders permission lanes '{name_a}' and '{name_b}' identically",
theme_id.name(),
);
}
}
}
}
#[test]
fn every_selectable_theme_separates_live_workers_from_completed_work() {
for theme_id in SELECTABLE_THEMES {
+56
View File
@@ -265,6 +265,10 @@ pub struct Settings {
/// Ocean Tasks / To-do / Workers rail placement: top, left, or right.
/// The lower edge remains owned by the composer and phase footer.
pub work_surface_placement: String,
/// Remembered total height (content plus divider) for top Work placement.
pub work_surface_top_height: u16,
/// Remembered total width (content plus divider) for side Work placement.
pub work_surface_side_width: u16,
/// Runtime-only 30 FPS cap for terminals that flicker at high redraw
/// rates. Separate from accessibility motion and text delivery.
#[serde(skip)]
@@ -438,6 +442,8 @@ impl Default for Settings {
fancy_animations: true,
ocean_treatment: "ombre".to_string(),
work_surface_placement: "top".to_string(),
work_surface_top_height: 3,
work_surface_side_width: 30,
constrained_frame_rate: false,
bracketed_paste: true,
paste_burst_detection: true,
@@ -651,6 +657,8 @@ impl Settings {
s.ocean_treatment = normalize_ocean_treatment(&s.ocean_treatment).to_string();
s.work_surface_placement =
normalize_work_surface_placement(&s.work_surface_placement).to_string();
s.work_surface_top_height = s.work_surface_top_height.clamp(2, 16);
s.work_surface_side_width = s.work_surface_side_width.clamp(26, 80);
s.inline_diffs = normalize_inline_diffs(&s.inline_diffs).to_string();
s.synchronized_output =
normalize_synchronized_output(&s.synchronized_output).to_string();
@@ -879,6 +887,14 @@ impl Settings {
}
self.work_surface_placement = normalized;
}
"work_surface_top_height" | "work_top_height" => {
self.work_surface_top_height =
parse_u16_range("work_surface_top_height", value, 2, 16)?;
}
"work_surface_side_width" | "work_side_width" => {
self.work_surface_side_width =
parse_u16_range("work_surface_side_width", value, 26, 80)?;
}
"bracketed_paste" | "paste" => {
self.bracketed_paste = parse_bool(value)?;
}
@@ -1143,6 +1159,14 @@ impl Settings {
" work_surface: {}",
self.work_surface_placement
));
lines.push(format!(
" work_top_height: {}",
self.work_surface_top_height
));
lines.push(format!(
" work_side_width: {}",
self.work_surface_side_width
));
lines.push(format!(" bracketed_paste: {}", self.bracketed_paste));
lines.push(format!(
" paste_burst_detect: {}",
@@ -1265,6 +1289,14 @@ impl Settings {
"work_surface_placement",
"Ocean Tasks/To-do/Workers rail placement: top/left/right",
),
(
"work_surface_top_height",
"Resizable To-do/Sub-agent top bar height: 2-16 rows",
),
(
"work_surface_side_width",
"Resizable To-do/Sub-agent side bar width: 26-80 columns",
),
(
"bracketed_paste",
"Terminal bracketed-paste mode: on/off (rare to disable)",
@@ -1600,6 +1632,17 @@ fn parse_usize_setting(key: &str, value: &str) -> Result<usize> {
})
}
fn parse_u16_range(key: &str, value: &str, min: u16, max: u16) -> Result<u16> {
let parsed = value
.trim()
.parse::<u16>()
.map_err(|_| anyhow::anyhow!("Invalid {key} '{value}': expected {min}-{max}"))?;
if !(min..=max).contains(&parsed) {
anyhow::bail!("Invalid {key} '{value}': expected {min}-{max}");
}
Ok(parsed)
}
fn parse_percent_setting(key: &str, value: &str) -> Result<f64> {
let trimmed = value.trim().trim_end_matches('%').trim();
let percent = trimmed.parse::<f64>().map_err(|_| {
@@ -1843,6 +1886,19 @@ mod tests {
assert_eq!(settings.work_surface_placement, "top");
}
#[test]
fn work_surface_drag_sizes_round_trip_with_bounded_values() {
let mut settings = Settings::default();
settings.set("work_surface_top_height", "9").unwrap();
settings.set("work_surface_side_width", "54").unwrap();
let body = toml::to_string(&settings).expect("serialize settings");
let restored: Settings = toml::from_str(&body).expect("restore settings");
assert_eq!(restored.work_surface_top_height, 9);
assert_eq!(restored.work_surface_side_width, 54);
assert!(settings.set("work_surface_top_height", "17").is_err());
assert!(settings.set("work_surface_side_width", "25").is_err());
}
#[test]
fn inline_diffs_default_full_and_persist_exactly_one_mode() {
let mut settings = Settings::default();
+5 -1
View File
@@ -3117,6 +3117,8 @@ impl App {
let ocean_treatment = crate::tui::ocean::OceanTreatment::parse(&settings.ocean_treatment);
let work_surface_placement =
crate::tui::work_surface::WorkSurfacePlacement::parse(&settings.work_surface_placement);
let work_surface_top_height = settings.work_surface_top_height;
let work_surface_side_width = settings.work_surface_side_width;
let synchronized_output_enabled = settings.synchronized_output_enabled();
let status_indicator = settings.status_indicator.clone();
let show_thinking = settings.show_thinking;
@@ -3430,8 +3432,10 @@ impl App {
selection_anchor: None,
},
viewport: ViewportState::default(),
work_surface: crate::tui::work_surface::WorkSurfaceState::with_placement(
work_surface: crate::tui::work_surface::WorkSurfaceState::with_layout(
work_surface_placement,
work_surface_top_height,
work_surface_side_width,
),
hunt: HuntState::default(),
session: SessionState::default(),
+12 -10
View File
@@ -1035,7 +1035,7 @@ fn render_thinking_collapsed_shows_details_affordance() {
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect::<String>();
assert!(text.contains("Full reasoning in Ctrl+O"));
assert!(text.contains("Ctrl+O: full reasoning"));
// Pin the actual header shape ("… reasoning done") — a bare
// `contains("reasoning")` is already satisfied by the Ctrl+O
// affordance line above and would never fail on its own.
@@ -1142,7 +1142,7 @@ fn render_thinking_streaming_truncated_shows_continues_affordance() {
// #861 RC4: when a streaming thinking block exceeds the line cap,
// surface a live affordance pointing at Ctrl+O. The earlier code
// suppressed the affordance unless `!streaming`.
let long = (1..=12)
let long = (1..=16)
.map(|i| format!("Reasoning line {i}"))
.collect::<Vec<_>>()
.join("\n");
@@ -1157,7 +1157,7 @@ fn render_thinking_streaming_truncated_shows_continues_affordance() {
);
// The most recent line must be the visible tail (head dropped).
assert!(
text.contains("Reasoning line 12"),
text.contains("Reasoning line 16"),
"tail line missing, got: {text}"
);
assert!(
@@ -2319,7 +2319,9 @@ fn long_thinking_display_is_shorter_than_transcript() {
Third paragraph: even more reasoning.\n\
With another line.\n\n\
Fourth paragraph: the conclusion.\n\
And one more line for good measure.";
And one more line for good measure.\n\n\
Fifth paragraph: final verification.\n\
One last supporting detail.";
let cell = HistoryCell::Thinking {
content: body.to_string(),
streaming: false,
@@ -2354,19 +2356,19 @@ fn long_thinking_display_is_shorter_than_transcript() {
"live thinking should preview completed reasoning: {live_text}"
);
assert!(
transcript_text.contains("Fourth paragraph"),
transcript_text.contains("Fifth paragraph"),
"transcript thinking must keep the full body"
);
assert!(
!live_text.contains("Fourth paragraph"),
!live_text.contains("Fifth paragraph"),
"live thinking must drop the tail when collapsed"
);
assert!(
live_text.contains("Full reasoning in Ctrl+O"),
live_text.contains("Ctrl+O: full reasoning"),
"live thinking must offer the pager affordance"
);
assert!(
!transcript_text.contains("Full reasoning in Ctrl+O"),
!transcript_text.contains("Ctrl+O: full reasoning"),
"transcript thinking must not include the live affordance"
);
}
@@ -2403,7 +2405,7 @@ fn completed_short_thinking_without_summary_stays_visible_in_live_view() {
"transcript thinking must keep the full reasoning body"
);
assert!(
!live_text.contains("Full reasoning in Ctrl+O"),
!live_text.contains("Ctrl+O: full reasoning"),
"complete short reasoning should not need the detail affordance: {live_text}"
);
}
@@ -2438,7 +2440,7 @@ fn completed_reasoning_receipt_hides_internal_function_names_until_expanded() {
"surrounding prose must still read: {collapsed_text}"
);
assert!(
collapsed_text.contains("Full reasoning in Ctrl+O"),
collapsed_text.contains("Ctrl+O: full reasoning"),
"collapsed receipt must offer the expand affordance: {collapsed_text}"
);
+3 -3
View File
@@ -18,8 +18,8 @@ pub(super) const REASONING_RAIL: &str = "\u{254E} "; // ╎ + space
pub(super) const REASONING_CURSOR: &str = "\u{258E}"; // ▎
const THINKING_SUMMARY_LINE_LIMIT: usize = 4;
const THINKING_COMPLETED_PREVIEW_LINE_LIMIT: usize = 6;
const THINKING_STREAMING_PREVIEW_LINE_LIMIT: usize = 8;
const THINKING_COMPLETED_PREVIEW_LINE_LIMIT: usize = 10;
const THINKING_STREAMING_PREVIEW_LINE_LIMIT: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThinkingVisualState {
@@ -261,7 +261,7 @@ pub(super) fn render_thinking(
let label = if streaming {
"More reasoning in Ctrl+O"
} else {
"Space to expand · Full reasoning in Ctrl+O"
"Space: expand here · Ctrl+O: full reasoning"
};
lines.push(Line::from(vec![
Span::styled(REASONING_RAIL.to_string(), rail_style),
+16 -9
View File
@@ -19,7 +19,6 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::tui::{
app::{App, AppMode, OnboardingState},
approval::ApprovalMode,
@@ -804,9 +803,9 @@ pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) {
// Match the composer's warm top edge exactly: Ask amber, Auto-Review
// Signal Gold, and Full Access coral.
let permission_color = match app.approval_mode {
ApprovalMode::Suggest | ApprovalMode::Never => palette::TEXT_REASONING,
ApprovalMode::Auto => palette::WHALE_HUMAN,
ApprovalMode::Bypass => palette::STATUS_WARNING,
ApprovalMode::Suggest | ApprovalMode::Never => app.ui_theme.permission_ask,
ApprovalMode::Auto => app.ui_theme.permission_auto_review,
ApprovalMode::Bypass => app.ui_theme.permission_full_access,
};
let status_indicator = crate::tui::widgets::header_status_indicator_frame(
(!app.low_motion && app.fancy_animations)
@@ -1311,13 +1310,18 @@ mod tests {
#[test]
fn header_labels_follow_the_ask_amber_auto_gold_full_access_coral_ramp() {
for width in [40, 100] {
for (approval_mode, expected_label, expected_color) in [
(ApprovalMode::Suggest, "ask", palette::TEXT_REASONING),
(ApprovalMode::Auto, "auto", palette::WHALE_HUMAN),
(ApprovalMode::Bypass, "Full Access", palette::STATUS_WARNING),
for (approval_mode, expected_label) in [
(ApprovalMode::Suggest, "ask"),
(ApprovalMode::Auto, "auto"),
(ApprovalMode::Bypass, "Full Access"),
] {
let mut app = test_app();
app.approval_mode = approval_mode;
let expected_color = match approval_mode {
ApprovalMode::Suggest | ApprovalMode::Never => app.ui_theme.permission_ask,
ApprovalMode::Auto => app.ui_theme.permission_auto_review,
ApprovalMode::Bypass => app.ui_theme.permission_full_access,
};
let label = permission_label(&app).into_owned();
assert_eq!(label, expected_label, "{approval_mode:?}");
let area = Rect::new(0, 0, width, 1);
@@ -1326,8 +1330,10 @@ mod tests {
render_header(area, &mut buf, &app);
let rendered = (0..width).map(|x| buf[(x, 0)].symbol()).collect::<String>();
// `auto` can also appear earlier as a route/mode label. The
// permission posture owns the rightmost occurrence.
let label_byte = rendered
.find(&label)
.rfind(&label)
.expect("permission label should render");
let label_x = rendered[..label_byte].width() as u16;
assert_eq!(buf[(label_x, 0)].fg, expected_color, "{approval_mode:?}");
@@ -1702,6 +1708,7 @@ mod tests {
#[test]
fn idle_whale_rows_share_one_centered_block_without_losing_authored_offsets() {
let mut app = test_app();
app.ui_theme = crate::palette::ThemeId::Whale.ui_theme();
app.low_motion = true;
let width = 60usize;
let rendered = empty_state_lines(&app, Rect::new(0, 0, width as u16, 16))
+22
View File
@@ -1570,6 +1570,20 @@ impl ConfigView {
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Sidebar,
key: "work_surface_top_height".to_string(),
value: settings.work_surface_top_height.to_string(),
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Sidebar,
key: "work_surface_side_width".to_string(),
value: settings.work_surface_side_width.to_string(),
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Sidebar,
key: "sidebar_width".to_string(),
@@ -2410,6 +2424,8 @@ fn config_label_for_key(key: &str) -> String {
"background_color" => "Background",
"ocean_treatment" => "Ocean treatment",
"work_surface_placement" => "Sidebar position",
"work_surface_top_height" => "Top bar height",
"work_surface_side_width" => "Side bar width",
"calm_mode" => "Quiet transcript",
"low_motion" => "Reduce motion",
"fancy_animations" => "Live UI motion",
@@ -2517,6 +2533,8 @@ fn config_hint_for_key(key: &str) -> &'static str {
"work_surface_placement" => {
"top | left | right · side rails require Ocean mode and at least 72 columns"
}
"work_surface_top_height" => "2..=16 rows · also adjustable by dragging the divider",
"work_surface_side_width" => "26..=80 columns · also adjustable by dragging the divider",
"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"
@@ -5032,6 +5050,8 @@ base_url = "https://api.xiaomimimo.com/v1"
visible_row_keys(&view),
vec![
"work_surface_placement",
"work_surface_top_height",
"work_surface_side_width",
"sidebar_width",
"sidebar_focus",
"context_panel",
@@ -5052,6 +5072,8 @@ base_url = "https://api.xiaomimimo.com/v1"
visible_row_keys(&view),
vec![
"work_surface_placement",
"work_surface_top_height",
"work_surface_side_width",
"sidebar_width",
"sidebar_focus",
"context_panel",
+47 -53
View File
@@ -1200,9 +1200,9 @@ impl<'a> ComposerWidget<'a> {
fn mode_color(&self) -> Color {
match self.app.mode {
AppMode::Agent | AppMode::Auto | AppMode::Yolo => palette::MODE_AGENT,
AppMode::Plan => palette::MODE_PLAN,
AppMode::Operate => palette::MODE_OPERATE,
AppMode::Agent | AppMode::Auto | AppMode::Yolo => self.app.ui_theme.mode_agent,
AppMode::Plan => self.app.ui_theme.mode_plan,
AppMode::Operate => self.app.ui_theme.mode_operate,
}
}
@@ -1350,9 +1350,9 @@ impl Renderable for ComposerWidget<'_> {
// and Full Access is coral. The bottom edge independently carries
// the cool Plan -> Act -> Operate mode ramp.
let permission_color = match self.app.approval_mode {
ApprovalMode::Suggest | ApprovalMode::Never => palette::TEXT_REASONING,
ApprovalMode::Auto => palette::WHALE_HUMAN,
ApprovalMode::Bypass => palette::STATUS_WARNING,
ApprovalMode::Suggest | ApprovalMode::Never => self.app.ui_theme.permission_ask,
ApprovalMode::Auto => self.app.ui_theme.permission_auto_review,
ApprovalMode::Bypass => self.app.ui_theme.permission_full_access,
};
let mut top_border = Block::default()
.borders(Borders::TOP)
@@ -5932,54 +5932,48 @@ mod tests {
let mention_menu_entries = Vec::<String>::new();
let area = Rect::new(0, 0, 40, 5);
// Shift-Tab cycle order must stay amber -> Signal Gold -> coral.
for (approval_mode, expected) in [
(ApprovalMode::Suggest, palette::TEXT_REASONING),
(ApprovalMode::Auto, palette::WHALE_HUMAN),
(ApprovalMode::Bypass, palette::STATUS_WARNING),
] {
let mut app = create_test_app();
app.approval_mode = approval_mode;
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let mut buf = Buffer::empty(area);
for theme_id in palette::SELECTABLE_THEMES {
let theme = theme_id.ui_theme();
for (approval_mode, expected) in [
(ApprovalMode::Suggest, theme.permission_ask),
(ApprovalMode::Never, theme.permission_ask),
(ApprovalMode::Auto, theme.permission_auto_review),
(ApprovalMode::Bypass, theme.permission_full_access),
] {
let mut app = create_test_app();
app.ui_theme = theme;
app.approval_mode = approval_mode;
let widget =
ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
assert_eq!(
buf[(1, area.top())].fg,
expected,
"{} {approval_mode:?}",
theme_id.name()
);
}
widget.render(area, &mut buf);
assert_eq!(buf[(1, area.top())].fg, expected, "{approval_mode:?}");
}
// Never is a fail-closed Ask-family posture, so it keeps Ask amber and
// never enters the three-step user-facing permission cycle.
let mut never_app = create_test_app();
never_app.approval_mode = ApprovalMode::Never;
let never_widget =
ComposerWidget::new(&never_app, 5, &slash_menu_entries, &mention_menu_entries);
let mut never_buf = Buffer::empty(area);
never_widget.render(area, &mut never_buf);
assert_eq!(
never_buf[(1, area.top())].fg,
palette::TEXT_REASONING,
"Never must keep the fail-closed Ask-family amber"
);
// The bottom edge remains the independent icy -> blue -> violet ramp.
for (mode, expected) in [
(AppMode::Plan, palette::MODE_PLAN),
(AppMode::Agent, palette::MODE_AGENT),
(AppMode::Operate, palette::MODE_OPERATE),
] {
let mut app = create_test_app();
app.mode = mode;
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
assert_eq!(
buf[(1, area.bottom().saturating_sub(1))].fg,
expected,
"{mode:?}"
);
for (mode, expected) in [
(AppMode::Plan, theme.mode_plan),
(AppMode::Agent, theme.mode_agent),
(AppMode::Operate, theme.mode_operate),
] {
let mut app = create_test_app();
app.ui_theme = theme;
app.mode = mode;
let widget =
ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
assert_eq!(
buf[(1, area.bottom().saturating_sub(1))].fg,
expected,
"{} {mode:?}",
theme_id.name()
);
}
}
}
+86 -2
View File
@@ -3,7 +3,10 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent,
use crate::tui::app::{App, SidebarRowAction};
use super::interaction::{activate_primary, claim_focus, close_opened, release_focus};
use super::model::{WorkRow, WorkRowId, project};
use super::model::{
SIDE_WIDTH_MAX, SIDE_WIDTH_MIN, TOP_HEIGHT_MAX, TOP_HEIGHT_MIN, WorkRow, WorkRowId,
WorkSurfacePlacement, project_visible,
};
#[derive(Debug, Default)]
pub struct MouseOutcome {
@@ -16,7 +19,7 @@ pub struct MouseOutcome {
/// a local stop arm / open detail first). Plain printable input always returns
/// ownership to the composer instead of becoming a hidden panel shortcut.
pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowAction>> {
let rows = project(app);
let rows = project_visible(app);
if rows.is_empty() {
return None;
}
@@ -85,6 +88,87 @@ pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> MouseOutcome {
let Some(area) = app.work_surface.last_area else {
return MouseOutcome::default();
};
let placement = app.work_surface.effective_placement;
let on_divider = match placement {
WorkSurfacePlacement::Top => {
mouse.row == area.bottom().saturating_sub(1)
&& mouse.column >= area.x
&& mouse.column < area.right()
}
WorkSurfacePlacement::Left => {
mouse.column == area.right().saturating_sub(1)
&& mouse.row >= area.y
&& mouse.row < area.bottom()
}
WorkSurfacePlacement::Right => {
mouse.column == area.x && mouse.row >= area.y && mouse.row < area.bottom()
}
};
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) if on_divider => {
app.work_surface.resizing = true;
app.work_surface.resize_anchor_column = mouse.column;
app.work_surface.resize_anchor_row = mouse.row;
app.work_surface.resize_anchor_size = match placement {
WorkSurfacePlacement::Top => area.height,
WorkSurfacePlacement::Left | WorkSurfacePlacement::Right => area.width,
};
app.needs_redraw = true;
return MouseOutcome {
consumed: true,
action: None,
};
}
MouseEventKind::Drag(MouseButton::Left) if app.work_surface.resizing => {
let anchor = i32::from(app.work_surface.resize_anchor_size);
match placement {
WorkSurfacePlacement::Top => {
let delta =
i32::from(mouse.row) - i32::from(app.work_surface.resize_anchor_row);
app.work_surface.top_height = (anchor + delta)
.clamp(i32::from(TOP_HEIGHT_MIN), i32::from(TOP_HEIGHT_MAX))
as u16;
}
WorkSurfacePlacement::Left => {
let delta =
i32::from(mouse.column) - i32::from(app.work_surface.resize_anchor_column);
app.work_surface.side_width = (anchor + delta)
.clamp(i32::from(SIDE_WIDTH_MIN), i32::from(SIDE_WIDTH_MAX))
as u16;
}
WorkSurfacePlacement::Right => {
let delta =
i32::from(app.work_surface.resize_anchor_column) - i32::from(mouse.column);
app.work_surface.side_width = (anchor + delta)
.clamp(i32::from(SIDE_WIDTH_MIN), i32::from(SIDE_WIDTH_MAX))
as u16;
}
}
app.needs_redraw = true;
return MouseOutcome {
consumed: true,
action: None,
};
}
MouseEventKind::Up(MouseButton::Left) if app.work_surface.resizing => {
app.work_surface.resizing = false;
if let Ok(mut settings) = crate::settings::Settings::load_persisted() {
settings.work_surface_top_height = app.work_surface.top_height;
settings.work_surface_side_width = app.work_surface.side_width;
if let Err(error) = settings.save() {
app.status_message =
Some(format!("Failed to save To-do/Sub-agent bar size: {error}"));
}
}
app.needs_redraw = true;
return MouseOutcome {
consumed: true,
action: None,
};
}
_ => {}
}
let inside = mouse.column >= area.x
&& mouse.column < area.right()
&& mouse.row >= area.y
+101 -1
View File
@@ -15,6 +15,7 @@ pub use render::{height, render, split_chat};
#[cfg(test)]
mod tests {
use super::WorkSurfacePlacement;
use std::path::PathBuf;
use crossterm::event::{
@@ -284,6 +285,8 @@ mod tests {
);
assert!(!body.contains("PRIVATE-TRANSCRIPT-MARKER"), "{body}");
app.work_surface.placement = WorkSurfacePlacement::Right;
app.work_surface.effective_placement = WorkSurfacePlacement::Right;
let narrow = render_text(&mut app, 32, 4);
assert!(narrow.contains("Coordination Work"), "{narrow}");
let _ = super::handle_key(
@@ -580,7 +583,7 @@ mod tests {
.iter()
.find(|row| row.id.0 == "worker:agent_worker")
.expect("agent work row");
assert_eq!(row.label, "Agent Blue Whale · worker");
assert_eq!(row.label, "Sub-agent Blue Whale · worker");
assert!(row.detail.contains("Wire settled file activity"));
assert!(row.detail.contains("using File.apply_patch"));
assert!(row.detail.contains("step 2"));
@@ -909,6 +912,8 @@ mod tests {
#[test]
fn work_rows_open_graph_inspector_without_inline_controls() {
let mut app = app();
app.work_surface.placement = WorkSurfacePlacement::Right;
app.work_surface.effective_placement = WorkSurfacePlacement::Right;
let graph = operation_graph(NodeState::Active);
restore_graph(&mut app, &graph);
app.runtime_services
@@ -1027,6 +1032,8 @@ mod tests {
},
);
app.work_surface.placement = WorkSurfacePlacement::Right;
app.work_surface.effective_placement = WorkSurfacePlacement::Right;
let text = render_text(&mut app, 80, 6);
assert!(text.contains("Wrote 4 files"), "{text}");
}
@@ -1209,6 +1216,99 @@ mod tests {
}
}
#[test]
fn divider_drag_resizes_top_left_and_right_surfaces() {
let mut top = app();
add_todos(&mut top, 3);
let _ = render_text(&mut top, 80, 3);
let down = super::handle_mouse(
&mut top,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 20,
row: 2,
modifiers: KeyModifiers::NONE,
},
);
assert!(down.consumed);
let _ = super::handle_mouse(
&mut top,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 20,
row: 7,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(top.work_surface.top_height, 8);
for (placement, drag_column, expected_width) in [
(WorkSurfacePlacement::Left, 39, 40),
(WorkSurfacePlacement::Right, 10, 26),
] {
let mut side = app();
add_todos(&mut side, 2);
side.work_surface.placement = placement;
side.work_surface.effective_placement = placement;
let _ = render_text(&mut side, 30, 8);
let divider_column = if placement == WorkSurfacePlacement::Left {
29
} else {
0
};
let _ = super::handle_mouse(
&mut side,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: divider_column,
row: 2,
modifiers: KeyModifiers::NONE,
},
);
let _ = super::handle_mouse(
&mut side,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: drag_column,
row: 2,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(
side.work_surface.side_width, expected_width,
"{placement:?}"
);
}
}
#[test]
fn top_bar_excludes_generic_operations() {
let mut operation_app = app();
let graph = operation_graph(NodeState::Failed);
restore_graph(&mut operation_app, &graph);
assert_eq!(super::height(&mut operation_app, 100, 24, false), 0);
assert!(operation_app.work_surface.latest_rows.is_empty());
let mut todo_app = app();
add_todos(&mut todo_app, 2);
assert!(super::height(&mut todo_app, 100, 24, false) > 0);
assert!(
todo_app
.work_surface
.latest_rows
.iter()
.all(|row| row.id.0.starts_with("graph:") || row.id.0.starts_with("worker:"))
);
assert!(
todo_app
.work_surface
.latest_rows
.iter()
.all(|row| !row.label.starts_with("Work ·"))
);
}
#[test]
fn opened_row_toggles_closed_without_losing_selection() {
let mut app = app();
+94 -7
View File
@@ -102,13 +102,21 @@ impl WorkSourceState {
pub(super) const RECENT_ONLY_TTL_MS: u64 = 4_000;
/// Settled file/search/write receipt lifetime in the live strip (#4690).
pub(super) const ACTIVITY_RECEIPT_TTL_MS: u64 = 3_000;
/// Bound live top-area content rows below the fixed route header (#4690).
pub(super) const LIVE_AUX_ROW_BUDGET: usize = 2;
pub(super) const TOP_HEIGHT_MIN: u16 = 2;
pub(super) const TOP_HEIGHT_MAX: u16 = 16;
pub(super) const SIDE_WIDTH_MIN: u16 = 26;
pub(super) const SIDE_WIDTH_MAX: u16 = 80;
#[derive(Debug, Clone)]
pub struct WorkSurfaceState {
pub placement: WorkSurfacePlacement,
pub(super) effective_placement: WorkSurfacePlacement,
pub top_height: u16,
pub side_width: u16,
pub(super) resizing: bool,
pub(super) resize_anchor_column: u16,
pub(super) resize_anchor_row: u16,
pub(super) resize_anchor_size: u16,
/// Focus owner axis — distinct from selection and detail-open.
pub focused: bool,
/// Keyboard/mouse selection highlight.
@@ -153,9 +161,20 @@ impl Default for WorkSurfaceState {
impl WorkSurfaceState {
#[must_use]
pub fn with_placement(placement: WorkSurfacePlacement) -> Self {
Self::with_layout(placement, 3, 30)
}
#[must_use]
pub fn with_layout(placement: WorkSurfacePlacement, top_height: u16, side_width: u16) -> Self {
Self {
placement,
effective_placement: placement,
top_height: top_height.clamp(TOP_HEIGHT_MIN, TOP_HEIGHT_MAX),
side_width: side_width.clamp(SIDE_WIDTH_MIN, SIDE_WIDTH_MAX),
resizing: false,
resize_anchor_column: 0,
resize_anchor_row: 0,
resize_anchor_size: 0,
focused: false,
selected: None,
opened: None,
@@ -319,6 +338,53 @@ pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
rows
}
/// Projection used by the live surface. The full Work catalog remains intact
/// for explicit inspectors, while persistent chrome stays literal: plan-step
/// to-dos first, then current sub-agents. Tool operations, coordination
/// receipts, file activity, and generic graph headings never enter this list.
pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
let rows = project(app);
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
return rows;
}
let todo_ids = app
.work_surface
.cached_graph
.as_ref()
.map(|snapshot| {
snapshot
.nodes
.iter()
.filter(|node| node.kind == NodeKind::PlanStep)
.map(|node| format!("graph:{}", node.id.as_str()))
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let mut todos = Vec::new();
let mut agents = Vec::new();
for row in rows {
if todo_ids.contains(&row.id.0) {
todos.push(row);
} else if row.id.0.starts_with("worker:") {
agents.push(row);
}
}
let has_live_item = todos
.iter()
.chain(agents.iter())
.any(|row| !matches!(row.tone, WorkTone::Success));
if !has_live_item {
app.work_surface.latest_rows.clear();
return Vec::new();
}
todos.extend(agents);
app.work_surface.latest_rows = todos.clone();
todos
}
fn graph_rows(
surface: &mut WorkSurfaceState,
snapshot: &WorkGraphSnapshot,
@@ -524,7 +590,6 @@ fn ordered_rows(
.iter()
.find(|item| item.bucket.is_actionable())
.map(|item| (item.bucket, sanitize_summary_title(&item.row.label)));
let heading_label = match (actionable > 0, subject.as_ref()) {
(true, Some((WorkBucket::Attention, title))) => {
format!("Work · Needs input: {title} · {attention} blocked{source}")
@@ -671,6 +736,7 @@ fn coordination_row(app: &App) -> Option<RankedWorkRow> {
fn node_bucket(node: &WorkNode) -> WorkBucket {
match node.state {
NodeState::Initializing | NodeState::Active => WorkBucket::Active,
NodeState::Failed if is_transient_failed_operation(node) => WorkBucket::Recent,
NodeState::Waiting | NodeState::Blocked | NodeState::Stale | NodeState::Failed => {
WorkBucket::Attention
}
@@ -683,6 +749,16 @@ fn node_bucket(node: &WorkNode) -> WorkBucket {
}
}
fn is_transient_failed_operation(node: &WorkNode) -> bool {
node.kind == NodeKind::Operation
&& node
.binding
.as_ref()
.is_some_and(|binding| !binding.durable)
&& node.acceptance.is_empty()
&& node.state == NodeState::Failed
}
fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
let cached_ids = app
.subagent_cache
@@ -745,7 +821,7 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
row: WorkRow {
id: WorkRowId(format!("worker:{}", agent.agent_id)),
mark: agent_mark(bucket),
label: format!("Agent {name} · {role}"),
label: format!("Sub-agent {name} · {role}"),
detail: facts.join(" · "),
tone: bucket_tone(bucket),
selectable: true,
@@ -807,7 +883,7 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
row: WorkRow {
id: WorkRowId(format!("worker:{id}")),
mark: agent_mark(bucket),
label: format!("Agent {name}"),
label: format!("Sub-agent {name}"),
detail: facts.join(" · "),
tone: bucket_tone(bucket),
selectable: true,
@@ -1755,10 +1831,11 @@ mod tests {
}
#[test]
fn projection_keeps_durable_and_attention_terminal_operations() {
fn projection_keeps_durable_and_evidence_gated_terminal_operations() {
let mut durable = operation(NodeState::Completed, "durable");
durable.binding.as_mut().expect("binding").durable = true;
let failed = operation(NodeState::Failed, "failed");
let mut failed = operation(NodeState::Failed, "failed");
failed.binding.as_mut().expect("binding").durable = true;
let mut evidence_pending = operation(NodeState::Completed, "evidence-pending");
evidence_pending.acceptance = vec![AcceptanceRequirement::EvidenceOfKind {
kind: EvidenceKindTag::ToolRun,
@@ -1788,6 +1865,16 @@ mod tests {
}
}
#[test]
fn transient_failed_operation_is_recent_while_durable_failure_needs_input() {
let transient = operation(NodeState::Failed, "shell transient");
let mut durable = operation(NodeState::Failed, "durable");
durable.binding.as_mut().expect("binding").durable = true;
assert_eq!(node_bucket(&transient), WorkBucket::Recent);
assert_eq!(node_bucket(&durable), WorkBucket::Attention);
}
#[test]
fn projection_orders_attention_before_ready_and_recent() {
let mut recent = operation(NodeState::Completed, "recent");
+23 -36
View File
@@ -11,11 +11,9 @@ use unicode_width::UnicodeWidthStr;
use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
use crate::tui::ui_text::truncate_line_to_width;
use super::model::{WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project};
use super::model::{WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project_visible};
const SIDE_RAIL_MIN_HOST_WIDTH: u16 = 72;
const SIDE_RAIL_MIN_WIDTH: u16 = 26;
const SIDE_RAIL_MAX_WIDTH: u16 = 40;
const SIDE_RAIL_MIN_CHAT_WIDTH: u16 = 40;
fn effective_placement(
@@ -32,8 +30,10 @@ fn effective_placement(
/// Responsive work-surface height. The component owns a bounded window; long
/// work lists scroll instead of consuming the transcript.
pub fn height(app: &mut App, width: u16, _terminal_height: u16, classic_shell: bool) -> u16 {
let rows = project(app);
pub fn height(app: &mut App, width: u16, terminal_height: u16, classic_shell: bool) -> u16 {
app.work_surface.effective_placement =
effective_placement(app.work_surface.placement, width, classic_shell);
let rows = project_visible(app);
if rows.is_empty() {
app.work_surface.focused = false;
app.work_surface.selected = None;
@@ -45,20 +45,16 @@ pub fn height(app: &mut App, width: u16, _terminal_height: u16, classic_shell: b
app.work_surface.visible_rows = 0;
app.work_surface.total_rows = 0;
app.work_surface.scroll_offset = 0;
app.work_surface.resizing = false;
return 0;
}
app.work_surface.effective_placement =
effective_placement(app.work_surface.placement, width, classic_shell);
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
return 0;
}
// Live top-area budget: at most two auxiliary content rows below the
// fixed route header (#4690), plus the panel-owned divider.
let content_cap = u16::try_from(super::model::LIVE_AUX_ROW_BUDGET).unwrap_or(2);
let content_height = u16::try_from(rows.len())
.unwrap_or(u16::MAX)
.min(content_cap);
content_height.saturating_add(1)
let terminal_cap = terminal_height
.saturating_div(2)
.clamp(super::model::TOP_HEIGHT_MIN, super::model::TOP_HEIGHT_MAX);
app.work_surface.top_height.min(terminal_cap)
}
/// Split the transcript slot for a side rail. Top placement consumes its own
@@ -71,11 +67,12 @@ pub fn split_chat(app: &mut App, area: Rect, classic_shell: bool) -> (Rect, Opti
return (area, None);
}
let proportional = area.width.saturating_mul(30) / 100;
let rail_width = proportional
.clamp(SIDE_RAIL_MIN_WIDTH, SIDE_RAIL_MAX_WIDTH)
let rail_width = app
.work_surface
.side_width
.clamp(super::model::SIDE_WIDTH_MIN, super::model::SIDE_WIDTH_MAX)
.min(area.width.saturating_sub(SIDE_RAIL_MIN_CHAT_WIDTH));
if rail_width < SIDE_RAIL_MIN_WIDTH {
if rail_width < super::model::SIDE_WIDTH_MIN {
app.work_surface.effective_placement = WorkSurfacePlacement::Top;
return (area, None);
}
@@ -137,22 +134,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
},
};
let mut rows = project(app);
if body_area.height <= 2 && rows.len() > usize::from(body_area.height) {
// Compact fallback spends its two content rows on the first actionable
// Task and To-do/worker objects instead of section chrome.
let mut compact = Vec::new();
for prefix in ["task:", "todo:", "worker:"] {
if let Some(row) = rows.iter().find(|row| row.id.0.starts_with(prefix)) {
compact.push(row.clone());
}
}
for row in rows.iter().filter(|row| row.selectable) {
if !compact.iter().any(|candidate| candidate.id == row.id) {
compact.push(row.clone());
}
}
rows = compact;
let mut rows = project_visible(app);
if placement == WorkSurfacePlacement::Top {
// The top bar is the literal list: to-dos first, then sub-agents.
// Section summaries belong to the optional side/detail surface and
// must not spend scarce transcript rows on generic chrome.
rows.retain(|row| row.selectable);
}
let body_height = usize::from(body_area.height);
let overflow = rows.len() > body_height;
@@ -197,12 +184,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
let hovered = app.work_surface.hovered.as_ref() == Some(&row.id);
let opened = app.work_surface.opened.as_ref() == Some(&row.id);
let style = row_style(app, row, selected, hovered, opened);
let compact_owner = if body_area.height <= 2 {
let compact_owner = if placement == WorkSurfacePlacement::Top {
row.id
.0
.split_once(':')
.map(|(kind, _)| match kind {
"graph" => "Work · ".to_string(),
"graph" => "To-do · ".to_string(),
_ => String::new(),
})
.unwrap_or_default()