fix(tui): focus live chrome on todos and agents
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}'"),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user