Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/hi-agent/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ pub enum Command {
/// accept input from remote clients via ipop. Arg: empty (use current
/// session) or a session id to resume.
Daemon(String),
/// Switch the TUI color theme (TUI only). Arg: `dark`, `light`, `ansi`,
/// `auto` (follow OS), or empty to cycle to the next.
Theme(String),
Quit,
/// A `/word` that isn't recognized.
Unknown(String),
Expand Down Expand Up @@ -154,6 +157,7 @@ pub fn parse(line: &str) -> Option<Command> {
"dashboard" | "fleet" => Command::Dashboard(arg),
"loop" | "loops" => Command::Loop(arg),
"watch" => Command::Watch,
"theme" | "themes" => Command::Theme(arg),
"digest" | "activity" => Command::Digest,
// Compatibility aliases remain accepted, but the public command
// surface is consolidated under `/sessions`.
Expand Down Expand Up @@ -1043,6 +1047,17 @@ pub const COMMANDS: &[CommandSpec] = &[
help: "what your loops noticed, grouped — with what's new since you last looked",
arg_values: &[],
},
CommandSpec {
name: "theme",
args: "[dark|light|ansi|auto]",
help: "switch the TUI color theme (empty cycles; auto follows OS light/dark)",
arg_values: &[
("dark", "designed dark palette (truecolor)"),
("light", "designed light palette (truecolor)"),
("ansi", "terminal-native 16-color palette"),
("auto", "follow the OS light/dark appearance"),
],
},
CommandSpec {
name: "sessions",
args: "[switch|rename|favorite|archive|restore|delete|attach|host|sync]",
Expand Down Expand Up @@ -1555,6 +1570,8 @@ mod tests {
// Command parse.
assert_eq!(parse("/loop"), Some(Command::Loop(String::new())));
assert_eq!(parse("/watch"), Some(Command::Watch));
assert_eq!(parse("/theme dark"), Some(Command::Theme("dark".into())));
assert_eq!(parse("/theme"), Some(Command::Theme(String::new())));
assert_eq!(parse("/digest"), Some(Command::Digest));
assert_eq!(parse("/activity"), Some(Command::Digest));
assert_eq!(
Expand Down
7 changes: 4 additions & 3 deletions crates/hi-agent/src/tests/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,9 +484,10 @@ async fn repeated_plan_repost_gets_synthetic_result_and_plan_nudge() {
"synthetic result explains the skip: {skipped_result}"
);
assert!(
!agent.messages().iter().any(|m| m
.text()
.contains("Provider-invisible assistant content")),
!agent
.messages()
.iter()
.any(|m| m.text().contains("Provider-invisible assistant content")),
"skipped calls must not degrade to the provider-invisible placeholder"
);
assert!(
Expand Down
5 changes: 5 additions & 0 deletions crates/hi-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@ pub(crate) fn handle_command(agent: &mut Agent, command: hi_agent::Command) -> b
"\x1b[33m/watch is only available in the full-screen TUI (run hi without --plain)\x1b[0m"
);
}
Command::Theme(_) => {
println!(
"\x1b[33m/theme is only available in the full-screen TUI (run hi without --plain)\x1b[0m"
);
}
Command::Digest => {
println!(
"\x1b[33m/digest is only available in the full-screen TUI (run hi without --plain)\x1b[0m"
Expand Down
5 changes: 2 additions & 3 deletions crates/hi-tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,8 @@ pub use tools::{
commit_in, delegate_tool_spec, execute_in_runtime, execute_prepared_in_runtime,
execute_streaming_in_runtime, explore_tool_spec, fast_check_for, is_coordination,
is_filesystem_mutating, is_known_tool, is_read_only, prepare_mutation_in_with_state,
prepare_verify_workdir,
run_check_in, run_fast_check_in, target_path, tool_metadata, working_tree_diff_in,
working_tree_diff_plain_in,
prepare_verify_workdir, run_check_in, run_fast_check_in, target_path, tool_metadata,
working_tree_diff_in, working_tree_diff_plain_in,
};
#[cfg(test)]
pub(crate) use tools::{execute, execute_in, preview_edit_in};
Expand Down
31 changes: 31 additions & 0 deletions crates/hi-tui/src/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,36 @@ impl crate::App {
}
}

/// Handle `/theme`: set a named mode (`dark`/`light`/`ansi`/`auto`), or
/// cycle to the next when the arg is empty. Applies immediately (the whole
/// TUI re-reads the theme each frame) and echoes the new mode.
fn handle_theme(&mut self, arg: &str) {
let arg = arg.trim();
let mode = if arg.is_empty() {
crate::theme::cycle_mode()
} else if let Some(mode) = crate::theme::ThemeMode::parse(arg) {
crate::theme::set_mode(mode);
mode
} else {
self.push(Line::styled(
format!("unknown theme '{arg}' — try dark, light, ansi, or auto"),
Style::default().fg(crate::theme::theme().warning),
));
self.follow();
return;
};
let note = if mode == crate::theme::ThemeMode::Auto {
format!("theme: {} (following OS light/dark)", mode.label())
} else {
format!("theme: {}", mode.label())
};
self.push(Line::styled(
note,
Style::default().fg(crate::theme::theme().accent_success),
));
self.follow();
}

/// Echo the current goal state: the structured checklist summary (prominent),
/// or the transient set/clear/read feedback.
fn report_goal_result(&mut self, agent: &Agent, arg: &str, error: Option<String>) {
Expand Down Expand Up @@ -505,6 +535,7 @@ impl crate::App {
Command::Watch => {}
// Handled inline by the run loop (needs the loops manager handle).
Command::Digest => {}
Command::Theme(arg) => self.handle_theme(&arg),
Command::Help => {
for line in command::help_text().lines() {
self.push(Line::styled(line.to_string(), dim()));
Expand Down
17 changes: 17 additions & 0 deletions crates/hi-tui/src/app/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,23 @@ impl crate::App {
}
let inner_w = rows[0].width.saturating_sub(2);
let inner_h = rows[0].height.saturating_sub(2);
// Sunken panels: a `Line` background only paints behind its text, so pad
// any panel-tagged tool-output line (base bg == theme.panel) to the full
// inner width with a trailing space carrying the panel bg. This turns
// the per-glyph background into a full-width block.
let panel_bg = th.panel;
if th.paints_backgrounds() {
for line in &mut lines {
if line.style.bg == Some(panel_bg) {
let used: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
if (used as u16) < inner_w {
let pad = (inner_w as usize) - used;
line.spans
.push(Span::styled(" ".repeat(pad), Style::default().bg(panel_bg)));
}
}
}
}
let total = wrapped_height(&lines, inner_w);
let max_scroll = total.saturating_sub(inner_h);
// Cache the geometry so scroll events (which fire outside render) can clamp
Expand Down
9 changes: 8 additions & 1 deletion crates/hi-tui/src/app/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ pub async fn run(
}
app.push(Line::styled(
format!(
"Enter to send · Alt-Enter for a newline · Ctrl-C interrupts/double exits · Ctrl-T shows reasoning · Ctrl-O expands tool output · Ctrl-D toggles diff · /help for all commands{ctx}.",
"Enter to send · Alt-Enter for a newline · Ctrl-C interrupts/double exits · Ctrl-T shows reasoning · Ctrl-O expands tool output · Ctrl-D toggles diff · /theme to restyle · /help for all commands{ctx}.",
),
dim(),
));
Expand Down Expand Up @@ -318,6 +318,13 @@ pub async fn run(
// Loop firings land while you're idle too.
app.spinner = app.spinner.wrapping_add(1);
app.drain_loops();
// Follow OS light/dark when theme = auto.
// ~5s cadence (40 × 120ms tick); a no-op for
// fixed modes, so it only queries the OS on
// auto. The next redraw picks up any change.
if app.spinner.is_multiple_of(40) {
crate::theme::poll_auto_appearance();
}
continue 'input;
}
}
Expand Down
17 changes: 15 additions & 2 deletions crates/hi-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,26 @@ impl TranscriptEntry {
}
}
TranscriptEntry::ToolOutput { body } => {
// The visible body lines sit in a sunken panel (a `panel` base
// background) on truecolor themes, tagging them so the render
// pass can pad them to full width. The fold footer stays plain
// so the fold boundary reads as the panel's edge.
let panel = th.panel;
let tag = |line: &Line<'static>| -> Line<'static> {
let mut l = line.clone();
if th.paints_backgrounds() {
l.style = l.style.bg(panel);
}
l
};
// Short output, or the global expand toggle, shows in full;
// otherwise a preview + a fold footer naming what's hidden.
if show_tool_output || body.len() <= TOOL_OUTPUT_PREVIEW_LINES {
body.clone()
body.iter().map(tag).collect()
} else {
let hidden = body.len() - TOOL_OUTPUT_PREVIEW_LINES;
let mut lines: Vec<Line<'static>> = body[..TOOL_OUTPUT_PREVIEW_LINES].to_vec();
let mut lines: Vec<Line<'static>> =
body[..TOOL_OUTPUT_PREVIEW_LINES].iter().map(tag).collect();
lines.push(Line::from(vec![
Span::styled("┃ ", Style::default().fg(th.gray_dim)),
Span::styled(
Expand Down
24 changes: 24 additions & 0 deletions crates/hi-tui/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2511,3 +2511,27 @@ fn long_tool_output_folds_to_preview_and_expands_on_ctrl_o() {
.join("\n");
assert!(full.contains("line 39"), "copy/export keeps the full body");
}

#[test]
fn tool_output_body_carries_panel_background_when_theme_paints() {
// Mutation-free: flatten reads the active theme; assert the body-line bg is
// consistent with whatever palette is active (panel on truecolor, none on
// ansi). This never touches the global mode, so it can't race other tests.
let th = crate::theme::theme();
let body: Vec<Line<'static>> = vec![Line::raw("a line of output")];
let entry = TranscriptEntry::ToolOutput { body };
let flat = entry.flatten(false, true);
let bg = flat[0].style.bg;
if th.paints_backgrounds() {
assert_eq!(
bg,
Some(th.panel),
"truecolor themes sink the body into a panel"
);
} else {
assert_eq!(
bg, None,
"ansi theme leaves the body background at terminal default"
);
}
}
Loading
Loading