Synced from monorepo
Synced from monorepo Changes: - Report invalid MCP server config instead of failing startup - Keep completed terminal output when the gateway connection is lost - Show a duration-only detail view for single-task task output - Don't let a stale registry turn counter hide local sessions - Raise the file-descriptor soft limit on Linux and log effective limits at startup - Stop aborting when HTTP client construction fails - Make session thread and runtime spawn failures recoverable - Fix main-prompt paste parity in the question freeform input - Fire SessionEnd hooks on /exit and headless quit - Embed the deployment-config signing public key - Repaint paste-chip background on inline panel inputs - Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root - Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker - Parse hooks from config files - Add a remote kill-switch for managed-config signature verification - Security: fix workspace file-reference resolution bypassing workspace filesystem confinement Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -571,7 +571,7 @@ pub fn render_peek_panel(
|
|||
live_tail: Option<PeekLiveTailArgs<'_>>,
|
||||
empty_hint: Option<&str>,
|
||||
) -> PeekRenderResult {
|
||||
use crate::views::prompt_widget::PromptStyle;
|
||||
use crate::views::prompt_widget::{PromptBg, PromptStyle};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
if area.area() == 0 || area.height < 3 || area.width < 20 {
|
||||
|
|
@ -724,7 +724,7 @@ pub fn render_peek_panel(
|
|||
show_prefix: false,
|
||||
vpad_top: 0,
|
||||
chrome: false,
|
||||
bg_override: Some(theme.bg_base),
|
||||
bg: PromptBg::Canvas(theme.bg_base),
|
||||
image_preview: false,
|
||||
..PromptStyle::default()
|
||||
};
|
||||
|
|
@ -863,7 +863,7 @@ pub fn render_peek_panel(
|
|||
show_prefix: false,
|
||||
vpad_top: 0,
|
||||
chrome: false,
|
||||
bg_override: Some(theme.bg_base),
|
||||
bg: PromptBg::Canvas(theme.bg_base),
|
||||
placeholder_override: Some("reply\u{2026}"),
|
||||
image_preview: false,
|
||||
..PromptStyle::default()
|
||||
|
|
|
|||
|
|
@ -2852,7 +2852,7 @@ fn render_dispatch(
|
|||
) -> Option<(u16, u16)> {
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
|
||||
use crate::views::prompt_widget::PromptStyle;
|
||||
use crate::views::prompt_widget::{PromptBg, PromptStyle};
|
||||
|
||||
if area.area() == 0 {
|
||||
return None;
|
||||
|
|
@ -3028,7 +3028,7 @@ fn render_dispatch(
|
|||
show_prefix: true,
|
||||
vpad_top: 0,
|
||||
chrome: false,
|
||||
bg_override: Some(theme.bg_base),
|
||||
bg: PromptBg::Canvas(theme.bg_base),
|
||||
image_preview: false,
|
||||
..PromptStyle::default()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -158,10 +158,8 @@ pub struct PromptStyle {
|
|||
/// Only used when `chrome` is true.
|
||||
pub chrome_pad_left: u16,
|
||||
pub chrome_pad_right: u16,
|
||||
/// Override the background color. When `Some`, the prompt uses this bg
|
||||
/// instead of computing one from focus state. Useful for rendering the
|
||||
/// prompt inline within another widget (e.g., question view).
|
||||
pub bg_override: Option<ratatui::style::Color>,
|
||||
/// Background surface for the prompt; see [`PromptBg`].
|
||||
pub bg: PromptBg,
|
||||
/// Override the accent line color. When `Some`, uses this color instead
|
||||
/// of the default `accent_user` / `gray_dim`. Used for plan mode (golden).
|
||||
pub accent_color_override: Option<ratatui::style::Color>,
|
||||
|
|
@ -194,6 +192,35 @@ pub struct PromptStyle {
|
|||
pub image_preview: bool,
|
||||
}
|
||||
|
||||
/// Background for the prompt widget.
|
||||
///
|
||||
/// Paste chips bake `theme.paste_bg` — a badge color tuned for the default
|
||||
/// canvas — into their display `Line` at paste time, so the background says
|
||||
/// what *kind* of surface the prompt sits on, not just its color.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PromptBg {
|
||||
/// The standalone prompt's default fill (`theme.bg_base`).
|
||||
#[default]
|
||||
Default,
|
||||
/// Explicit canvas color for prompts rendered inline within another
|
||||
/// widget whose surface matches the main prompt's (dashboard dispatch
|
||||
/// box, peek reply). Chips keep their badge background.
|
||||
Canvas(ratatui::style::Color),
|
||||
/// Inline panel color (question freeform input, permission follow-up).
|
||||
/// Chip cells are repainted to blend into the panel.
|
||||
Panel(ratatui::style::Color),
|
||||
}
|
||||
|
||||
impl PromptBg {
|
||||
/// Effective fill color; `default` is the standalone prompt's.
|
||||
fn color(self, default: ratatui::style::Color) -> ratatui::style::Color {
|
||||
match self {
|
||||
Self::Default => default,
|
||||
Self::Canvas(c) | Self::Panel(c) => c,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PromptStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -203,7 +230,7 @@ impl Default for PromptStyle {
|
|||
chrome: true,
|
||||
chrome_pad_left: 2,
|
||||
chrome_pad_right: 1,
|
||||
bg_override: None,
|
||||
bg: PromptBg::Default,
|
||||
accent_color_override: None,
|
||||
border_color_override: None,
|
||||
prefix_override: None,
|
||||
|
|
@ -237,7 +264,7 @@ impl PromptStyle {
|
|||
chrome: false,
|
||||
chrome_pad_left: 0,
|
||||
chrome_pad_right: 0,
|
||||
bg_override: Some(bg),
|
||||
bg: PromptBg::Panel(bg),
|
||||
accent_color_override: None,
|
||||
border_color_override: None,
|
||||
prefix_override: None,
|
||||
|
|
@ -986,6 +1013,17 @@ impl PromptWidget {
|
|||
self.update_file_search_context();
|
||||
}
|
||||
|
||||
/// [`Self::set_text`] unless the buffer already holds exactly `text`.
|
||||
///
|
||||
/// Skipping the no-op swap keeps chip elements, images, and undo history
|
||||
/// intact when a surface reloads an unchanged draft (the question view's
|
||||
/// freeform slots); any real content change takes the normal reset path.
|
||||
pub fn set_text_preserving(&mut self, text: &str) {
|
||||
if self.text() != text {
|
||||
self.set_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append plain text at the end without replacing existing chip elements.
|
||||
pub fn append_text(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
|
|
@ -2856,11 +2894,7 @@ impl PromptWidget {
|
|||
}
|
||||
|
||||
let theme = Theme::current();
|
||||
let bg = if let Some(override_bg) = style.bg_override {
|
||||
override_bg
|
||||
} else {
|
||||
theme.bg_base
|
||||
};
|
||||
let bg = style.bg.color(theme.bg_base);
|
||||
|
||||
let border_color = style.border_color_override.unwrap_or(if style.focused {
|
||||
theme.prompt_border_active
|
||||
|
|
@ -2987,6 +3021,21 @@ impl PromptWidget {
|
|||
|
||||
(&self.textarea).render_ref(ta_area, buf, &mut self.textarea_state);
|
||||
|
||||
// Chip bg remap (see `PromptBg::Panel`): chip `Line`s bake in
|
||||
// `paste_bg` at paste time and the same element can render on
|
||||
// multiple surfaces, so restyle at paint time.
|
||||
if matches!(style.bg, PromptBg::Panel(_)) && bg != theme.paste_bg {
|
||||
for y in ta_area.top()..ta_area.bottom() {
|
||||
for x in ta_area.left()..ta_area.right() {
|
||||
if let Some(cell) = buf.cell_mut((x, y))
|
||||
&& cell.bg == theme.paste_bg
|
||||
{
|
||||
cell.bg = bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slash overlays: teal command name + args ghost text. Both use the
|
||||
// same snapshot, so clone once. Capture flags for later ghost text
|
||||
// suppression to avoid a second clone.
|
||||
|
|
@ -3194,8 +3243,8 @@ impl PromptWidget {
|
|||
}
|
||||
|
||||
// Unfocused dimming: blend fg toward bg (bg already precomputed above).
|
||||
// Skip when bg_override is set — the prompt is inline in another widget.
|
||||
if !style.focused && style.bg_override.is_none() {
|
||||
// Skip when the bg is overridden — the prompt is inline in another widget.
|
||||
if !style.focused && style.bg == PromptBg::Default {
|
||||
// Dim only the content inside the box (skip all border chars).
|
||||
let dim_area = Rect {
|
||||
x: area.x + 1,
|
||||
|
|
|
|||
|
|
@ -4573,3 +4573,53 @@
|
|||
let buf = draw_bordered(11, &title_test_style(Some("my session")));
|
||||
assert_eq!(buf_text_at(&buf, 1, 10, 0), "\u{2500}".repeat(9));
|
||||
}
|
||||
|
||||
// ── PromptBg::Panel chip remap (inline surfaces) ────────────────
|
||||
|
||||
fn any_cell_with_bg(buf: &Buffer, bg: ratatui::style::Color) -> bool {
|
||||
let area = *buf.area();
|
||||
(area.top()..area.bottom())
|
||||
.any(|y| (area.left()..area.right()).any(|x| buf.cell((x, y)).is_some_and(|c| c.bg == bg)))
|
||||
}
|
||||
|
||||
/// Inline surfaces repaint the chip's baked-in `paste_bg` to the panel
|
||||
/// background; without the flag the chip keeps its own background. Uses
|
||||
/// a sentinel panel color so the test holds under terminal-default,
|
||||
/// where every palette entry quantizes to `Color::Reset`.
|
||||
#[test]
|
||||
fn panel_bg_repaints_paste_chip_to_panel_bg() {
|
||||
let theme = Theme::current();
|
||||
let panel = ratatui::style::Color::Rgb(12, 34, 56);
|
||||
assert_ne!(theme.paste_bg, panel, "fixture: sentinel must differ");
|
||||
|
||||
let mut pw = PromptWidget::new();
|
||||
pw.handle_paste("a\nb\nc\nd\ne"); // 5 lines >= chip threshold (4)
|
||||
let area = Rect::new(0, 0, 40, 2);
|
||||
|
||||
let inline = PromptStyle::inline(panel);
|
||||
assert!(
|
||||
matches!(inline.bg, PromptBg::Panel(_)),
|
||||
"inline surfaces are panels"
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
pw.draw(&mut buf, area, None, &inline, None, None);
|
||||
assert!(
|
||||
!any_cell_with_bg(&buf, theme.paste_bg),
|
||||
"chip cells must be repainted to the panel background"
|
||||
);
|
||||
assert!(
|
||||
any_cell_with_bg(&buf, panel),
|
||||
"the chip row renders on the panel background"
|
||||
);
|
||||
|
||||
let no_remap = PromptStyle {
|
||||
bg: PromptBg::Canvas(panel),
|
||||
..PromptStyle::inline(panel)
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
pw.draw(&mut buf, area, None, &no_remap, None, None);
|
||||
assert!(
|
||||
any_cell_with_bg(&buf, theme.paste_bg),
|
||||
"without the remap the chip keeps its own background"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ use super::state::{
|
|||
};
|
||||
use crate::render::line_utils::truncate_str;
|
||||
use crate::settings::{
|
||||
OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue, StringValidator,
|
||||
dynamic_enum_choices,
|
||||
CodingDataSharingLock, OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue,
|
||||
StringValidator, dynamic_enum_choices,
|
||||
};
|
||||
use crate::theme::Theme;
|
||||
use crate::views::modal_window::{
|
||||
|
|
@ -644,9 +644,9 @@ pub(super) fn render_rows(
|
|||
width: area.width,
|
||||
height: desc_height.min(8),
|
||||
};
|
||||
render_expanded_description(buf, desc_rect, meta, theme);
|
||||
render_expanded_description(buf, desc_rect, meta, None, theme);
|
||||
let consumed =
|
||||
wrapped_description_height(meta, area.width, desc_rect.height);
|
||||
wrapped_description_height(meta, None, area.width, desc_rect.height);
|
||||
y_cursor = y_cursor.saturating_add(consumed);
|
||||
}
|
||||
continue;
|
||||
|
|
@ -668,25 +668,10 @@ pub(super) fn render_rows(
|
|||
}
|
||||
};
|
||||
|
||||
let lock = state.row_lock(key);
|
||||
|
||||
// Decide 1 vs 2 line layout; fall back to 1 if viewport is tight.
|
||||
let value_display = match value {
|
||||
SettingValue::Bool(b) => {
|
||||
if *b {
|
||||
"on".to_string()
|
||||
} else {
|
||||
"off".to_string()
|
||||
}
|
||||
}
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)".to_string()
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
|
||||
SettingValue::Int(i) => i.to_string(),
|
||||
};
|
||||
let value_display = value_display(meta, value, lock);
|
||||
let show_restart_pill_for_layout = meta.restart_required && is_expanded;
|
||||
let layout_decision = row_layout(
|
||||
area.width,
|
||||
|
|
@ -722,6 +707,7 @@ pub(super) fn render_rows(
|
|||
theme,
|
||||
is_expanded,
|
||||
is_hovered,
|
||||
lock,
|
||||
);
|
||||
state.value_hit_rects[row_idx] = value_rect;
|
||||
y_cursor = y_cursor.saturating_add(row_height);
|
||||
|
|
@ -734,10 +720,12 @@ pub(super) fn render_rows(
|
|||
width: area.width,
|
||||
height: desc_height.min(8), // cap at 8 lines per row to keep scroll sane
|
||||
};
|
||||
render_expanded_description(buf, desc_rect, meta, theme);
|
||||
let lock_reason = lock.map(CodingDataSharingLock::reason);
|
||||
render_expanded_description(buf, desc_rect, meta, lock_reason, theme);
|
||||
// Re-measure how many lines the wrapped description
|
||||
// actually consumed, so y_cursor advances precisely.
|
||||
let consumed = wrapped_description_height(meta, area.width, desc_rect.height);
|
||||
let consumed =
|
||||
wrapped_description_height(meta, lock_reason, area.width, desc_rect.height);
|
||||
y_cursor = y_cursor.saturating_add(consumed);
|
||||
}
|
||||
}
|
||||
|
|
@ -824,7 +812,7 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
if matches!(meta.kind, SettingKind::Group { .. }) {
|
||||
let mut h: u16 = 1;
|
||||
if state.expanded_keys.contains(key) {
|
||||
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
|
||||
h = h.saturating_add(wrapped_description_height(meta, None, area_width, 8));
|
||||
}
|
||||
heights.push(h);
|
||||
continue;
|
||||
|
|
@ -834,24 +822,8 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
continue;
|
||||
};
|
||||
let is_expanded = state.expanded_keys.contains(key);
|
||||
let value_display = match &value {
|
||||
SettingValue::Bool(b) => {
|
||||
if *b {
|
||||
"on".to_string()
|
||||
} else {
|
||||
"off".to_string()
|
||||
}
|
||||
}
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)".to_string()
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
|
||||
SettingValue::Int(i) => i.to_string(),
|
||||
};
|
||||
let lock = state.row_lock(key);
|
||||
let value_display = value_display(meta, &value, lock);
|
||||
let show_restart_pill = meta.restart_required && is_expanded;
|
||||
let layout = row_layout(area_width, meta.label, &value_display, show_restart_pill);
|
||||
let mut h: u16 = match layout {
|
||||
|
|
@ -861,7 +833,12 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
if is_expanded {
|
||||
// Cap matches the forward render loop at line
|
||||
// 2040 (`desc_rect.height = ... .min(8)`).
|
||||
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
|
||||
h = h.saturating_add(wrapped_description_height(
|
||||
meta,
|
||||
lock.map(CodingDataSharingLock::reason),
|
||||
area_width,
|
||||
8,
|
||||
));
|
||||
}
|
||||
heights.push(h);
|
||||
}
|
||||
|
|
@ -871,13 +848,19 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
|
|||
}
|
||||
|
||||
/// Wrapped description height for scroll math (mirrors render path).
|
||||
fn wrapped_description_height(meta: &SettingMeta, area_width: u16, cap: u16) -> u16 {
|
||||
fn wrapped_description_height(
|
||||
meta: &SettingMeta,
|
||||
lock_reason: Option<&'static str>,
|
||||
area_width: u16,
|
||||
cap: u16,
|
||||
) -> u16 {
|
||||
let indent = 4u16.min(area_width);
|
||||
let wrap_w = area_width.saturating_sub(indent);
|
||||
if wrap_w == 0 {
|
||||
return 0;
|
||||
}
|
||||
let line = Line::from(Span::raw(meta.description));
|
||||
let text = lock_reason.unwrap_or(meta.description);
|
||||
let line = Line::from(Span::raw(text));
|
||||
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
|
||||
(wrapped.len() as u16).min(cap)
|
||||
}
|
||||
|
|
@ -2230,6 +2213,37 @@ const ROW_CHEVRON_W: u16 = 2;
|
|||
/// Chevron column width — reserved for all rows for alignment.
|
||||
pub(super) const ROW_CHEVRON_COL_W: u16 = ROW_CHEVRON_W;
|
||||
const ROW_RESTART_PILL_W: u16 = 10; // " · restart" — used for layout budgeting only.
|
||||
/// Appended to the value column of a locked row (see `SettingsModalState::row_lock`).
|
||||
pub(super) const ROW_ADMIN_MANAGED_SUFFIX: &str = " \u{00B7} Admin Managed";
|
||||
/// Value column for ZDR-locked rows — replaces the opt-in/out value entirely.
|
||||
pub(super) const ROW_ZDR_VALUE: &str = "ZDR";
|
||||
|
||||
/// Value-column text, shared by layout, scroll math, and paint.
|
||||
pub(super) fn value_display(
|
||||
meta: &SettingMeta,
|
||||
value: &SettingValue,
|
||||
lock: Option<CodingDataSharingLock>,
|
||||
) -> String {
|
||||
if lock == Some(CodingDataSharingLock::Zdr) {
|
||||
return ROW_ZDR_VALUE.to_string();
|
||||
}
|
||||
let mut display = match value {
|
||||
SettingValue::Bool(b) => if *b { "on" } else { "off" }.to_string(),
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)".to_string()
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
|
||||
SettingValue::Int(i) => i.to_string(),
|
||||
};
|
||||
if lock == Some(CodingDataSharingLock::TeamManaged) {
|
||||
display.push_str(ROW_ADMIN_MANAGED_SUFFIX);
|
||||
}
|
||||
display
|
||||
}
|
||||
|
||||
/// Per-row layout decision.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -2309,6 +2323,7 @@ pub(super) fn render_setting_row(
|
|||
theme: &Theme,
|
||||
is_expanded: bool,
|
||||
is_hovered: bool,
|
||||
lock: Option<CodingDataSharingLock>,
|
||||
) -> Rect {
|
||||
let bg = settings_list_row_bg(theme, is_selected, is_hovered);
|
||||
// Paint the row bg across the full area (1 or 2 lines).
|
||||
|
|
@ -2327,43 +2342,24 @@ pub(super) fn render_setting_row(
|
|||
.add_modifier(Modifier::ITALIC);
|
||||
let desc_style = Style::default().fg(theme.gray).bg(bg);
|
||||
|
||||
// Enum rows display the user-friendly name, not the canonical.
|
||||
let value_text_owned;
|
||||
let value_text: &str = match value {
|
||||
SettingValue::Bool(b) => {
|
||||
if *b {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
}
|
||||
SettingValue::String(s) => {
|
||||
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
|
||||
"(no override)"
|
||||
} else {
|
||||
s.as_str()
|
||||
}
|
||||
}
|
||||
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e),
|
||||
SettingValue::Int(i) => {
|
||||
value_text_owned = i.to_string();
|
||||
&value_text_owned
|
||||
}
|
||||
};
|
||||
let value_text = value_display(meta, value, lock);
|
||||
let value_text = value_text.as_str();
|
||||
|
||||
let value_style = if matches!(value, SettingValue::Bool(false)) {
|
||||
let value_style = if lock.is_some() || matches!(value, SettingValue::Bool(false)) {
|
||||
Style::default().fg(theme.gray).bg(bg)
|
||||
} else {
|
||||
value_style
|
||||
};
|
||||
|
||||
// Chevron for Enum/String/DynamicEnum (opens picker/editor).
|
||||
let show_chevron = matches!(
|
||||
(&meta.kind, value),
|
||||
(SettingKind::Enum { .. }, _)
|
||||
| (SettingKind::String { .. }, _)
|
||||
| (SettingKind::DynamicEnum { .. }, _)
|
||||
);
|
||||
// Locked rows can't be entered, so they drop the affordance.
|
||||
let show_chevron = lock.is_none()
|
||||
&& matches!(
|
||||
(&meta.kind, value),
|
||||
(SettingKind::Enum { .. }, _)
|
||||
| (SettingKind::String { .. }, _)
|
||||
| (SettingKind::DynamicEnum { .. }, _)
|
||||
);
|
||||
let chevron_str = format!(" {}", crate::glyphs::chevron()); // › → > on legacy ConHost
|
||||
let chevron_w = if show_chevron {
|
||||
chevron_str.width() as u16
|
||||
|
|
@ -2589,7 +2585,13 @@ pub(super) fn render_setting_row(
|
|||
}
|
||||
|
||||
/// Render the wrapped description for an expanded row.
|
||||
fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta, theme: &Theme) {
|
||||
fn render_expanded_description(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
meta: &SettingMeta,
|
||||
lock_reason: Option<&'static str>,
|
||||
theme: &Theme,
|
||||
) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -2597,14 +2599,14 @@ fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta,
|
|||
.fg(theme.gray)
|
||||
.bg(theme.bg_base)
|
||||
.add_modifier(Modifier::ITALIC);
|
||||
let desc_src: &str = meta.description;
|
||||
let desc_text = lock_reason.unwrap_or(meta.description);
|
||||
// Indent 4 cols to nest under the label.
|
||||
let indent = 4u16.min(area.width);
|
||||
let wrap_w = area.width.saturating_sub(indent);
|
||||
if wrap_w == 0 {
|
||||
return;
|
||||
}
|
||||
let line = Line::from(Span::styled(desc_src, desc_style));
|
||||
let line = Line::from(Span::styled(desc_text, desc_style));
|
||||
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
|
||||
for (i, wrapped_line) in wrapped.iter().enumerate() {
|
||||
if (i as u16) >= area.height {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ use ratatui::layout::Rect;
|
|||
use crate::app::actions::Action;
|
||||
use crate::input::line_editor::LineEditor;
|
||||
use crate::settings::{
|
||||
EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind,
|
||||
SettingMeta, SettingValue, SettingsRegistry, StringValidator, current_value_for,
|
||||
dynamic_enum_choices,
|
||||
CodingDataSharingLock, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory,
|
||||
SettingKey, SettingKind, SettingMeta, SettingValue, SettingsRegistry, StringValidator,
|
||||
current_value_for, dynamic_enum_choices,
|
||||
};
|
||||
use crate::views::modal_window::ModalWindowState;
|
||||
|
||||
|
|
@ -243,6 +243,16 @@ impl SettingsModalState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Why a Browse row cannot be edited (`None` = editable). Consulted by
|
||||
/// both render and input.
|
||||
pub fn row_lock(&self, key: SettingKey) -> Option<CodingDataSharingLock> {
|
||||
if key == "coding_data_sharing" {
|
||||
self.pager_snapshot.coding_data_sharing_lock
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently-focused setting row, if any.
|
||||
pub fn focused_setting(&self) -> Option<(SettingKey, &SettingMeta)> {
|
||||
match self.rows.get(self.selected)? {
|
||||
|
|
@ -551,6 +561,9 @@ impl SettingsModalState {
|
|||
let Some((key, meta)) = self.focused_setting() else {
|
||||
return false;
|
||||
};
|
||||
if self.row_lock(key).is_some() {
|
||||
return false;
|
||||
}
|
||||
// Handles both static `Enum` and `DynamicEnum` catalogs.
|
||||
let (supports_preview, resolved): (bool, Vec<OwnedEnumChoice>) = match &meta.kind {
|
||||
SettingKind::Enum {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use super::state::*;
|
|||
use crate::app::actions::Action;
|
||||
use crate::input::line_editor::LineEditor;
|
||||
use crate::settings::{
|
||||
EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind, SettingMeta,
|
||||
SettingOwner, SettingValue, SettingsRegistry, StringValidator,
|
||||
CodingDataSharingLock, EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey,
|
||||
SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry, StringValidator,
|
||||
};
|
||||
use crate::theme::Theme;
|
||||
use xai_grok_shell::agent::config::UiConfig;
|
||||
|
|
@ -542,6 +542,7 @@ fn render_setting_row_shows_full_label_when_one_line_fits() {
|
|||
&theme,
|
||||
false, // is_expanded
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -976,6 +977,7 @@ fn selected_browse_row_label_is_bold() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
|
|
@ -1433,6 +1435,7 @@ fn render_setting_row_emits_restart_pill_when_required() {
|
|||
&theme,
|
||||
true, // is_expanded — gate on
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -1457,6 +1460,7 @@ fn render_setting_row_emits_restart_pill_when_required() {
|
|||
&theme,
|
||||
false, // is_expanded — off
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -1505,6 +1509,7 @@ fn render_setting_row_hides_restart_pill_when_at_default_and_collapsed() {
|
|||
&theme,
|
||||
false, // is_expanded
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let mut rendered = String::new();
|
||||
for x in 0..area.width {
|
||||
|
|
@ -4488,6 +4493,7 @@ fn narrow_terminal_drops_value_to_second_line() {
|
|||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
|
|
@ -4551,6 +4557,7 @@ fn wide_terminal_keeps_value_on_first_line() {
|
|||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
|
|
@ -4592,6 +4599,7 @@ fn pathologically_narrow_truncates_label_with_ellipsis() {
|
|||
&theme,
|
||||
false,
|
||||
false, // is_hovered
|
||||
None,
|
||||
);
|
||||
let line1 = buf_row_text(&buf, 0, area.x, area.width);
|
||||
let line2 = buf_row_text(&buf, 1, area.x, area.width);
|
||||
|
|
@ -5340,6 +5348,7 @@ fn bool_off_value_renders_in_dim_color() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// Use `find_text_col` so the
|
||||
// column index is the actual buffer position, not a byte
|
||||
|
|
@ -5372,6 +5381,7 @@ fn bool_off_value_renders_in_dim_color() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let on_col = find_text_col(&buf_on, 0, "on").expect("must find `on` substring");
|
||||
let on_cell = buf_on.cell((on_col, 0)).expect("on cell");
|
||||
|
|
@ -5443,6 +5453,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// Enum row — chevron column contains the `›` glyph.
|
||||
|
|
@ -5457,6 +5468,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
// The chevron column is a 2-cell block at
|
||||
|
|
@ -5528,6 +5540,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let _ = render_setting_row(
|
||||
&mut buf_multi,
|
||||
|
|
@ -5539,6 +5552,7 @@ fn chevron_column_is_at_constant_right_offset() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// Bool row's `off` ends at column N; Enum row's `›` glyph
|
||||
// lands at column M. The contract: N == M's column
|
||||
|
|
@ -5595,6 +5609,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let area_one = Rect {
|
||||
x: 0,
|
||||
|
|
@ -5613,6 +5628,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() {
|
|||
&theme,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// The column offset from the area's right edge is constant:
|
||||
// `area.right - ROW_RIGHT_PAD_W - 1` is the `›` glyph
|
||||
|
|
@ -7470,3 +7486,180 @@ fn preview_remains_clamped_when_pending_exceeds_widened_width() {
|
|||
"clamped note must render when pending > interior, even after widening",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locked coding_data_sharing row (ZDR / team non-admin)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_locked_state(lock: CodingDataSharingLock) -> SettingsModalState {
|
||||
SettingsModalState::new(
|
||||
Arc::new(SettingsRegistry::defaults()),
|
||||
UiConfig::default(),
|
||||
PagerLocalSnapshot {
|
||||
coding_data_sharing_lock: Some(lock),
|
||||
..PagerLocalSnapshot::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn coding_data_sharing_row_idx(s: &SettingsModalState) -> usize {
|
||||
s.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
|
||||
.expect("coding_data_sharing must be registered")
|
||||
}
|
||||
|
||||
/// A locked `coding_data_sharing` row must NOT open the enum picker —
|
||||
/// neither via `try_enter_picking_enum` directly (the shared entry point
|
||||
/// for Enter, mouse value clicks, and the `focus_key` auto-open path) nor
|
||||
/// via the Browse Enter key. With no lock, the same row opens the picker.
|
||||
#[test]
|
||||
fn locked_coding_data_sharing_row_does_not_open_picker() {
|
||||
for lock in [
|
||||
CodingDataSharingLock::Zdr,
|
||||
CodingDataSharingLock::TeamManaged,
|
||||
] {
|
||||
let mut s = make_locked_state(lock);
|
||||
s.selected = coding_data_sharing_row_idx(&s);
|
||||
assert!(
|
||||
!s.try_enter_picking_enum(),
|
||||
"try_enter_picking_enum must return false for a locked row ({lock:?})"
|
||||
);
|
||||
assert!(
|
||||
matches!(s.mode(), SettingsModalMode::Browse),
|
||||
"mode must stay Browse for a locked row ({lock:?}), got {:?}",
|
||||
s.mode()
|
||||
);
|
||||
let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(
|
||||
matches!(out, SettingsKeyOutcome::Unchanged),
|
||||
"Enter on a locked row must be a no-op ({lock:?}), got {out:?}"
|
||||
);
|
||||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||||
}
|
||||
|
||||
// Control arm: no lock → the picker opens (existing behavior).
|
||||
let mut s = make_state();
|
||||
s.selected = coding_data_sharing_row_idx(&s);
|
||||
assert!(s.try_enter_picking_enum());
|
||||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||||
}
|
||||
|
||||
/// Locked rows drop the `›` enter-affordance and render a per-variant
|
||||
/// value: ZDR replaces opt-in/out with "ZDR"; team-managed keeps the
|
||||
/// value with an " · Admin Managed" suffix. Unlocked rows keep the plain
|
||||
/// value + chevron.
|
||||
#[test]
|
||||
fn locked_coding_data_sharing_row_renders_locked_value_without_chevron() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 60,
|
||||
};
|
||||
let theme = Theme::current();
|
||||
let chevron = crate::glyphs::chevron();
|
||||
|
||||
let mut s = make_locked_state(CodingDataSharingLock::Zdr);
|
||||
let idx = coding_data_sharing_row_idx(&s);
|
||||
s.selected = idx;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let rect = s.row_rects[idx];
|
||||
let line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
assert!(
|
||||
line.contains("ZDR") && !line.contains("Opt"),
|
||||
"ZDR lock must replace the opt-in/out value with `ZDR`: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
!line.contains(chevron),
|
||||
"locked row must not render the `{chevron}` enter affordance: {line:?}"
|
||||
);
|
||||
|
||||
let mut s = make_locked_state(CodingDataSharingLock::TeamManaged);
|
||||
s.selected = idx;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let rect = s.row_rects[idx];
|
||||
let line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
assert!(
|
||||
line.contains("Opt out \u{00B7} Admin Managed"),
|
||||
"team-managed lock must append ` · Admin Managed`: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
!line.contains(chevron),
|
||||
"locked row must not render the `{chevron}` enter affordance: {line:?}"
|
||||
);
|
||||
|
||||
// Control arm: unlocked row shows the plain value + chevron.
|
||||
let mut s = make_state();
|
||||
s.selected = idx;
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let rect = s.row_rects[idx];
|
||||
let line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
assert!(
|
||||
line.contains("Opt out") && !line.contains("locked"),
|
||||
"unlocked row must show the plain value: {line:?}"
|
||||
);
|
||||
assert!(
|
||||
line.contains(chevron),
|
||||
"unlocked row must keep the `{chevron}` enter affordance: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Expanding a locked row replaces the registry description with the lock
|
||||
/// reason; the unlocked expansion shows the description.
|
||||
#[test]
|
||||
fn locked_coding_data_sharing_expanded_description_replaces_with_reason() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 60,
|
||||
};
|
||||
let theme = Theme::current();
|
||||
// Word-wrap may split the reason across lines; normalize the whole
|
||||
// buffer to a single whitespace-collapsed string before matching.
|
||||
let flatten = |buf: &Buffer| -> String {
|
||||
(0..area.height)
|
||||
.map(|y| buf_row_text(buf, y, area.x, area.width))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
|
||||
let mut s = make_locked_state(CodingDataSharingLock::TeamManaged);
|
||||
let idx = coding_data_sharing_row_idx(&s);
|
||||
s.selected = idx;
|
||||
s.expanded_keys.insert("coding_data_sharing");
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let text = flatten(&buf);
|
||||
assert!(
|
||||
text.contains("Managed by your team admin."),
|
||||
"expanded locked row must show the lock reason: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Controls whether"),
|
||||
"locked expansion must replace the description, not append to it: {text:?}"
|
||||
);
|
||||
|
||||
// Control arm: unlocked expansion shows the description only.
|
||||
let mut s = make_state();
|
||||
s.selected = idx;
|
||||
s.expanded_keys.insert("coding_data_sharing");
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let text = flatten(&buf);
|
||||
assert!(
|
||||
text.contains("Controls whether"),
|
||||
"expanded row must render the registry description: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Managed by your team admin."),
|
||||
"unlocked expansion must not mention the team-admin lock: {text:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,8 +212,7 @@ pub struct TurnStatusArgs<'a> {
|
|||
pub is_pending_user_input: bool,
|
||||
pub goal_verifying: bool,
|
||||
pub watchers: Watchers,
|
||||
/// Parked on a sendable wait (`AgentView::renders_parked`): suppress the
|
||||
/// running-turn chrome and render only the still-running cue.
|
||||
/// Parked on a sendable wait (`AgentView::renders_parked`).
|
||||
pub parked: bool,
|
||||
/// Transparent right-side background so the row blends with the
|
||||
/// terminal's own background (minimal mode).
|
||||
|
|
@ -295,39 +294,55 @@ pub fn render_turn_status(
|
|||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
// Idle or parked with watchers: persistent still-running cue (not
|
||||
// scrollback — it must never scroll away). Lower priority than the
|
||||
// starting-session and drain-blocked cues above.
|
||||
if (state.is_idle() || parked)
|
||||
&& let Some(cue) = still_running_label(watchers)
|
||||
{
|
||||
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
|
||||
// the agent is idle, so this breath runs slower than the active
|
||||
// turn spinner (see MONITOR_PULSE_DIVISOR).
|
||||
let frames = crate::glyphs::monitor_icon_frames();
|
||||
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
|
||||
let icon = format!("{} ", frames[frame_idx]);
|
||||
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
|
||||
theme.text_primary
|
||||
// Idle or parked: persistent cue (not scrollback — it must never scroll
|
||||
// away). Lower priority than the starting-session and drain-blocked cues
|
||||
// above. Parked never falls through to the running-turn chrome
|
||||
// (spinner/timers/[stop]) — the wait aborts the moment the user types,
|
||||
// so that chrome would lie.
|
||||
if state.is_idle() || parked {
|
||||
// Parked with held queued rows: the queued hint IS the input-semantics
|
||||
// story (Enter acts on the queue immediately), so it replaces the
|
||||
// generic interrupt copy.
|
||||
let parked_suffix = if held_queue > 0 && held_queue_top_sendable {
|
||||
format!(" \u{00b7} {held_queue} queued — Enter to send now")
|
||||
} else if held_queue > 0 {
|
||||
format!(" \u{00b7} {held_queue} queued")
|
||||
} else {
|
||||
theme.gray
|
||||
" \u{00b7} send a message to interrupt".to_string()
|
||||
};
|
||||
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
|
||||
let spans = vec![
|
||||
Span::styled(icon, Style::default().fg(theme.accent_system)),
|
||||
Span::styled(cue, Style::default().fg(label_fg)),
|
||||
];
|
||||
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
|
||||
return TurnStatusOutput {
|
||||
watching_cue: show_buttons.then(|| Rect::new(area.x, area.y, cue_width, 1)),
|
||||
..TurnStatusOutput::default()
|
||||
let cue = match (still_running_label(watchers), parked) {
|
||||
(Some(label), true) => Some(format!("{label}{parked_suffix}")),
|
||||
(Some(label), false) => Some(label),
|
||||
(None, true) => Some(format!("waiting{parked_suffix}")),
|
||||
(None, false) => None,
|
||||
};
|
||||
}
|
||||
|
||||
// Parked with no watchers left: render nothing. The stopped look must
|
||||
// never fall through to the running-turn chrome (spinner/timers/[stop])
|
||||
// — the wait aborts the moment the user types, so that chrome would lie.
|
||||
if parked {
|
||||
if let Some(cue) = cue {
|
||||
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
|
||||
// the agent is idle, so this breath runs slower than the active
|
||||
// turn spinner (see MONITOR_PULSE_DIVISOR).
|
||||
let frames = crate::glyphs::monitor_icon_frames();
|
||||
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
|
||||
let icon = format!("{} ", frames[frame_idx]);
|
||||
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
|
||||
theme.text_primary
|
||||
} else {
|
||||
theme.gray
|
||||
};
|
||||
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
|
||||
let spans = vec![
|
||||
Span::styled(icon, Style::default().fg(theme.accent_system)),
|
||||
Span::styled(cue, Style::default().fg(label_fg)),
|
||||
];
|
||||
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
|
||||
// The cue opens the tasks pane on click — only advertise the hit
|
||||
// area when there are tasks to show (a watcherless parked cue has
|
||||
// nothing behind it).
|
||||
return TurnStatusOutput {
|
||||
watching_cue: (show_buttons && watchers.total() > 0)
|
||||
.then(|| Rect::new(area.x, area.y, cue_width, 1)),
|
||||
..TurnStatusOutput::default()
|
||||
};
|
||||
}
|
||||
return TurnStatusOutput::default();
|
||||
}
|
||||
|
||||
|
|
@ -787,9 +802,7 @@ fn render_starting_session(
|
|||
/// completion/events, scheduled `/loop` tasks fire prompts, and background
|
||||
/// subagents inject a completion turn, any of which can start a new turn.
|
||||
///
|
||||
/// A parked turn (`parked` — the stopped look while blocked on a sendable
|
||||
/// wait) suppresses the running-turn chrome entirely: the row shows only when
|
||||
/// watchers exist, rendering the "… still running" cue.
|
||||
/// A parked turn always shows the row, watchers or not.
|
||||
///
|
||||
/// Real MCP progress (`total > 0`) renders as a compact chip in the top status
|
||||
/// bar instead, so it does not affect this row.
|
||||
|
|
@ -801,7 +814,7 @@ pub fn should_show(
|
|||
parked: bool,
|
||||
) -> bool {
|
||||
if parked {
|
||||
return watchers.total() > 0;
|
||||
return true;
|
||||
}
|
||||
!state.is_idle()
|
||||
|| drain_blocked
|
||||
|
|
@ -1068,9 +1081,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn should_show_parked_only_with_watchers() {
|
||||
// Parked (turn running but rendering the stopped look): the row shows
|
||||
// only to carry the "… still running" cue — never the running chrome.
|
||||
fn should_show_parked_always() {
|
||||
assert!(should_show(
|
||||
&AgentState::TurnRunning,
|
||||
false,
|
||||
|
|
@ -1081,7 +1092,7 @@ mod tests {
|
|||
},
|
||||
true
|
||||
));
|
||||
assert!(!should_show(
|
||||
assert!(should_show(
|
||||
&AgentState::TurnRunning,
|
||||
false,
|
||||
None,
|
||||
|
|
@ -1435,16 +1446,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parked_with_watchers_renders_cue_not_running_chrome() {
|
||||
// A parked running turn renders the still-running cue — never the busy
|
||||
// spinner/timers/[stop] chrome (the wait aborts as soon as the user
|
||||
// types, so that chrome would lie).
|
||||
// The wait aborts as soon as the user types, so busy chrome would lie.
|
||||
let text = render_parked_with_watchers(Watchers {
|
||||
commands: 2,
|
||||
..Watchers::default()
|
||||
});
|
||||
assert!(
|
||||
text.contains("2 commands still running"),
|
||||
"parked with bg work must render the still-running cue, got: {text:?}"
|
||||
text.contains("2 commands still running \u{00b7} send a message to interrupt"),
|
||||
"parked with bg work must render the interruptible still-running cue, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Waiting") && !text.contains("[stop]"),
|
||||
|
|
@ -1453,11 +1462,39 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parked_without_watchers_renders_nothing() {
|
||||
fn parked_without_watchers_renders_waiting_cue() {
|
||||
let text = render_parked_with_watchers(Watchers::default());
|
||||
assert!(
|
||||
text.trim().is_empty(),
|
||||
"parked with no watchers must render nothing, got: {text:?}"
|
||||
text.contains("waiting \u{00b7} send a message to interrupt"),
|
||||
"watcherless parked must render the waiting interrupt cue, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("[stop]"),
|
||||
"watcherless parked must not render the running-turn chrome, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parked_with_held_queue_renders_queued_hint() {
|
||||
// The queued hint replaces the interrupt copy (Enter = send-now).
|
||||
let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete));
|
||||
let mut args = idle_args(Watchers {
|
||||
commands: 1,
|
||||
..Watchers::default()
|
||||
});
|
||||
args.state = &AgentState::TurnRunning;
|
||||
args.activity = &activity;
|
||||
args.parked = true;
|
||||
args.held_queue = 1;
|
||||
args.held_queue_top_sendable = true;
|
||||
let text = render_row_text(args, 80);
|
||||
assert!(
|
||||
text.contains("1 queued — Enter to send now"),
|
||||
"parked with a held row must advertise the queued hint, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("send a message to interrupt"),
|
||||
"queued hint replaces the interrupt copy, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue