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
23 changes: 20 additions & 3 deletions shai-cli/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ use super::input::UserAction;
use crate::tui::perm::PermissionWidget;
use crate::tui::perm_alt_screen::AlternateScreenPermissionModal;
use super::perm::PermissionModalAction;
use super::theme::Theme;


pub enum AppModalState<'a> {
Expand Down Expand Up @@ -72,6 +73,8 @@ pub struct App<'a> {

pub(crate) total_input_tokens: u32,
pub(crate) total_output_tokens: u32,

pub(crate) theme: Theme, // UI theme (dark/light)
}


Expand Down Expand Up @@ -168,20 +171,24 @@ impl App<'_> {
// UI-related Internals
impl App<'_> {
pub fn new() -> Self {
let theme = Theme::from_env(); // Read from SHAI_TUI_THEME env var
let palette = theme.palette();

Self {
terminal: None,
terminal_height: 5,
agent: None,
custom_agent: None,
formatter: PrettyFormatter::new(),
state: AppModalState::InputShown,
input: InputArea::new(),
input: InputArea::new(palette),
commands: Self::list_command(),
exit: false,
running_tools: HashMap::new(),
permission_queue: VecDeque::new(),
total_input_tokens: 0,
total_output_tokens: 0,
theme,
}
}

Expand Down Expand Up @@ -277,6 +284,14 @@ impl App<'_> {
return Ok(());
}

// Handle theme toggle with Ctrl+T
if matches!(key_event.code, KeyCode::Char('t')) && key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
self.theme.toggle();
let new_palette = self.theme.palette();
self.input.set_palette(new_palette);
return Ok(());
}

match &mut self.state {
AppModalState::InputShown => {
let action = self.input.handle_event(key_event).await;
Expand Down Expand Up @@ -322,10 +337,12 @@ impl App<'_> {
match &self.state {
AppModalState::InputShown if !self.permission_queue.is_empty() => {
let (request_id, request) = self.permission_queue.front().unwrap();
let palette = self.theme.palette();
let widget = PermissionWidget::new(
request_id.clone(),
request.clone(),
self.permission_queue.len()
self.permission_queue.len(),
palette
);

let terminal_height = self.terminal.as_ref()
Expand All @@ -335,7 +352,7 @@ impl App<'_> {

if widget.height() > terminal_height.saturating_sub(5) {
// Use alternate screen for large modals
if let Ok(mut modal) = AlternateScreenPermissionModal::new(&widget) {
if let Ok(mut modal) = AlternateScreenPermissionModal::new(&widget, palette) {
let action = modal.run().await.unwrap_or(PermissionModalAction::Nope);
self.handle_permission_action(action).await?;
}
Expand Down
31 changes: 31 additions & 0 deletions shai-cli/src/tui/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::{collections::HashMap, io, time::Duration};
use shai_llm::ToolCallMethod;

use crate::tui::App;
use super::theme::Theme;

impl App<'_> {
pub(crate) fn list_command() -> HashMap<(String, String),Vec<String>> {
Expand All @@ -10,6 +11,7 @@ impl App<'_> {
(("/auth","select a provider"), vec![]),
(("/tc","set the tool call method: [fc | fc2 | so]"), vec!["method"]),
(("/tokens","display token usage (input/output)"), vec![]),
(("/theme","set theme: [dark | light | toggle]"), vec!["mode"]),
])
.into_iter()
.map(|((cmd,desc),args)|((cmd.to_string(),desc.to_string()),args.into_iter().map(|s|s.to_string()).collect()))
Expand Down Expand Up @@ -65,6 +67,35 @@ impl App<'_> {
);
self.input.alert_msg(&msg, Duration::from_secs(5));
}
"/theme" => {
match args.into_iter().next() {
Some("dark") => {
self.theme = Theme::Dark;
let new_palette = self.theme.palette();
self.input.set_palette(new_palette);
self.input.alert_msg("Theme set to dark", Duration::from_secs(2));
}
Some("light") => {
self.theme = Theme::Light;
let new_palette = self.theme.palette();
self.input.set_palette(new_palette);
self.input.alert_msg("Theme set to light", Duration::from_secs(2));
}
Some("toggle") => {
self.theme.toggle();
let new_palette = self.theme.palette();
self.input.set_palette(new_palette);
let theme_name = match self.theme {
Theme::Dark => "dark",
Theme::Light => "light",
};
self.input.alert_msg(&format!("Theme toggled to {}", theme_name), Duration::from_secs(2));
}
_ => {
self.input.alert_msg("Usage: /theme [dark|light|toggle]", Duration::from_secs(3));
}
}
}
_ => {
self.input.alert_msg("command unknown", Duration::from_secs(1));
}
Expand Down
43 changes: 22 additions & 21 deletions shai-cli/src/tui/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use tui_textarea::{Input, TextArea};

use crate::{tui::{cmdnav::CommandNav, helper::HelpArea}};

use super::theme::SHAI_YELLOW;
use super::theme::{SHAI_YELLOW, ThemePalette};

pub enum UserAction {
Nope,
Expand Down Expand Up @@ -72,10 +72,13 @@ pub struct InputArea<'a> {

// gitignore patterns (loaded once)
gitignore_patterns: Vec<String>,

// theme colors
palette: ThemePalette,
}

impl Default for InputArea<'_> {
fn default() -> Self {
impl InputArea<'_> {
pub fn new(palette: ThemePalette) -> Self {
Self {
agent_running: false,
input: TextArea::default(),
Expand All @@ -98,20 +101,19 @@ impl Default for InputArea<'_> {
suggestion_index: None,
suggestion_search: None,
gitignore_patterns: Self::load_gitignore_patterns(),
palette,
}
}
}

impl InputArea<'_> {
pub fn new() -> Self {
Self::default()
}

pub fn set_history(&mut self, history: Vec<String>) {
self.history = history;
self.history_index = self.history.len();
}

pub fn set_palette(&mut self, palette: ThemePalette) {
self.palette = palette;
}

// Parse .gitignore and return list of patterns to ignore
fn load_gitignore_patterns() -> Vec<String> {
if let Ok(content) = fs::read_to_string(".gitignore") {
Expand Down Expand Up @@ -609,15 +611,14 @@ impl InputArea<'_> {
]).areas(area);

// status
f.render_widget(Span::styled(self.get_status_text(), Style::default().fg(Color::Yellow)), status);
f.render_widget(Span::styled(self.get_status_text(), Style::default().fg(self.palette.status)), status);

// Input - clone and apply block styling
let block = Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.padding(Padding { left: 1, right: 1, top: 0, bottom: 0 })
.border_style(Style::default().fg(Color::DarkGray));
//.border_style(Style::default().bold().fg(Color::Rgb(SHAI_YELLOW.0, SHAI_YELLOW.1, SHAI_YELLOW.2)));
.border_style(Style::default().fg(self.palette.border));
let inner = block.inner(input_area);
f.render_widget(block, input_area);

Expand All @@ -626,11 +627,11 @@ impl InputArea<'_> {

// Set placeholder and block
self.input.set_placeholder_text("? for help");
self.input.set_placeholder_style(Style::default().fg(Color::DarkGray));
self.input.set_style(Style::default().fg(Color::White));
self.input.set_placeholder_style(Style::default().fg(self.palette.placeholder));
self.input.set_style(Style::default().fg(self.palette.input_text));
self.input.set_cursor_style(Style::default()
.fg(Color::White)
.bg(if !self.input.lines()[0].is_empty() { Color::White } else { Color::Reset }));
.fg(self.palette.cursor_fg)
.bg(if !self.input.lines()[0].is_empty() { self.palette.cursor_bg } else { Color::Reset }));
self.input.set_cursor_line_style(Style::default());
f.render_widget(&self.input, prompt);

Expand All @@ -643,13 +644,13 @@ impl InputArea<'_> {

let helper_text = self.check_helper_msg();
f.render_widget(
Span::styled(helper_text, Style::default().fg(Color::DarkGray).dim()),
Span::styled(helper_text, Style::default().fg(self.palette.method_label).dim()),
helper_left
);

// Status
f.render_widget(
Span::styled(self.method_str(), Style::default().fg(Color::DarkGray)),
Span::styled(self.method_str(), Style::default().fg(self.palette.method_label)),
helper_right
);

Expand All @@ -676,9 +677,9 @@ impl InputArea<'_> {
.map(|(window_idx, path)| {
let actual_idx = start + window_idx;
let style = if Some(actual_idx) == self.suggestion_index {
Style::default().fg(Color::Yellow).bg(Color::DarkGray)
Style::default().fg(self.palette.suggestion_selected_fg).bg(self.palette.suggestion_selected_bg)
} else {
Style::default().fg(Color::White)
Style::default().fg(self.palette.suggestion_normal)
};
ListItem::new(path.as_str()).style(style)
})
Expand All @@ -694,7 +695,7 @@ impl InputArea<'_> {
.block(Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(Color::DarkGray))
.border_style(Style::default().fg(self.palette.border))
.title(title));

f.render_widget(suggestions_list, suggestions_area);
Expand Down
32 changes: 17 additions & 15 deletions shai-cli/src/tui/perm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use ratatui::{
use shai_core::{agent::{events::PermissionRequest, output::PrettyFormatter, PermissionResponse}, tools::{ToolCall, ToolResult}};
// Removed tui_textarea dependency for colored preview

use super::theme::SHAI_YELLOW;
use super::theme::{SHAI_YELLOW, ThemePalette};

pub enum PermissionModalAction {
Nope,
Expand All @@ -33,11 +33,12 @@ pub struct PermissionWidget<'a> {
formatted_request: String,
preview_text: Text<'a>,
scroll_offset: usize,
scroll_state: ScrollbarState
scroll_state: ScrollbarState,
palette: ThemePalette,
}

impl PermissionWidget<'_> {
pub fn new(request_id: String, request: PermissionRequest, total: usize) -> Self {
pub fn new(request_id: String, request: PermissionRequest, total: usize, palette: ThemePalette) -> Self {
let formatter = PrettyFormatter::new();
let formatted_request = formatter.format_toolcall(&request.call, request.preview.as_ref());
let preview_text = formatted_request.into_text().unwrap();
Expand All @@ -51,7 +52,8 @@ impl PermissionWidget<'_> {
formatted_request,
preview_text,
scroll_offset: 0,
scroll_state: ScrollbarState::new(content_length)
scroll_state: ScrollbarState::new(content_length),
palette,
}
}

Expand Down Expand Up @@ -150,7 +152,7 @@ impl PermissionWidget<'_> {
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.padding(Padding{left: 1, right: 1, top: 1, bottom: 1})
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(self.palette.status))
.title(if self.remaining_perms > 1 {
format!(" 🔐 Permission Required ({}/{}) ", 1, self.remaining_perms)
} else {
Expand All @@ -166,20 +168,20 @@ impl PermissionWidget<'_> {
let tool_name = PrettyFormatter::capitalize_first(&call.tool_name);
let context = PrettyFormatter::extract_primary_param(&call.parameters, &call.tool_name);
let mut title = Line::from(vec![
Span::styled("🔧 ", Color::White),
Span::styled(tool_name, Style::new().white().bold())
Span::styled("🔧 ", self.palette.input_text),
Span::styled(tool_name, Style::default().fg(self.palette.input_text).bold())
]);
if let Some((_,ctx)) = context {
title.push_span(Span::styled(format!("({})", ctx), Style::new().white()));
title.push_span(Span::styled(format!("({})", ctx), Style::default().fg(self.palette.input_text)));
};

let block = Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.padding(Padding{left: 1, right: 1, top: 0, bottom: 0})
.title(title)
.title_style(Style::default().fg(Color::White))
.border_style(Style::default().fg(Color::DarkGray));
.title_style(Style::default().fg(self.palette.input_text))
.border_style(Style::default().fg(self.palette.border));

let inner = block.inner(tool);
f.render_widget(block, tool);
Expand All @@ -192,7 +194,7 @@ impl PermissionWidget<'_> {
// Render scrollbar if content is longer than area
if self.preview_text.lines.len() > inner.height as usize {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.style(Style::default().fg(Color::DarkGray));
.style(Style::default().fg(self.palette.border));
f.render_stateful_widget(scrollbar, inner, &mut self.scroll_state.clone());
}

Expand All @@ -201,13 +203,13 @@ impl PermissionWidget<'_> {
for (i,s) in items.into_iter().enumerate() {
if i == self.selected_index {
lines.push(Line::from(vec![
Span::styled("❯ ", Color::White),
Span::styled(s, Color::White)
Span::styled("❯ ", self.palette.suggestion_selected_fg),
Span::styled(s, self.palette.suggestion_selected_fg)
]));
} else {
lines.push(Line::from(vec![
Span::styled(" ", Color::DarkGray),
Span::styled(s, Color::DarkGray)
Span::styled(" ", self.palette.placeholder),
Span::styled(s, self.palette.placeholder)
]));
};
}
Expand Down
7 changes: 5 additions & 2 deletions shai-cli/src/tui/perm_alt_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,21 @@ use ratatui::{
use shai_core::agent::events::PermissionRequest;

use super::perm::{PermissionWidget, PermissionModalAction};
use super::theme::ThemePalette;

pub struct AlternateScreenPermissionModal<'a> {
widget: PermissionWidget<'a>,
}

impl AlternateScreenPermissionModal<'_> {
pub fn new(widget: &PermissionWidget) -> io::Result<Self> {
pub fn new(widget: &PermissionWidget, palette: ThemePalette) -> io::Result<Self> {
Ok(Self {
widget: PermissionWidget::new(
widget.request_id.clone(),
widget.request.clone(),
widget.remaining_perms)
widget.remaining_perms,
palette
)
})
}

Expand Down
Loading