mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:23:33 +00:00
feat: improve TUI interface with status bar and refined styling
- Add fixed status bar at top showing model and directory info - Make photon-light the default theme - Implement bottom-aligned history rendering (chat-style) - Add theme switching via /theme command - Refine padding: 3 chars for history/status, 2 for input/status bar - Use consistent lighter border color for all UI elements - Remove "Message" title from input box - Add padding around "Coding..." status indicator - Keep composer visible during task execution - Reduce bottom padding from 3 to 2 lines - Fix cursor positioning and text visibility issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
+186
@@ -0,0 +1,186 @@
|
||||
# Codex TUI Theme Configuration
|
||||
|
||||
The Codex TUI now supports customizable color themes! You can use predefined themes or create your own custom color scheme.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add theme configuration to your `~/.codex/config.toml` file:
|
||||
|
||||
### Using Predefined Themes
|
||||
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "carbon-night" # Default theme
|
||||
```
|
||||
|
||||
### Available Predefined Themes
|
||||
|
||||
- **carbon-night** (default): Sleek modern dark theme with blue accents - perfect for long coding sessions
|
||||
- **photon-light**: Clean professional light theme with confident blue accent - great for bright environments
|
||||
- **shinobi-dusk**: Japanese-inspired twilight theme with soothing purple and green tones
|
||||
- **oled-black-pro**: True black background for OLED displays with vibrant neon accents
|
||||
- **amber-terminal**: Retro amber CRT monitor aesthetic - nostalgic monochrome orange
|
||||
- **aurora-flux**: Northern lights inspired with cool blues and greens
|
||||
- **charcoal-rainbow**: High-contrast accessible theme with rainbow accents on dark gray
|
||||
- **zen-garden**: Calm, peaceful theme with mint and lavender tones
|
||||
- **paper-light-pro**: Premium paper-like light theme for comfortable daytime use
|
||||
|
||||
### Custom Color Configuration
|
||||
|
||||
You can override individual colors while using a base theme:
|
||||
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "carbon-night" # Base theme
|
||||
|
||||
[tui.theme.colors]
|
||||
primary = "#25c2ff" # Override the primary color
|
||||
secondary = "#a9e69e" # Green
|
||||
background = "#000000" # Black
|
||||
foreground = "#ffffff" # White
|
||||
border = "#404040" # Dark gray
|
||||
border_focused = "#00ffff" # Cyan
|
||||
text = "#ffffff" # White
|
||||
text_dim = "#808080" # Gray
|
||||
text_bright = "#ffffff" # Bright white
|
||||
success = "#00ff00" # Green
|
||||
warning = "#ffff00" # Yellow
|
||||
error = "#ff0000" # Red
|
||||
info = "#00ffff" # Cyan
|
||||
spinner = "#404040" # Dark gray
|
||||
progress = "#00ffff" # Cyan
|
||||
```
|
||||
|
||||
### Complete Custom Theme
|
||||
|
||||
For a fully custom theme:
|
||||
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "custom"
|
||||
|
||||
[tui.theme.colors]
|
||||
# Primary colors
|
||||
primary = "#ff79c6" # Pink
|
||||
secondary = "#50fa7b" # Green
|
||||
background = "#282a36" # Dark purple
|
||||
foreground = "#f8f8f2" # Off-white
|
||||
|
||||
# UI elements
|
||||
border = "#44475a" # Purple-gray
|
||||
border_focused = "#bd93f9" # Purple
|
||||
selection = "#44475a" # Purple-gray
|
||||
cursor = "#f8f8f2" # Off-white
|
||||
|
||||
# Status colors
|
||||
success = "#50fa7b" # Green
|
||||
warning = "#f1fa8c" # Yellow
|
||||
error = "#ff5555" # Red
|
||||
info = "#8be9fd" # Cyan
|
||||
|
||||
# Text colors
|
||||
text = "#f8f8f2" # Off-white
|
||||
text_dim = "#6272a4" # Comment blue
|
||||
text_bright = "#ffffff" # Pure white
|
||||
|
||||
# Special colors
|
||||
keyword = "#ff79c6" # Pink
|
||||
string = "#f1fa8c" # Yellow
|
||||
comment = "#6272a4" # Comment blue
|
||||
function = "#50fa7b" # Green
|
||||
|
||||
# Animation colors
|
||||
spinner = "#6272a4" # Comment blue
|
||||
progress = "#bd93f9" # Purple
|
||||
```
|
||||
|
||||
## Color Format
|
||||
|
||||
Colors can be specified in the following formats:
|
||||
|
||||
### Hex Colors
|
||||
- `"#rrggbb"` - Standard 6-digit hex color (e.g., `"#ff79c6"`)
|
||||
|
||||
### Named Colors
|
||||
- `"black"`, `"red"`, `"green"`, `"yellow"`, `"blue"`, `"magenta"`, `"cyan"`, `"white"`
|
||||
- `"gray"` or `"grey"`, `"darkgray"` or `"darkgrey"`
|
||||
- `"lightred"`, `"lightgreen"`, `"lightyellow"`, `"lightblue"`, `"lightmagenta"`, `"lightcyan"`
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Shinobi Dusk Theme
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "shinobi-dusk"
|
||||
```
|
||||
|
||||
### Example 2: Photon Light with Custom Accent
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "photon-light"
|
||||
|
||||
[tui.theme.colors]
|
||||
primary = "#007acc" # VS Code blue
|
||||
border_focused = "#007acc"
|
||||
info = "#007acc"
|
||||
```
|
||||
|
||||
### Example 3: Amber Terminal for Retro Feel
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "amber-terminal"
|
||||
|
||||
[tui.theme.colors]
|
||||
primary = "#ffa500" # Make it more orange
|
||||
success = "#ffff00" # Bright yellow
|
||||
error = "#ff6600" # Orange-red
|
||||
```
|
||||
|
||||
### Example 4: Custom Theme - Solarized-inspired
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "custom"
|
||||
|
||||
[tui.theme.colors]
|
||||
background = "#002b36" # Base03
|
||||
foreground = "#839496" # Base0
|
||||
primary = "#268bd2" # Blue
|
||||
secondary = "#859900" # Green
|
||||
border = "#073642" # Base02
|
||||
border_focused = "#268bd2" # Blue
|
||||
text = "#839496" # Base0
|
||||
text_dim = "#586e75" # Base01
|
||||
text_bright = "#93a1a1" # Base1
|
||||
success = "#859900" # Green
|
||||
warning = "#b58900" # Yellow
|
||||
error = "#dc322f" # Red
|
||||
info = "#2aa198" # Cyan
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. Start with a predefined theme that's close to what you want, then customize individual colors
|
||||
2. Use a color picker tool to find exact hex values
|
||||
3. Test your theme in different lighting conditions
|
||||
4. Consider accessibility - ensure sufficient contrast between text and background colors
|
||||
5. The theme takes effect immediately when you save the config file and restart Codex
|
||||
|
||||
## Color Meanings
|
||||
|
||||
- **primary**: Main accent color, used for important UI elements
|
||||
- **secondary**: Secondary accent color
|
||||
- **background**: Main background color
|
||||
- **foreground**: Default text color
|
||||
- **border**: Normal border color
|
||||
- **border_focused**: Border color when element has focus
|
||||
- **selection**: Background color for selected text
|
||||
- **cursor**: Cursor color
|
||||
- **success**: Success messages and indicators
|
||||
- **warning**: Warning messages
|
||||
- **error**: Error messages
|
||||
- **info**: Informational messages
|
||||
- **text**: Normal text
|
||||
- **text_dim**: Dimmed/secondary text
|
||||
- **text_bright**: Bright/emphasized text
|
||||
- **spinner**: Loading spinner color
|
||||
- **progress**: Progress bar color
|
||||
@@ -76,7 +76,86 @@ pub enum HistoryPersistence {
|
||||
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct Tui {}
|
||||
pub struct Tui {
|
||||
/// Theme configuration for the TUI
|
||||
#[serde(default)]
|
||||
pub theme: ThemeConfig,
|
||||
}
|
||||
|
||||
/// Theme configuration for the TUI
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ThemeConfig {
|
||||
/// Name of the predefined theme to use
|
||||
#[serde(default)]
|
||||
pub name: ThemeName,
|
||||
|
||||
/// Custom color overrides (optional)
|
||||
#[serde(default)]
|
||||
pub colors: ThemeColors,
|
||||
}
|
||||
|
||||
impl Default for ThemeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: ThemeName::default(),
|
||||
colors: ThemeColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Available predefined themes
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ThemeName {
|
||||
CarbonNight,
|
||||
#[default]
|
||||
PhotonLight,
|
||||
ShinobiDusk,
|
||||
OledBlackPro,
|
||||
AmberTerminal,
|
||||
AuroraFlux,
|
||||
CharcoalRainbow,
|
||||
ZenGarden,
|
||||
PaperLightPro,
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Theme colors that can be customized
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct ThemeColors {
|
||||
// Primary colors
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
pub background: Option<String>,
|
||||
pub foreground: Option<String>,
|
||||
|
||||
// UI elements
|
||||
pub border: Option<String>,
|
||||
pub border_focused: Option<String>,
|
||||
pub selection: Option<String>,
|
||||
pub cursor: Option<String>,
|
||||
|
||||
// Status colors
|
||||
pub success: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub info: Option<String>,
|
||||
|
||||
// Text colors
|
||||
pub text: Option<String>,
|
||||
pub text_dim: Option<String>,
|
||||
pub text_bright: Option<String>,
|
||||
|
||||
// Syntax/special colors
|
||||
pub keyword: Option<String>,
|
||||
pub string: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
pub function: Option<String>,
|
||||
|
||||
// Animation colors
|
||||
pub spinner: Option<String>,
|
||||
pub progress: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
|
||||
+62
-54
@@ -11,7 +11,6 @@ use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
@@ -19,8 +18,8 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::terminal::supports_keyboard_enhancement;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -211,8 +210,9 @@ impl App<'_> {
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
match event {
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
self.pending_history_lines.extend(lines);
|
||||
AppEvent::InsertHistory(_lines) => {
|
||||
// No longer using scrollback, history is managed in ChatWidget
|
||||
// Just trigger a redraw
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
AppEvent::RequestRedraw => {
|
||||
@@ -376,6 +376,13 @@ impl App<'_> {
|
||||
widget.handle_reasoning_command(command_text);
|
||||
}
|
||||
}
|
||||
SlashCommand::Theme => {
|
||||
// Theme selection is handled in submit_user_message
|
||||
// This case is here for completeness
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.show_theme_selection();
|
||||
}
|
||||
}
|
||||
SlashCommand::Prompts => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_prompts_output();
|
||||
@@ -386,6 +393,7 @@ impl App<'_> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::FileChange;
|
||||
|
||||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||||
@@ -426,6 +434,28 @@ impl App<'_> {
|
||||
widget.set_reasoning_effort(new_effort);
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateTheme(new_theme) => {
|
||||
// Switch the theme immediately
|
||||
crate::theme::switch_theme(new_theme);
|
||||
|
||||
// Clear terminal with new theme colors
|
||||
let theme_bg = crate::colors::background();
|
||||
let theme_fg = crate::colors::text();
|
||||
let _ = crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
crossterm::style::SetColors(crossterm::style::Colors::new(theme_fg.into(), theme_bg.into())),
|
||||
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
|
||||
crossterm::cursor::MoveTo(0, 0)
|
||||
);
|
||||
|
||||
// Update config and save to file
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.set_theme(new_theme);
|
||||
}
|
||||
|
||||
// Request a redraw to apply the new theme
|
||||
self.schedule_redraw();
|
||||
}
|
||||
AppEvent::OnboardingAuthComplete(result) => {
|
||||
if let AppState::Onboarding { screen } = &mut self.app_state {
|
||||
screen.on_auth_complete(result);
|
||||
@@ -472,60 +502,38 @@ impl App<'_> {
|
||||
}
|
||||
|
||||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
let screen_size = terminal.size()?;
|
||||
let last_known_screen_size = terminal.last_known_screen_size;
|
||||
if screen_size != last_known_screen_size {
|
||||
let cursor_pos = terminal.get_cursor_position()?;
|
||||
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
||||
if cursor_pos.y != last_known_cursor_pos.y {
|
||||
// The terminal was resized. The only point of reference we have for where our viewport
|
||||
// was moved is the cursor position.
|
||||
// NB this assumes that the cursor was not wrapped as part of the resize.
|
||||
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
||||
|
||||
let new_viewport_area = terminal.viewport_area.offset(Offset {
|
||||
x: 0,
|
||||
y: cursor_delta,
|
||||
});
|
||||
terminal.set_viewport_area(new_viewport_area);
|
||||
terminal.clear()?;
|
||||
}
|
||||
}
|
||||
|
||||
// In alternate screen mode, we use the full terminal
|
||||
let size = terminal.size()?;
|
||||
let desired_height = match &self.app_state {
|
||||
AppState::Chat { widget } => widget.desired_height(size.width),
|
||||
AppState::Onboarding { .. } => size.height,
|
||||
};
|
||||
|
||||
let mut area = terminal.viewport_area;
|
||||
area.height = desired_height.min(size.height);
|
||||
area.width = size.width;
|
||||
if area.bottom() > size.height {
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
|
||||
area.y = size.height - area.height;
|
||||
|
||||
// Update viewport to full terminal size if it changed
|
||||
if size != terminal.last_known_screen_size {
|
||||
let full_area = Rect::new(0, 0, size.width, size.height);
|
||||
terminal.set_viewport_area(full_area);
|
||||
terminal.resize(size)?;
|
||||
}
|
||||
if area != terminal.viewport_area {
|
||||
terminal.clear()?;
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
if !self.pending_history_lines.is_empty() {
|
||||
crate::insert_history::insert_history_lines(
|
||||
terminal,
|
||||
self.pending_history_lines.clone(),
|
||||
);
|
||||
self.pending_history_lines.clear();
|
||||
}
|
||||
terminal.draw(|frame| match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
|
||||
// Draw to the full terminal
|
||||
terminal.draw(|frame| {
|
||||
// Fill entire frame with theme background
|
||||
let bg_style = ratatui::style::Style::default()
|
||||
.bg(crate::colors::background())
|
||||
.fg(crate::colors::text());
|
||||
let area = frame.area();
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
frame.buffer_mut()[(x, y)].set_style(bg_style);
|
||||
}
|
||||
frame.render_widget_ref(&**widget, frame.area())
|
||||
}
|
||||
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
|
||||
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
frame.render_widget_ref(&**widget, frame.area())
|
||||
}
|
||||
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use codex_core::config_types::ReasoningEffort;
|
||||
use codex_core::config_types::{ReasoningEffort, ThemeName};
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -38,6 +38,9 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Update the reasoning effort level
|
||||
UpdateReasoningEffort(ReasoningEffort),
|
||||
|
||||
/// Update the theme
|
||||
UpdateTheme(ThemeName),
|
||||
|
||||
/// Kick off an asynchronous file search for the given query (text after
|
||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||
|
||||
@@ -95,27 +95,53 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
self.textarea.desired_height(width - 1)
|
||||
+ match &self.active_popup {
|
||||
ActivePopup::None => 1u16,
|
||||
ActivePopup::Command(c) => c.calculate_required_height(),
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
}
|
||||
// Calculate hint/popup height
|
||||
let hint_height = match &self.active_popup {
|
||||
ActivePopup::None => 1u16,
|
||||
ActivePopup::Command(c) => c.calculate_required_height(),
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
};
|
||||
|
||||
// Calculate actual content height of textarea
|
||||
// Account for: 2 border + 2 horizontal padding (1 left + 1 right)
|
||||
let content_width = width.saturating_sub(4); // 2 border + 2 horizontal padding
|
||||
let content_lines = self.textarea.desired_height(content_width).max(1); // At least 1 line
|
||||
|
||||
// Total input height: content + border (2) only, no vertical padding
|
||||
// Minimum of 3 ensures at least 1 visible line with border
|
||||
let input_height = (content_lines + 2).max(3).min(15);
|
||||
|
||||
input_height + hint_height
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let popup_height = match &self.active_popup {
|
||||
ActivePopup::Command(popup) => popup.calculate_required_height(),
|
||||
ActivePopup::File(popup) => popup.calculate_required_height(),
|
||||
ActivePopup::None => 1,
|
||||
// Split area: textarea with border at top, hints/popup at bottom
|
||||
let hint_height = if matches!(self.active_popup, ActivePopup::None) { 1 } else {
|
||||
match &self.active_popup {
|
||||
ActivePopup::Command(popup) => popup.calculate_required_height(),
|
||||
ActivePopup::File(popup) => popup.calculate_required_height(),
|
||||
ActivePopup::None => 1,
|
||||
}
|
||||
};
|
||||
let [textarea_rect, _] =
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
|
||||
let mut textarea_rect = textarea_rect;
|
||||
textarea_rect.width = textarea_rect.width.saturating_sub(1);
|
||||
textarea_rect.x += 1;
|
||||
// Calculate dynamic height based on content
|
||||
let content_width = area.width.saturating_sub(4); // Account for border and padding
|
||||
let content_lines = self.textarea.desired_height(content_width).max(1);
|
||||
let desired_input_height = (content_lines + 2).max(3).min(15); // Dynamic with min/max
|
||||
|
||||
// Use desired height but don't exceed available space
|
||||
let input_height = desired_input_height.min(area.height.saturating_sub(hint_height));
|
||||
let [input_area, _] =
|
||||
Layout::vertical([Constraint::Length(input_height), Constraint::Length(hint_height)]).areas(area);
|
||||
|
||||
// Get inner area of the bordered input box
|
||||
let input_block = Block::default().borders(Borders::ALL);
|
||||
let textarea_rect = input_block.inner(input_area);
|
||||
|
||||
// Apply same padding as in render (1 char horizontal only, no vertical padding)
|
||||
let padded_textarea_rect = textarea_rect.inner(Margin::new(1, 0));
|
||||
|
||||
let state = self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, &state)
|
||||
self.textarea.cursor_pos_with_state(padded_textarea_rect, &state)
|
||||
}
|
||||
|
||||
/// Returns true if the composer currently contains no user input.
|
||||
@@ -661,18 +687,28 @@ impl WidgetRef for &ChatComposer {
|
||||
ActivePopup::File(popup) => popup.calculate_required_height(),
|
||||
ActivePopup::None => 1,
|
||||
};
|
||||
let [textarea_rect, popup_rect] =
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area);
|
||||
// Split area: textarea with border at top, hints/popup at bottom
|
||||
let hint_height = if matches!(self.active_popup, ActivePopup::None) { 1 } else { popup_height };
|
||||
|
||||
// Calculate dynamic height based on content
|
||||
let content_width = area.width.saturating_sub(4); // Account for border and padding
|
||||
let content_lines = self.textarea.desired_height(content_width).max(1);
|
||||
let desired_input_height = (content_lines + 2).max(3).min(15); // Dynamic with min/max
|
||||
|
||||
// Use desired height but don't exceed available space
|
||||
let input_height = desired_input_height.min(area.height.saturating_sub(hint_height));
|
||||
let [input_area, hint_area] =
|
||||
Layout::vertical([Constraint::Length(input_height), Constraint::Length(hint_height)]).areas(area);
|
||||
match &self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
popup.render_ref(popup_rect, buf);
|
||||
popup.render_ref(hint_area, buf);
|
||||
}
|
||||
ActivePopup::File(popup) => {
|
||||
popup.render_ref(popup_rect, buf);
|
||||
popup.render_ref(hint_area, buf);
|
||||
}
|
||||
ActivePopup::None => {
|
||||
let bottom_line_rect = popup_rect;
|
||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||
let bottom_line_rect = hint_area;
|
||||
let key_hint_style = Style::default().fg(crate::colors::light_blue());
|
||||
let mut hint = if self.ctrl_c_quit_hint {
|
||||
vec![
|
||||
Span::from(" "),
|
||||
@@ -727,29 +763,23 @@ impl WidgetRef for &ChatComposer {
|
||||
.render_ref(bottom_line_rect, buf);
|
||||
}
|
||||
}
|
||||
Block::default()
|
||||
.border_style(Style::default().dim())
|
||||
.borders(Borders::LEFT)
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().fg(if self.has_focus {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::Gray
|
||||
}))
|
||||
.render_ref(
|
||||
Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height),
|
||||
buf,
|
||||
);
|
||||
let mut textarea_rect = textarea_rect;
|
||||
textarea_rect.width = textarea_rect.width.saturating_sub(1);
|
||||
textarea_rect.x += 1;
|
||||
// Draw border around input area (no title) - use lighter border color
|
||||
let input_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(crate::colors::border()));
|
||||
|
||||
let textarea_rect = input_block.inner(input_area);
|
||||
input_block.render_ref(input_area, buf);
|
||||
|
||||
// Add padding inside the text area (1 char horizontal only, no vertical padding)
|
||||
let padded_textarea_rect = textarea_rect.inner(Margin::new(1, 0));
|
||||
|
||||
let mut state = self.textarea_state.borrow_mut();
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), padded_textarea_rect, buf, &mut state);
|
||||
if self.textarea.text().is_empty() {
|
||||
Line::from(BASE_PLACEHOLDER_TEXT)
|
||||
.style(Style::default().dim())
|
||||
.render_ref(textarea_rect.inner(Margin::new(1, 0)), buf);
|
||||
.render_ref(padded_textarea_rect, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ mod scroll_state;
|
||||
mod selection_popup_common;
|
||||
mod status_indicator_view;
|
||||
mod textarea;
|
||||
mod theme_selection_view;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CancellationEvent {
|
||||
@@ -38,8 +39,9 @@ pub(crate) use chat_composer::InputResult;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use approval_modal_view::ApprovalModalView;
|
||||
use reasoning_selection_view::ReasoningSelectionView;
|
||||
use theme_selection_view::ThemeSelectionView;
|
||||
use status_indicator_view::StatusIndicatorView;
|
||||
use codex_core::config_types::ReasoningEffort;
|
||||
use codex_core::config_types::{ReasoningEffort, ThemeName};
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane<'a> {
|
||||
@@ -127,14 +129,27 @@ impl BottomPane<'_> {
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
// Hide the cursor whenever an overlay view is active (e.g. the
|
||||
// status indicator shown while a task is running, or approval modal).
|
||||
// In these states the textarea is not interactable, so we should not
|
||||
// show its caret.
|
||||
// Hide the cursor whenever an overlay view is active (e.g. approval modal).
|
||||
// But keep cursor visible when only status overlay is shown.
|
||||
if self.active_view.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.composer.cursor_pos(area)
|
||||
// Calculate y_offset for status overlay if present
|
||||
let mut y_offset = 0u16;
|
||||
if let Some(status) = &self.live_status {
|
||||
y_offset = status.desired_height(area.width).min(area.height);
|
||||
}
|
||||
|
||||
// Adjust composer area to account for status overlay and padding
|
||||
let horizontal_padding = 2u16; // Message input uses 2 chars padding
|
||||
let composer_rect = Rect {
|
||||
x: area.x + horizontal_padding,
|
||||
y: area.y + y_offset,
|
||||
width: area.width.saturating_sub(horizontal_padding * 2),
|
||||
height: (area.height.saturating_sub(y_offset))
|
||||
- BottomPane::BOTTOM_PAD_LINES.min((area.height.saturating_sub(y_offset)).saturating_sub(1)),
|
||||
};
|
||||
self.composer.cursor_pos(composer_rect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,12 +159,8 @@ impl BottomPane<'_> {
|
||||
view.handle_key_event(self, key_event);
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
v.update_text("waiting for model".to_string());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
}
|
||||
// Don't create a status view - keep composer visible
|
||||
self.request_redraw();
|
||||
InputResult::None
|
||||
} else {
|
||||
@@ -174,13 +185,8 @@ impl BottomPane<'_> {
|
||||
CancellationEvent::Handled => {
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
// Modal aborted but task still running – restore status indicator.
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
v.update_text("waiting for model".to_string());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
}
|
||||
// Don't create a status view - keep composer visible
|
||||
self.show_ctrl_c_quit_hint();
|
||||
}
|
||||
CancellationEvent::Ignored => {
|
||||
@@ -199,10 +205,10 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status indicator text. Prefer replacing the composer with
|
||||
/// the StatusIndicatorView so the input pane shows a single-line status
|
||||
/// like: `▌ Working waiting for model`.
|
||||
/// Update the status indicator text. Shows status as overlay above composer
|
||||
/// to allow continued input while processing.
|
||||
pub(crate) fn update_status_text(&mut self, text: String) {
|
||||
// If there's an active modal view that can handle status updates, let it
|
||||
let mut handled_by_view = false;
|
||||
if let Some(view) = self.active_view.as_mut() {
|
||||
if matches!(
|
||||
@@ -211,18 +217,9 @@ impl BottomPane<'_> {
|
||||
) {
|
||||
handled_by_view = true;
|
||||
}
|
||||
} else {
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
v.update_text(text.clone());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
handled_by_view = true;
|
||||
}
|
||||
|
||||
// Fallback: if the current active view did not consume status updates
|
||||
// and no modal view is active, present an overlay above the composer.
|
||||
// If a modal is active, do NOT render the overlay to avoid drawing
|
||||
// over the dialog.
|
||||
// If not handled by a modal, show as overlay above composer
|
||||
if !handled_by_view && self.active_view.is_none() {
|
||||
if self.live_status.is_none() {
|
||||
self.live_status = Some(StatusIndicatorWidget::new(self.app_event_tx.clone()));
|
||||
@@ -261,11 +258,12 @@ impl BottomPane<'_> {
|
||||
self.is_task_running = running;
|
||||
|
||||
if running {
|
||||
if self.active_view.is_none() {
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
)));
|
||||
self.status_view_active = true;
|
||||
// Show status as overlay above composer instead of replacing it
|
||||
if self.live_status.is_none() {
|
||||
self.live_status = Some(StatusIndicatorWidget::new(self.app_event_tx.clone()));
|
||||
}
|
||||
if let Some(status) = &mut self.live_status {
|
||||
status.update_text("waiting for model".to_string());
|
||||
}
|
||||
self.request_redraw();
|
||||
} else {
|
||||
@@ -333,6 +331,15 @@ impl BottomPane<'_> {
|
||||
self.status_view_active = false;
|
||||
self.request_redraw()
|
||||
}
|
||||
|
||||
/// Show the theme selection UI
|
||||
pub fn show_theme_selection(&mut self, current_theme: ThemeName) {
|
||||
let view = ThemeSelectionView::new(current_theme, self.app_event_tx.clone());
|
||||
self.active_view = Some(Box::new(view));
|
||||
self.live_status = None;
|
||||
self.status_view_active = false;
|
||||
self.request_redraw()
|
||||
}
|
||||
|
||||
/// Height (terminal rows) required by the current bottom pane.
|
||||
pub(crate) fn request_redraw(&self) {
|
||||
@@ -402,14 +409,17 @@ impl WidgetRef for &BottomPane<'_> {
|
||||
y_offset = y_offset.saturating_add(1);
|
||||
}
|
||||
if let Some(status) = &self.live_status {
|
||||
// Add horizontal padding (3 chars on each side) to match composer
|
||||
let horizontal_padding = 3u16;
|
||||
let padded_width = area.width.saturating_sub(horizontal_padding * 2);
|
||||
let live_h = status
|
||||
.desired_height(area.width)
|
||||
.desired_height(padded_width)
|
||||
.min(area.height.saturating_sub(y_offset));
|
||||
if live_h > 0 {
|
||||
let live_rect = Rect {
|
||||
x: area.x,
|
||||
x: area.x + horizontal_padding,
|
||||
y: area.y + y_offset,
|
||||
width: area.width,
|
||||
width: padded_width,
|
||||
height: live_h,
|
||||
};
|
||||
status.render_ref(live_rect, buf);
|
||||
@@ -422,19 +432,23 @@ impl WidgetRef for &BottomPane<'_> {
|
||||
// Reserve bottom padding lines; keep at least 1 line for the view.
|
||||
let avail = area.height - y_offset;
|
||||
let pad = BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1));
|
||||
// Add horizontal padding (2 chars on each side) for views
|
||||
let horizontal_padding = 2u16;
|
||||
let view_rect = Rect {
|
||||
x: area.x,
|
||||
x: area.x + horizontal_padding,
|
||||
y: area.y + y_offset,
|
||||
width: area.width,
|
||||
width: area.width.saturating_sub(horizontal_padding * 2),
|
||||
height: avail - pad,
|
||||
};
|
||||
view.render(view_rect, buf);
|
||||
}
|
||||
} else if y_offset < area.height {
|
||||
// Add horizontal padding (2 chars on each side) for Message input
|
||||
let horizontal_padding = 2u16;
|
||||
let composer_rect = Rect {
|
||||
x: area.x,
|
||||
x: area.x + horizontal_padding,
|
||||
y: area.y + y_offset,
|
||||
width: area.width,
|
||||
width: area.width.saturating_sub(horizontal_padding * 2),
|
||||
// Reserve bottom padding
|
||||
height: (area.height - y_offset)
|
||||
- BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)),
|
||||
@@ -564,15 +578,15 @@ mod tests {
|
||||
}
|
||||
assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}");
|
||||
|
||||
// Bottom row is the status line; it should contain the left bar and "Working".
|
||||
// Bottom row is the status line; it should contain the left bar and "Coding".
|
||||
let mut r3 = String::new();
|
||||
for x in 0..area.width {
|
||||
r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌');
|
||||
assert!(
|
||||
r3.contains("Working"),
|
||||
"expected Working header in status line: {r3:?}"
|
||||
r3.contains("Coding"),
|
||||
"expected Coding header in status line: {r3:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -591,7 +605,7 @@ mod tests {
|
||||
// Attempt to update status; this should NOT create an overlay while modal is visible.
|
||||
pane.update_status_text("running command".to_string());
|
||||
|
||||
// Render and verify the top row does not include the Working header overlay.
|
||||
// Render and verify the top row does not include the Coding header overlay.
|
||||
let area = Rect::new(0, 0, 60, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
@@ -601,8 +615,8 @@ mod tests {
|
||||
r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
!r0.contains("Working"),
|
||||
"overlay Working header should not render above modal"
|
||||
!r0.contains("Coding"),
|
||||
"overlay Coding header should not render above modal"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -637,7 +651,7 @@ mod tests {
|
||||
);
|
||||
assert!(pane.active_view.is_some(), "active view should be present");
|
||||
|
||||
// Render and ensure the top row includes the Working header instead of the composer.
|
||||
// Render and ensure the top row includes the Coding header instead of the composer.
|
||||
// Give the animation thread a moment to tick.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let area = Rect::new(0, 0, 40, 3);
|
||||
@@ -648,8 +662,8 @@ mod tests {
|
||||
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header after denial: {row0:?}"
|
||||
row0.contains("Coding"),
|
||||
"expected Coding header after denial: {row0:?}"
|
||||
);
|
||||
|
||||
// Drain the channel to avoid unused warnings.
|
||||
@@ -677,7 +691,7 @@ mod tests {
|
||||
// Allow some frames so the animation thread ticks.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
|
||||
// Render and confirm the line contains the "Working" header.
|
||||
// Render and confirm the line contains the "Coding" header.
|
||||
let area = Rect::new(0, 0, 40, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
@@ -687,8 +701,8 @@ mod tests {
|
||||
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header: {row0:?}"
|
||||
row0.contains("Coding"),
|
||||
"expected Coding header: {row0:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -723,8 +737,8 @@ mod tests {
|
||||
}
|
||||
assert_eq!(buf[(0, 0)].symbol().chars().next().unwrap_or(' '), '▌');
|
||||
assert!(
|
||||
top.contains("Working"),
|
||||
"expected Working header on top row: {top:?}"
|
||||
top.contains("Coding"),
|
||||
"expected Coding header on top row: {top:?}"
|
||||
);
|
||||
|
||||
// Bottom two rows are blank padding
|
||||
@@ -768,8 +782,8 @@ mod tests {
|
||||
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header on row 0: {row0:?}"
|
||||
row0.contains("Coding"),
|
||||
"expected Coding header on row 0: {row0:?}"
|
||||
);
|
||||
assert!(
|
||||
row1.trim().is_empty(),
|
||||
@@ -785,8 +799,8 @@ mod tests {
|
||||
only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
only.contains("Working"),
|
||||
"expected Working header with no padding: {only:?}"
|
||||
only.contains("Coding"),
|
||||
"expected Coding header with no padding: {only:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ impl<'a> BottomPaneView<'a> for ReasoningSelectionView {
|
||||
// Create a centered box
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Blue))
|
||||
.border_style(Style::default().fg(crate::colors::light_blue()))
|
||||
.title(" Select Reasoning Effort ")
|
||||
.title_alignment(Alignment::Center);
|
||||
|
||||
@@ -160,14 +160,14 @@ impl<'a> BottomPaneView<'a> for ReasoningSelectionView {
|
||||
Span::raw(prefix),
|
||||
Span::styled(format!("{:<8}", name), style),
|
||||
Span::raw(" - "),
|
||||
Span::styled(description, Style::default().fg(Color::Gray)),
|
||||
Span::styled(description, Style::default().fg(crate::colors::dim())),
|
||||
]);
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
|
||||
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
|
||||
Span::raw(" Navigate "),
|
||||
Span::styled("Enter", Style::default().fg(Color::Green)),
|
||||
Span::raw(" Select "),
|
||||
|
||||
@@ -107,7 +107,7 @@ pub(crate) fn render_rows(
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
} else if *is_current {
|
||||
cell = cell.style(Style::default().fg(Color::Cyan));
|
||||
cell = cell.style(Style::default().fg(crate::colors::light_blue()));
|
||||
}
|
||||
rows.push(Row::new(vec![cell]));
|
||||
}
|
||||
@@ -118,7 +118,7 @@ pub(crate) fn render_rows(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().fg(Color::DarkGray)),
|
||||
.border_style(Style::default().fg(crate::colors::dim())),
|
||||
)
|
||||
.widths([Constraint::Percentage(100)]);
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
use codex_core::config_types::ThemeName;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::BottomPane;
|
||||
|
||||
/// Interactive UI for selecting theme
|
||||
pub(crate) struct ThemeSelectionView {
|
||||
current_theme: ThemeName,
|
||||
selected_theme: ThemeName,
|
||||
selected_index: usize,
|
||||
app_event_tx: AppEventSender,
|
||||
is_complete: bool,
|
||||
}
|
||||
|
||||
impl ThemeSelectionView {
|
||||
pub fn new(current_theme: ThemeName, app_event_tx: AppEventSender) -> Self {
|
||||
let themes = Self::get_theme_options();
|
||||
let selected_index = themes
|
||||
.iter()
|
||||
.position(|(t, _, _)| *t == current_theme)
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
current_theme,
|
||||
selected_theme: current_theme,
|
||||
selected_index,
|
||||
app_event_tx,
|
||||
is_complete: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_theme_options() -> Vec<(ThemeName, &'static str, &'static str)> {
|
||||
vec![
|
||||
(ThemeName::CarbonNight, "Carbon Night", "Sleek modern dark theme"),
|
||||
(ThemeName::PhotonLight, "Photon Light", "Clean professional light theme"),
|
||||
(ThemeName::ShinobiDusk, "Shinobi Dusk", "Japanese-inspired twilight"),
|
||||
(ThemeName::OledBlackPro, "OLED Black Pro", "True black for OLED displays"),
|
||||
(ThemeName::AmberTerminal, "Amber Terminal", "Retro amber CRT aesthetic"),
|
||||
(ThemeName::AuroraFlux, "Aurora Flux", "Northern lights inspired"),
|
||||
(ThemeName::CharcoalRainbow, "Charcoal Rainbow", "High-contrast accessible"),
|
||||
(ThemeName::ZenGarden, "Zen Garden", "Calm and peaceful"),
|
||||
(ThemeName::PaperLightPro, "Paper Light Pro", "Premium paper-like light"),
|
||||
]
|
||||
}
|
||||
|
||||
fn move_selection_up(&mut self) {
|
||||
let options = Self::get_theme_options();
|
||||
if self.selected_index == 0 {
|
||||
self.selected_index = options.len() - 1;
|
||||
} else {
|
||||
self.selected_index -= 1;
|
||||
}
|
||||
self.selected_theme = options[self.selected_index].0;
|
||||
}
|
||||
|
||||
fn move_selection_down(&mut self) {
|
||||
let options = Self::get_theme_options();
|
||||
self.selected_index = (self.selected_index + 1) % options.len();
|
||||
self.selected_theme = options[self.selected_index].0;
|
||||
}
|
||||
|
||||
fn confirm_selection(&self) {
|
||||
// Send event to update theme
|
||||
self.app_event_tx.send(AppEvent::UpdateTheme(self.selected_theme));
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
// Return height needed for the popup
|
||||
15
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_selection_up();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_selection_down();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.confirm_selection();
|
||||
self.is_complete = true;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.is_complete = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.is_complete
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let options = Self::get_theme_options();
|
||||
|
||||
// Create content
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::raw("Select Theme"),
|
||||
]).style(Style::default().add_modifier(Modifier::BOLD)),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
for (i, (theme, name, description)) in options.iter().enumerate() {
|
||||
let is_selected = i == self.selected_index;
|
||||
let is_current = *theme == self.current_theme;
|
||||
|
||||
let prefix = if is_selected { "> " } else { " " };
|
||||
let suffix = if is_current { " (current)" } else { "" };
|
||||
|
||||
let spans = vec![
|
||||
Span::raw(prefix),
|
||||
Span::raw(*name),
|
||||
Span::raw(suffix),
|
||||
Span::raw(" - "),
|
||||
Span::styled(
|
||||
*description,
|
||||
Style::default().fg(crate::colors::dim()),
|
||||
),
|
||||
];
|
||||
|
||||
let line = Line::from(spans);
|
||||
let styled_line = if is_selected {
|
||||
line.style(
|
||||
Style::default()
|
||||
.fg(crate::colors::light_blue())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
line
|
||||
};
|
||||
|
||||
lines.push(styled_line);
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw("Use "),
|
||||
Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" to navigate, "),
|
||||
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" to select, "),
|
||||
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" to cancel"),
|
||||
]).style(Style::default().fg(Color::DarkGray)));
|
||||
|
||||
// Create the popup
|
||||
let popup_width = 60;
|
||||
let popup_height = (lines.len() + 2) as u16;
|
||||
|
||||
let popup_x = area.width.saturating_sub(popup_width) / 2;
|
||||
let popup_y = area.height.saturating_sub(popup_height) / 2;
|
||||
|
||||
let popup_area = Rect {
|
||||
x: area.x + popup_x,
|
||||
y: area.y + popup_y,
|
||||
width: popup_width.min(area.width),
|
||||
height: popup_height.min(area.height),
|
||||
};
|
||||
|
||||
// Clear the area
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
// Render the popup
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(crate::colors::text()));
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block)
|
||||
.alignment(Alignment::Left);
|
||||
|
||||
paragraph.render(popup_area, buf);
|
||||
}
|
||||
}
|
||||
+278
-16
@@ -65,6 +65,8 @@ pub(crate) struct ChatWidget<'a> {
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
active_history_cell: Option<HistoryCell>,
|
||||
history_cells: Vec<HistoryCell>, // Store all history in memory
|
||||
history_scroll: u16, // Scroll position in history
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
total_token_usage: TokenUsage,
|
||||
@@ -200,14 +202,13 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
// Status bar at top (3 lines for box border), history in middle, bottom pane at bottom
|
||||
let bottom_height = 6u16.max(self.bottom_pane.desired_height(area.width)).min(15);
|
||||
Layout::vertical([
|
||||
Constraint::Max(
|
||||
self.active_history_cell
|
||||
.as_ref()
|
||||
.map_or(0, |c| c.desired_height(area.width)),
|
||||
),
|
||||
Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||||
Constraint::Length(3), // Status bar with box border
|
||||
Constraint::Fill(1), // History takes all remaining space
|
||||
Constraint::Length(bottom_height),
|
||||
])
|
||||
.areas(area)
|
||||
}
|
||||
@@ -274,6 +275,13 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
});
|
||||
|
||||
// Add initial welcome message to history
|
||||
let mut history_cells = Vec::new();
|
||||
history_cells.push(HistoryCell::new_text_line(
|
||||
ratatui::text::Line::from("Welcome to Codex TUI! Type your message below or use /help for commands.")
|
||||
.style(ratatui::style::Style::default().fg(crate::colors::text_dim()))
|
||||
));
|
||||
|
||||
Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
@@ -283,6 +291,8 @@ impl ChatWidget<'_> {
|
||||
enhanced_keys_supported,
|
||||
}),
|
||||
active_history_cell: None,
|
||||
history_cells,
|
||||
history_scroll: 0,
|
||||
config,
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
@@ -409,12 +419,28 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn add_to_history(&mut self, cell: HistoryCell) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistory(cell.plain_lines()));
|
||||
// Store in memory instead of sending to scrollback
|
||||
self.history_cells.push(cell);
|
||||
// Request redraw to show new history
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
|
||||
// Check for slash commands
|
||||
if text.trim() == "/theme" {
|
||||
// Get current theme from config
|
||||
let current_theme = self.config.tui.theme.name;
|
||||
self.bottom_pane.show_theme_selection(current_theme);
|
||||
return;
|
||||
} else if text.trim() == "/reasoning" {
|
||||
// Get current reasoning effort from config
|
||||
let current_effort = self.config.model_reasoning_effort;
|
||||
self.bottom_pane.show_reasoning_selection(current_effort);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
|
||||
if !text.is_empty() {
|
||||
@@ -787,6 +813,59 @@ impl ChatWidget<'_> {
|
||||
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
}
|
||||
|
||||
pub(crate) fn get_current_theme(&self) -> codex_core::config_types::ThemeName {
|
||||
self.config.tui.theme.name
|
||||
}
|
||||
|
||||
pub(crate) fn show_theme_selection(&mut self) {
|
||||
self.bottom_pane.show_theme_selection(self.config.tui.theme.name);
|
||||
}
|
||||
|
||||
pub(crate) fn set_theme(&mut self, new_theme: codex_core::config_types::ThemeName) {
|
||||
// Update the config
|
||||
self.config.tui.theme.name = new_theme;
|
||||
|
||||
// Save the theme to config file
|
||||
self.save_theme_to_config(new_theme);
|
||||
|
||||
// Add confirmation message to history
|
||||
let theme_name = match new_theme {
|
||||
codex_core::config_types::ThemeName::CarbonNight => "Carbon Night",
|
||||
codex_core::config_types::ThemeName::PhotonLight => "Photon Light",
|
||||
codex_core::config_types::ThemeName::ShinobiDusk => "Shinobi Dusk",
|
||||
codex_core::config_types::ThemeName::OledBlackPro => "OLED Black Pro",
|
||||
codex_core::config_types::ThemeName::AmberTerminal => "Amber Terminal",
|
||||
codex_core::config_types::ThemeName::AuroraFlux => "Aurora Flux",
|
||||
codex_core::config_types::ThemeName::CharcoalRainbow => "Charcoal Rainbow",
|
||||
codex_core::config_types::ThemeName::ZenGarden => "Zen Garden",
|
||||
codex_core::config_types::ThemeName::PaperLightPro => "Paper Light Pro",
|
||||
codex_core::config_types::ThemeName::Custom => "Custom",
|
||||
};
|
||||
let message = format!("✓ Theme changed to {}", theme_name);
|
||||
self.add_to_history(HistoryCell::new_background_event(message));
|
||||
}
|
||||
|
||||
fn save_theme_to_config(&self, new_theme: codex_core::config_types::ThemeName) {
|
||||
// For now, just log the theme change - config saving could be implemented
|
||||
// using the core config system in a future update
|
||||
let theme_str = match new_theme {
|
||||
codex_core::config_types::ThemeName::CarbonNight => "carbon-night",
|
||||
codex_core::config_types::ThemeName::PhotonLight => "photon-light",
|
||||
codex_core::config_types::ThemeName::ShinobiDusk => "shinobi-dusk",
|
||||
codex_core::config_types::ThemeName::OledBlackPro => "oled-black-pro",
|
||||
codex_core::config_types::ThemeName::AmberTerminal => "amber-terminal",
|
||||
codex_core::config_types::ThemeName::AuroraFlux => "aurora-flux",
|
||||
codex_core::config_types::ThemeName::CharcoalRainbow => "charcoal-rainbow",
|
||||
codex_core::config_types::ThemeName::ZenGarden => "zen-garden",
|
||||
codex_core::config_types::ThemeName::PaperLightPro => "paper-light-pro",
|
||||
codex_core::config_types::ThemeName::Custom => "custom",
|
||||
};
|
||||
tracing::info!("Theme changed to: {}", theme_str);
|
||||
// Note: To persist the theme, add the following to your config.toml:
|
||||
// [tui.theme]
|
||||
// name = "{}"
|
||||
}
|
||||
|
||||
pub(crate) fn on_esc(&mut self) -> bool {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
@@ -855,9 +934,67 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let [_, bottom_pane_area] = self.layout_areas(area);
|
||||
let [_status_bar_area, _history_area, bottom_pane_area] = self.layout_areas(area);
|
||||
self.bottom_pane.cursor_pos(bottom_pane_area)
|
||||
}
|
||||
|
||||
fn render_status_bar(&self, area: Rect, buf: &mut Buffer) {
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Block, Borders};
|
||||
use ratatui::style::{Style, Modifier};
|
||||
use ratatui::layout::Margin;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
|
||||
// Add same horizontal padding as the Message input (2 chars on each side)
|
||||
let horizontal_padding = 2u16;
|
||||
let padded_area = Rect {
|
||||
x: area.x + horizontal_padding,
|
||||
y: area.y,
|
||||
width: area.width.saturating_sub(horizontal_padding * 2),
|
||||
height: area.height,
|
||||
};
|
||||
|
||||
// Get current working directory string
|
||||
let cwd_str = match relativize_to_home(&self.config.cwd) {
|
||||
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
|
||||
Some(_) => "~".to_string(),
|
||||
None => self.config.cwd.display().to_string(),
|
||||
};
|
||||
|
||||
// Create status bar content - more compact single line
|
||||
let status_line = Line::from(vec![
|
||||
Span::styled("Codex TUI", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" • "),
|
||||
Span::styled("Model: ", Style::default().dim()),
|
||||
Span::styled(
|
||||
format!("{}", self.config.model),
|
||||
Style::default().fg(crate::colors::info())
|
||||
),
|
||||
Span::raw(" • "),
|
||||
Span::styled("Directory: ", Style::default().dim()),
|
||||
Span::styled(cwd_str, Style::default().fg(crate::colors::info())),
|
||||
Span::raw(" • "),
|
||||
Span::raw("Type "),
|
||||
Span::styled("/help", Style::default().fg(crate::colors::light_blue())),
|
||||
Span::raw(" for commands"),
|
||||
]);
|
||||
|
||||
// Create box border similar to Message input
|
||||
let status_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(crate::colors::border()));
|
||||
|
||||
// Add padding inside the box (1 char horizontal only, like Message input)
|
||||
let inner_area = status_block.inner(padded_area);
|
||||
let padded_inner = inner_area.inner(Margin::new(1, 0));
|
||||
|
||||
// Render the block first
|
||||
status_block.render(padded_area, buf);
|
||||
|
||||
// Then render the text inside with padding
|
||||
let status_widget = Paragraph::new(vec![status_line]);
|
||||
ratatui::widgets::Widget::render(status_widget, padded_inner, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
@@ -904,7 +1041,11 @@ impl ChatWidget<'_> {
|
||||
for r in drained {
|
||||
lines.push(ratatui::text::Line::from(r.text));
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||||
// Add to in-memory history instead of scrollback
|
||||
for line in lines {
|
||||
self.history_cells.push(HistoryCell::new_text_line(line));
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
|
||||
// Update the live ring overlay lines (text-only, newest at bottom).
|
||||
@@ -947,7 +1088,11 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
// Close the block with a blank line for readability.
|
||||
lines.push(ratatui::text::Line::from(""));
|
||||
self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||||
// Add to in-memory history instead of scrollback
|
||||
for line in lines {
|
||||
self.history_cells.push(HistoryCell::new_text_line(line));
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
|
||||
// Clear the live overlay and reset state for the next stream.
|
||||
@@ -960,11 +1105,128 @@ impl ChatWidget<'_> {
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||
if let Some(cell) = &self.active_history_cell {
|
||||
cell.render_ref(active_cell_area, buf);
|
||||
use ratatui::style::Style;
|
||||
|
||||
// Fill entire area with theme background
|
||||
let bg_style = Style::default()
|
||||
.bg(crate::colors::background())
|
||||
.fg(crate::colors::text());
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
buf[(x, y)].set_style(bg_style);
|
||||
}
|
||||
}
|
||||
|
||||
let [status_bar_area, history_area, bottom_pane_area] = self.layout_areas(area);
|
||||
|
||||
// Render status bar
|
||||
self.render_status_bar(status_bar_area, buf);
|
||||
|
||||
// Add horizontal padding to match the bottom pane (3 chars on each side)
|
||||
let horizontal_padding = 3u16;
|
||||
let padded_history_area = Rect {
|
||||
x: history_area.x + horizontal_padding,
|
||||
y: history_area.y,
|
||||
width: history_area.width.saturating_sub(horizontal_padding * 2),
|
||||
height: history_area.height,
|
||||
};
|
||||
|
||||
// Calculate total height of all history cells with padded width
|
||||
let mut total_height = 0u16;
|
||||
let mut cell_heights = Vec::new();
|
||||
|
||||
for cell in &self.history_cells {
|
||||
let h = cell.desired_height(padded_history_area.width);
|
||||
cell_heights.push(h);
|
||||
total_height += h;
|
||||
}
|
||||
|
||||
// Add active cell height if present
|
||||
if let Some(cell) = &self.active_history_cell {
|
||||
let h = cell.desired_height(padded_history_area.width);
|
||||
cell_heights.push(h);
|
||||
total_height += h;
|
||||
}
|
||||
|
||||
// Add a small top padding for better visual spacing
|
||||
let top_padding = 2u16;
|
||||
|
||||
// Start rendering from bottom if total height is less than area height
|
||||
let start_y = if total_height < padded_history_area.height.saturating_sub(top_padding) {
|
||||
// Align to bottom with top padding
|
||||
padded_history_area.y + top_padding.max(padded_history_area.height - total_height)
|
||||
} else {
|
||||
// Scroll: show most recent messages at bottom
|
||||
padded_history_area.y + top_padding
|
||||
};
|
||||
|
||||
// Render history cells from bottom alignment
|
||||
let mut y_offset = if total_height < padded_history_area.height {
|
||||
0u16
|
||||
} else {
|
||||
// When scrolling, skip older messages that don't fit
|
||||
total_height.saturating_sub(padded_history_area.height)
|
||||
};
|
||||
|
||||
let mut current_y = start_y;
|
||||
let mut cell_idx = 0;
|
||||
|
||||
// Skip cells that are scrolled off the top
|
||||
for (i, _cell) in self.history_cells.iter().enumerate() {
|
||||
if y_offset >= cell_heights[i] {
|
||||
y_offset = y_offset.saturating_sub(cell_heights[i]);
|
||||
cell_idx += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Render visible history cells
|
||||
for (i, cell) in self.history_cells.iter().enumerate().skip(cell_idx) {
|
||||
if current_y >= padded_history_area.y + padded_history_area.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let cell_height = cell_heights[i];
|
||||
let visible_height = (padded_history_area.y + padded_history_area.height).saturating_sub(current_y);
|
||||
|
||||
if visible_height > 0 {
|
||||
let cell_area = Rect {
|
||||
x: padded_history_area.x,
|
||||
y: current_y,
|
||||
width: padded_history_area.width,
|
||||
height: visible_height.min(cell_height),
|
||||
};
|
||||
|
||||
// If we're partially scrolled, adjust the rendering
|
||||
if y_offset > 0 && i == cell_idx {
|
||||
// This cell is partially scrolled off
|
||||
// We need to render it with an offset
|
||||
// For now, just render what's visible
|
||||
}
|
||||
|
||||
cell.render_ref(cell_area, buf);
|
||||
current_y += cell_height.saturating_sub(y_offset);
|
||||
y_offset = 0; // Only first cell can be partially scrolled
|
||||
}
|
||||
}
|
||||
|
||||
// Render active cell if present and there's room
|
||||
if let Some(cell) = &self.active_history_cell {
|
||||
if current_y < padded_history_area.y + padded_history_area.height {
|
||||
let cell_area = Rect {
|
||||
x: padded_history_area.x,
|
||||
y: current_y,
|
||||
width: padded_history_area.width,
|
||||
height: (padded_history_area.y + padded_history_area.height).saturating_sub(current_y),
|
||||
};
|
||||
cell.render_ref(cell_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the bottom pane directly without a border for now
|
||||
// The composer has its own layout with hints at the bottom
|
||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,73 @@
|
||||
use crate::theme::current_theme;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub(crate) const LIGHT_BLUE: Color = Color::Rgb(134, 238, 255);
|
||||
pub(crate) const SUCCESS_GREEN: Color = Color::Rgb(169, 230, 158);
|
||||
// Legacy color constants - now redirect to theme
|
||||
pub(crate) fn light_blue() -> Color {
|
||||
current_theme().primary
|
||||
}
|
||||
|
||||
pub(crate) fn success_green() -> Color {
|
||||
current_theme().success
|
||||
}
|
||||
|
||||
// Convenience functions for common theme colors
|
||||
pub(crate) fn primary() -> Color {
|
||||
current_theme().primary
|
||||
}
|
||||
|
||||
pub(crate) fn secondary() -> Color {
|
||||
current_theme().secondary
|
||||
}
|
||||
|
||||
pub(crate) fn border() -> Color {
|
||||
current_theme().border
|
||||
}
|
||||
|
||||
pub(crate) fn border_focused() -> Color {
|
||||
current_theme().border_focused
|
||||
}
|
||||
|
||||
pub(crate) fn text() -> Color {
|
||||
current_theme().text
|
||||
}
|
||||
|
||||
pub(crate) fn text_dim() -> Color {
|
||||
current_theme().text_dim
|
||||
}
|
||||
|
||||
pub(crate) fn text_bright() -> Color {
|
||||
current_theme().text_bright
|
||||
}
|
||||
|
||||
pub(crate) fn error() -> Color {
|
||||
current_theme().error
|
||||
}
|
||||
|
||||
pub(crate) fn warning() -> Color {
|
||||
current_theme().warning
|
||||
}
|
||||
|
||||
pub(crate) fn info() -> Color {
|
||||
current_theme().info
|
||||
}
|
||||
|
||||
pub(crate) fn spinner() -> Color {
|
||||
current_theme().spinner
|
||||
}
|
||||
|
||||
pub(crate) fn progress() -> Color {
|
||||
current_theme().progress
|
||||
}
|
||||
|
||||
// Alias for text_dim
|
||||
pub(crate) fn dim() -> Color {
|
||||
text_dim()
|
||||
}
|
||||
|
||||
pub(crate) fn background() -> Color {
|
||||
current_theme().background
|
||||
}
|
||||
|
||||
pub(crate) fn surface() -> Color {
|
||||
current_theme().foreground
|
||||
}
|
||||
|
||||
@@ -293,15 +293,17 @@ where
|
||||
pub fn with_options(mut backend: B) -> io::Result<Self> {
|
||||
let screen_size = backend.size()?;
|
||||
let cursor_pos = backend.get_cursor_position()?;
|
||||
// In alternate screen mode, use full terminal as viewport
|
||||
let viewport_area = Rect::new(0, 0, screen_size.width, screen_size.height);
|
||||
Ok(Self {
|
||||
backend,
|
||||
buffers: [
|
||||
Buffer::empty(Rect::new(0, 0, 0, 0)),
|
||||
Buffer::empty(Rect::new(0, 0, 0, 0)),
|
||||
Buffer::empty(viewport_area),
|
||||
Buffer::empty(viewport_area),
|
||||
],
|
||||
current: 0,
|
||||
hidden_cursor: false,
|
||||
viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
|
||||
viewport_area,
|
||||
last_known_screen_size: screen_size,
|
||||
last_known_cursor_pos: cursor_pos,
|
||||
frame_count: 0,
|
||||
|
||||
@@ -211,22 +211,8 @@ impl HistoryCell {
|
||||
history_entry_count: _,
|
||||
} = event;
|
||||
if is_first_event {
|
||||
let cwd_str = match relativize_to_home(&config.cwd) {
|
||||
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
|
||||
Some(_) => "~".to_string(),
|
||||
None => config.cwd.display().to_string(),
|
||||
};
|
||||
|
||||
// Since we now have a status bar, just show helpful commands in history
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(">_ ").dim(),
|
||||
Span::styled(
|
||||
"You are using OpenAI Codex in",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {cwd_str}")).dim(),
|
||||
]),
|
||||
Line::from("".dim()),
|
||||
Line::from(" To get started, describe a task or try one of these commands:".dim()),
|
||||
Line::from("".dim()),
|
||||
Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()),
|
||||
@@ -257,7 +243,7 @@ impl HistoryCell {
|
||||
|
||||
pub(crate) fn new_user_prompt(message: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("user".cyan().bold()));
|
||||
lines.push(Line::from(Span::styled("user", Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD))));
|
||||
lines.extend(message.lines().map(|l| Line::from(l.to_string())));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
@@ -273,12 +259,12 @@ impl HistoryCell {
|
||||
let mut iter = command_escaped.lines();
|
||||
if let Some(first) = iter.next() {
|
||||
lines.push(Line::from(vec![
|
||||
"▌ ".cyan(),
|
||||
"Running command ".magenta(),
|
||||
Span::styled("▌ ", Style::default().fg(crate::colors::primary())),
|
||||
Span::styled("Running command ", Style::default().fg(crate::colors::secondary())),
|
||||
first.to_string().into(),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec!["▌ ".cyan(), "Running command".magenta()]));
|
||||
lines.push(Line::from(vec![Span::styled("▌ ", Style::default().fg(crate::colors::primary())), Span::styled("Running command", Style::default().fg(crate::colors::secondary()))]));
|
||||
}
|
||||
for cont in iter {
|
||||
lines.push(Line::from(cont.to_string()));
|
||||
@@ -851,12 +837,23 @@ impl HistoryCell {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a simple text line cell for streaming messages
|
||||
pub(crate) fn new_text_line(line: Line<'static>) -> Self {
|
||||
HistoryCell::BackgroundEvent {
|
||||
view: TextBlock::new(vec![line]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &HistoryCell {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Apply theme background and text color to the paragraph
|
||||
Paragraph::new(Text::from(self.plain_lines()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.style(Style::default()
|
||||
.fg(crate::colors::text())
|
||||
.bg(crate::colors::background()))
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,21 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
|
||||
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
||||
queue!(writer, MoveTo(0, cursor_top)).ok();
|
||||
|
||||
// Set theme colors for the newlines and any unstyled content
|
||||
let theme_fg = crate::colors::text();
|
||||
let theme_bg = crate::colors::background();
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(theme_fg.into(), theme_bg.into()))
|
||||
).ok();
|
||||
|
||||
for line in lines {
|
||||
// Fill entire line with background before writing content
|
||||
queue!(writer, Print("\r\n")).ok();
|
||||
queue!(writer, Print("\x1b[K")).ok(); // Clear to end of line with current bg
|
||||
write_spans(writer, line.iter()).ok();
|
||||
queue!(writer, Print("\x1b[K")).ok(); // Clear remainder of line after content
|
||||
}
|
||||
|
||||
queue!(writer, ResetScrollRegion).ok();
|
||||
@@ -245,9 +256,20 @@ fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = &'a Span<'a>>,
|
||||
{
|
||||
let mut fg = Color::Reset;
|
||||
let mut bg = Color::Reset;
|
||||
// Use theme colors as defaults instead of terminal defaults
|
||||
let theme_fg = crate::colors::text();
|
||||
let theme_bg = crate::colors::background();
|
||||
|
||||
let mut fg = theme_fg;
|
||||
let mut bg = theme_bg;
|
||||
let mut last_modifier = Modifier::empty();
|
||||
|
||||
// Set initial theme colors
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(theme_fg.into(), theme_bg.into()))
|
||||
)?;
|
||||
|
||||
for span in content {
|
||||
let mut modifier = Modifier::empty();
|
||||
modifier.insert(span.style.add_modifier);
|
||||
@@ -260,8 +282,8 @@ where
|
||||
diff.queue(&mut writer)?;
|
||||
last_modifier = modifier;
|
||||
}
|
||||
let next_fg = span.style.fg.unwrap_or(Color::Reset);
|
||||
let next_bg = span.style.bg.unwrap_or(Color::Reset);
|
||||
let next_fg = span.style.fg.unwrap_or(theme_fg);
|
||||
let next_bg = span.style.bg.unwrap_or(theme_bg);
|
||||
if next_fg != fg || next_bg != bg {
|
||||
queue!(
|
||||
writer,
|
||||
@@ -274,10 +296,10 @@ where
|
||||
queue!(writer, Print(span.content.clone()))?;
|
||||
}
|
||||
|
||||
// Reset to theme colors instead of terminal defaults
|
||||
queue!(
|
||||
writer,
|
||||
SetForegroundColor(CColor::Reset),
|
||||
SetBackgroundColor(CColor::Reset),
|
||||
SetColors(Colors::new(theme_fg.into(), theme_bg.into())),
|
||||
SetAttribute(crossterm::style::Attribute::Reset),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ mod slash_command;
|
||||
mod status_indicator_widget;
|
||||
mod text_block;
|
||||
mod text_formatting;
|
||||
mod theme;
|
||||
mod tui;
|
||||
mod user_approval_widget;
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ use codex_login::AuthMode;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::colors::LIGHT_BLUE;
|
||||
use crate::colors::SUCCESS_GREEN;
|
||||
use crate::colors::{light_blue, success_green};
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::shimmer::FrameTicker;
|
||||
@@ -130,9 +129,9 @@ impl AuthModeWidget {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} {}. ", caret, idx + 1),
|
||||
Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
|
||||
Style::default().fg(light_blue()).add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
|
||||
Span::styled(text.to_owned(), Style::default().fg(light_blue())),
|
||||
])
|
||||
} else {
|
||||
Line::from(format!(" {}. {text}", idx + 1))
|
||||
@@ -140,7 +139,7 @@ impl AuthModeWidget {
|
||||
|
||||
let line2 = if is_selected {
|
||||
Line::from(format!(" {description}"))
|
||||
.style(Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM))
|
||||
.style(Style::default().fg(light_blue()).add_modifier(Modifier::DIM))
|
||||
} else {
|
||||
Line::from(format!(" {description}"))
|
||||
.style(Style::default().add_modifier(Modifier::DIM))
|
||||
@@ -197,7 +196,7 @@ impl AuthModeWidget {
|
||||
Span::styled(
|
||||
url,
|
||||
Style::default()
|
||||
.fg(LIGHT_BLUE)
|
||||
.fg(light_blue())
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
),
|
||||
]));
|
||||
@@ -216,7 +215,7 @@ impl AuthModeWidget {
|
||||
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
Line::from("✓ Signed in with your ChatGPT account")
|
||||
.style(Style::default().fg(SUCCESS_GREEN)),
|
||||
.style(Style::default().fg(success_green())),
|
||||
Line::from(""),
|
||||
Line::from("> Before you start:"),
|
||||
Line::from(""),
|
||||
@@ -245,7 +244,7 @@ impl AuthModeWidget {
|
||||
])
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
Line::from(""),
|
||||
Line::from(" Press Enter to continue").style(Style::default().fg(LIGHT_BLUE)),
|
||||
Line::from(" Press Enter to continue").style(Style::default().fg(light_blue())),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
@@ -256,7 +255,7 @@ impl AuthModeWidget {
|
||||
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
Line::from("✓ Signed in with your ChatGPT account")
|
||||
.style(Style::default().fg(SUCCESS_GREEN)),
|
||||
.style(Style::default().fg(success_green())),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
@@ -266,7 +265,7 @@ impl AuthModeWidget {
|
||||
|
||||
fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines =
|
||||
vec![Line::from("✓ Using OPENAI_API_KEY").style(Style::default().fg(SUCCESS_GREEN))];
|
||||
vec![Line::from("✓ Using OPENAI_API_KEY").style(Style::default().fg(success_green()))];
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
|
||||
@@ -18,7 +18,7 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::colors::LIGHT_BLUE;
|
||||
use crate::colors::light_blue;
|
||||
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
@@ -80,9 +80,9 @@ impl WidgetRef for &TrustDirectoryWidget {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("> {}. ", idx + 1),
|
||||
Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
|
||||
Style::default().fg(light_blue()).add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
|
||||
Span::styled(text.to_owned(), Style::default().fg(light_blue())),
|
||||
])
|
||||
} else {
|
||||
Line::from(format!(" {}. {}", idx + 1, text))
|
||||
|
||||
@@ -17,6 +17,7 @@ pub enum SlashCommand {
|
||||
Compact,
|
||||
Diff,
|
||||
Status,
|
||||
Theme,
|
||||
Reasoning,
|
||||
Prompts,
|
||||
Logout,
|
||||
@@ -35,6 +36,7 @@ impl SlashCommand {
|
||||
SlashCommand::Quit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Theme => "switch between color themes",
|
||||
SlashCommand::Reasoning => "change reasoning effort (low/medium/high/none)",
|
||||
SlashCommand::Prompts => "show example prompts",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::time::Instant;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use crate::colors;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
@@ -87,7 +88,7 @@ impl StatusIndicatorWidget {
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, _width: u16) -> u16 {
|
||||
1
|
||||
3 // 1 line for status + 1 line padding above + 1 line padding below
|
||||
}
|
||||
|
||||
/// Update the line that is displayed in the widget.
|
||||
@@ -170,11 +171,21 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// Center the status line within the 3-line area (line 2 of 3)
|
||||
// If we have less than 3 lines, render on the middle line if possible
|
||||
let render_line = if area.height >= 3 {
|
||||
1 // Render on line 2 (0-indexed) for padding above and below
|
||||
} else if area.height == 2 {
|
||||
0 // Render on first line if only 2 lines available
|
||||
} else {
|
||||
0 // Single line - render on it
|
||||
};
|
||||
|
||||
let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let elapsed = self.start_time.elapsed().as_secs();
|
||||
let shown_now = self.current_shown_len(idx);
|
||||
let status_prefix: String = self.text.chars().take(shown_now).collect();
|
||||
let animated_text = "Working";
|
||||
let animated_text = "Coding";
|
||||
let header_chars: Vec<char> = animated_text.chars().collect();
|
||||
let padding = 4usize; // virtual padding around the animated segment for smoother loop
|
||||
let period = header_chars.len() + padding * 2;
|
||||
@@ -214,15 +225,15 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
let inner_width = area.width as usize;
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::styled("▌ ", Style::default().fg(Color::Cyan)));
|
||||
spans.push(Span::styled("▌ ", Style::default().fg(colors::info())));
|
||||
|
||||
// Simple dim spinner to the left of the header.
|
||||
let spinner_frames = ['·', '•', '●', '•'];
|
||||
// Moon phase spinner animation
|
||||
let spinner_frames = ['◯', '◔', '◑', '◕', '●', '◕', '◑', '◔'];
|
||||
const SPINNER_SLOWDOWN: usize = 2;
|
||||
let spinner_ch = spinner_frames[(idx / SPINNER_SLOWDOWN) % spinner_frames.len()];
|
||||
spans.push(Span::styled(
|
||||
spinner_ch.to_string(),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
Style::default().fg(colors::spinner()),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
@@ -235,27 +246,27 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
let bracket_prefix = format!("({elapsed}s • ");
|
||||
spans.push(Span::styled(
|
||||
bracket_prefix,
|
||||
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
|
||||
Style::default().fg(colors::text_dim()).add_modifier(Modifier::DIM),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
"Esc",
|
||||
Style::default()
|
||||
.fg(Color::Gray)
|
||||
.fg(colors::text_dim())
|
||||
.add_modifier(Modifier::DIM | Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
" to interrupt)",
|
||||
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
|
||||
Style::default().fg(colors::text_dim()).add_modifier(Modifier::DIM),
|
||||
));
|
||||
// Add a space and then the log text (not animated by the gradient)
|
||||
if !status_prefix.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
" ",
|
||||
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
|
||||
Style::default().fg(colors::text_dim()).add_modifier(Modifier::DIM),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
status_prefix,
|
||||
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
|
||||
Style::default().fg(colors::text_dim()).add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -275,18 +286,25 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
|
||||
// No-op once full text is revealed; the app no longer reacts to a completion event.
|
||||
|
||||
// Render the paragraph on the calculated line for padding
|
||||
let render_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + render_line,
|
||||
width: area.width,
|
||||
height: 1, // Always render just the one line
|
||||
};
|
||||
let paragraph = Paragraph::new(lines);
|
||||
paragraph.render_ref(area, buf);
|
||||
paragraph.render_ref(render_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn color_for_level(level: u8) -> Color {
|
||||
if level < 128 {
|
||||
Color::DarkGray
|
||||
colors::spinner()
|
||||
} else if level < 192 {
|
||||
Color::Gray
|
||||
colors::text_dim()
|
||||
} else {
|
||||
Color::White
|
||||
colors::text_bright()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +351,7 @@ mod tests {
|
||||
for x in 0..area.width {
|
||||
row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(row.contains("Working"), "expected Working header: {row:?}");
|
||||
assert!(row.contains("Coding"), "expected Coding header: {row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -350,7 +368,7 @@ mod tests {
|
||||
|
||||
let ch = buf[(2, 0)].symbol().chars().next().unwrap_or(' ');
|
||||
assert!(
|
||||
matches!(ch, '·' | '•' | '●'),
|
||||
matches!(ch, '◯' | '◔' | '◑' | '◕' | '●'),
|
||||
"expected spinner char at col 2: {ch:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
use codex_core::config_types::{ThemeColors, ThemeConfig, ThemeName};
|
||||
use lazy_static::lazy_static;
|
||||
use ratatui::style::Color;
|
||||
use std::sync::RwLock;
|
||||
|
||||
lazy_static! {
|
||||
static ref CURRENT_THEME: RwLock<Theme> = RwLock::new(Theme::default());
|
||||
}
|
||||
|
||||
/// Represents a complete theme with all colors resolved
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
// Primary colors
|
||||
pub primary: Color,
|
||||
pub secondary: Color,
|
||||
pub background: Color,
|
||||
pub foreground: Color,
|
||||
|
||||
// UI elements
|
||||
pub border: Color,
|
||||
pub border_focused: Color,
|
||||
pub selection: Color,
|
||||
pub cursor: Color,
|
||||
|
||||
// Status colors
|
||||
pub success: Color,
|
||||
pub warning: Color,
|
||||
pub error: Color,
|
||||
pub info: Color,
|
||||
|
||||
// Text colors
|
||||
pub text: Color,
|
||||
pub text_dim: Color,
|
||||
pub text_bright: Color,
|
||||
|
||||
// Syntax/special colors
|
||||
pub keyword: Color,
|
||||
pub string: Color,
|
||||
pub comment: Color,
|
||||
pub function: Color,
|
||||
|
||||
// Animation colors
|
||||
pub spinner: Color,
|
||||
pub progress: Color,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
get_predefined_theme(ThemeName::PhotonLight)
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the global theme from configuration
|
||||
pub fn init_theme(config: &ThemeConfig) {
|
||||
let mut theme = get_predefined_theme(config.name);
|
||||
apply_custom_colors(&mut theme, &config.colors);
|
||||
|
||||
let mut current = CURRENT_THEME.write().unwrap();
|
||||
*current = theme;
|
||||
}
|
||||
|
||||
/// Get the current theme
|
||||
pub fn current_theme() -> Theme {
|
||||
CURRENT_THEME.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Switch to a different predefined theme
|
||||
pub fn switch_theme(theme_name: ThemeName) {
|
||||
let theme = get_predefined_theme(theme_name);
|
||||
let mut current = CURRENT_THEME.write().unwrap();
|
||||
*current = theme;
|
||||
}
|
||||
|
||||
/// Get list of available theme names for display
|
||||
pub fn available_themes() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("carbon-night", "Sleek modern dark theme"),
|
||||
("photon-light", "Clean professional light theme"),
|
||||
("shinobi-dusk", "Japanese-inspired twilight"),
|
||||
("oled-black-pro", "True black for OLED displays"),
|
||||
("amber-terminal", "Retro amber CRT aesthetic"),
|
||||
("aurora-flux", "Northern lights inspired"),
|
||||
("charcoal-rainbow", "High-contrast accessible"),
|
||||
("zen-garden", "Calm and peaceful"),
|
||||
("paper-light-pro", "Premium paper-like light"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse a color string (hex or named color)
|
||||
fn parse_color(color_str: &str) -> Option<Color> {
|
||||
if let Some(hex) = color_str.strip_prefix('#') {
|
||||
if hex.len() == 6 {
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||
u8::from_str_radix(&hex[0..2], 16),
|
||||
u8::from_str_radix(&hex[2..4], 16),
|
||||
u8::from_str_radix(&hex[4..6], 16),
|
||||
) {
|
||||
return Some(Color::Rgb(r, g, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Named colors
|
||||
match color_str.to_lowercase().as_str() {
|
||||
"black" => Some(Color::Black),
|
||||
"red" => Some(Color::Red),
|
||||
"green" => Some(Color::Green),
|
||||
"yellow" => Some(Color::Yellow),
|
||||
"blue" => Some(Color::Blue),
|
||||
"magenta" => Some(Color::Magenta),
|
||||
"cyan" => Some(Color::Cyan),
|
||||
"gray" | "grey" => Some(Color::Gray),
|
||||
"darkgray" | "darkgrey" => Some(Color::DarkGray),
|
||||
"lightred" => Some(Color::LightRed),
|
||||
"lightgreen" => Some(Color::LightGreen),
|
||||
"lightyellow" => Some(Color::LightYellow),
|
||||
"lightblue" => Some(Color::LightBlue),
|
||||
"lightmagenta" => Some(Color::LightMagenta),
|
||||
"lightcyan" => Some(Color::LightCyan),
|
||||
"white" => Some(Color::White),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply custom color overrides to a theme
|
||||
fn apply_custom_colors(theme: &mut Theme, colors: &ThemeColors) {
|
||||
if let Some(ref c) = colors.primary {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.primary = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.secondary {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.secondary = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.background {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.background = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.foreground {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.foreground = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.border {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.border = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.border_focused {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.border_focused = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.selection {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.selection = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.cursor {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.cursor = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.success {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.success = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.warning {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.warning = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.error {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.error = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.info {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.info = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.text {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.text = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.text_dim {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.text_dim = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.text_bright {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.text_bright = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.keyword {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.keyword = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.string {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.string = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.comment {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.comment = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.function {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.function = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.spinner {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.spinner = color;
|
||||
}
|
||||
}
|
||||
if let Some(ref c) = colors.progress {
|
||||
if let Some(color) = parse_color(c) {
|
||||
theme.progress = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a predefined theme by name
|
||||
fn get_predefined_theme(name: ThemeName) -> Theme {
|
||||
match name {
|
||||
ThemeName::CarbonNight => Theme {
|
||||
// Dark default - sleek modern dark theme
|
||||
primary: Color::Rgb(37, 194, 255), // #25c2ff
|
||||
secondary: Color::Rgb(179, 146, 240), // #b392f0
|
||||
background: Color::Rgb(11, 13, 16), // #0b0d10
|
||||
foreground: Color::Rgb(230, 237, 243), // #e6edf3
|
||||
border: Color::Rgb(17, 22, 28), // #11161c
|
||||
border_focused: Color::Rgb(37, 194, 255),
|
||||
selection: Color::Rgb(23, 32, 42), // #17202a
|
||||
cursor: Color::Rgb(230, 237, 243),
|
||||
success: Color::Rgb(63, 185, 80), // #3fb950
|
||||
warning: Color::Rgb(210, 153, 34), // #d29922
|
||||
error: Color::Rgb(248, 81, 73), // #f85149
|
||||
info: Color::Rgb(121, 192, 255), // #79c0ff
|
||||
text: Color::Rgb(230, 237, 243),
|
||||
text_dim: Color::Rgb(139, 148, 158), // #8b949e
|
||||
text_bright: Color::White,
|
||||
keyword: Color::Rgb(179, 146, 240),
|
||||
string: Color::Rgb(165, 214, 255), // #a5d6ff
|
||||
comment: Color::Rgb(110, 118, 129), // #6e7681
|
||||
function: Color::Rgb(126, 231, 135), // #7ee787
|
||||
spinner: Color::Rgb(59, 67, 79), // #3b434f
|
||||
progress: Color::Rgb(37, 194, 255),
|
||||
},
|
||||
|
||||
ThemeName::PhotonLight => Theme {
|
||||
// Light default - clean professional light theme
|
||||
primary: Color::Rgb(0, 95, 204), // #005FCC
|
||||
secondary: Color::Rgb(110, 89, 203), // #6E59CB
|
||||
background: Color::Rgb(250, 250, 250), // #FAFAFA
|
||||
foreground: Color::Rgb(31, 35, 40), // #1F2328
|
||||
border: Color::Rgb(208, 215, 222), // #D0D7DE
|
||||
border_focused: Color::Rgb(0, 95, 204),
|
||||
selection: Color::Rgb(234, 242, 255), // #EAF2FF
|
||||
cursor: Color::Rgb(31, 35, 40),
|
||||
success: Color::Rgb(26, 127, 55), // #1A7F37
|
||||
warning: Color::Rgb(154, 103, 0), // #9A6700
|
||||
error: Color::Rgb(207, 34, 46), // #CF222E
|
||||
info: Color::Rgb(9, 105, 218), // #0969DA
|
||||
text: Color::Rgb(31, 35, 40),
|
||||
text_dim: Color::Rgb(102, 112, 133), // #667085
|
||||
text_bright: Color::Black,
|
||||
keyword: Color::Rgb(110, 89, 203),
|
||||
string: Color::Rgb(11, 125, 105), // #0B7D69
|
||||
comment: Color::Rgb(100, 115, 132), // #647384
|
||||
function: Color::Rgb(0, 95, 204),
|
||||
spinner: Color::Rgb(156, 163, 175), // #9CA3AF
|
||||
progress: Color::Rgb(0, 95, 204),
|
||||
},
|
||||
|
||||
ThemeName::ShinobiDusk => Theme {
|
||||
// Japanese-inspired twilight theme
|
||||
primary: Color::Rgb(122, 162, 247), // #7aa2f7
|
||||
secondary: Color::Rgb(158, 206, 106), // #9ece6a
|
||||
background: Color::Rgb(15, 20, 25), // #0f1419
|
||||
foreground: Color::Rgb(192, 202, 245), // #c0caf5
|
||||
border: Color::Rgb(27, 35, 48), // #1b2330
|
||||
border_focused: Color::Rgb(122, 162, 247),
|
||||
selection: Color::Rgb(26, 33, 48), // #1a2130
|
||||
cursor: Color::Rgb(192, 202, 245),
|
||||
success: Color::Rgb(158, 206, 106),
|
||||
warning: Color::Rgb(224, 175, 104), // #e0af68
|
||||
error: Color::Rgb(247, 118, 142), // #f7768e
|
||||
info: Color::Rgb(125, 207, 255), // #7dcfff
|
||||
text: Color::Rgb(192, 202, 245),
|
||||
text_dim: Color::Rgb(122, 136, 160), // #7a88a0
|
||||
text_bright: Color::White,
|
||||
keyword: Color::Rgb(187, 154, 247), // #bb9af7
|
||||
string: Color::Rgb(158, 206, 106),
|
||||
comment: Color::Rgb(91, 100, 121), // #5b6479
|
||||
function: Color::Rgb(122, 162, 247),
|
||||
spinner: Color::Rgb(42, 49, 64), // #2a3140
|
||||
progress: Color::Rgb(122, 162, 247),
|
||||
},
|
||||
|
||||
ThemeName::OledBlackPro => Theme {
|
||||
// True black for OLED displays with vibrant accents
|
||||
primary: Color::Rgb(0, 209, 255), // #00d1ff
|
||||
secondary: Color::Rgb(255, 116, 208), // #ff74d0
|
||||
background: Color::Black,
|
||||
foreground: Color::Rgb(218, 218, 218), // #dadada
|
||||
border: Color::Rgb(26, 26, 26), // #1a1a1a
|
||||
border_focused: Color::Rgb(0, 209, 255),
|
||||
selection: Color::Rgb(13, 13, 13), // #0d0d0d
|
||||
cursor: Color::Rgb(218, 218, 218),
|
||||
success: Color::Rgb(33, 243, 114), // #21f372
|
||||
warning: Color::Rgb(255, 209, 102), // #ffd166
|
||||
error: Color::Rgb(255, 59, 48), // #ff3b30
|
||||
info: Color::Rgb(37, 194, 255), // #25c2ff
|
||||
text: Color::Rgb(208, 208, 208), // #d0d0d0
|
||||
text_dim: Color::Rgb(128, 128, 128), // #808080
|
||||
text_bright: Color::White,
|
||||
keyword: Color::Rgb(255, 116, 208),
|
||||
string: Color::Rgb(186, 255, 128), // #baff80
|
||||
comment: Color::Rgb(102, 102, 102), // #666666
|
||||
function: Color::Rgb(37, 194, 255),
|
||||
spinner: Color::Rgb(45, 45, 45), // #2d2d2d
|
||||
progress: Color::Rgb(0, 209, 255),
|
||||
},
|
||||
|
||||
ThemeName::AmberTerminal => Theme {
|
||||
// Retro amber CRT monitor aesthetic
|
||||
primary: Color::Rgb(255, 176, 0), // #ffb000
|
||||
secondary: Color::Rgb(255, 209, 138), // #ffd18a
|
||||
background: Color::Rgb(12, 12, 8), // #0c0c08
|
||||
foreground: Color::Rgb(255, 209, 138),
|
||||
border: Color::Rgb(48, 42, 26), // #302a1a
|
||||
border_focused: Color::Rgb(255, 176, 0),
|
||||
selection: Color::Rgb(26, 20, 8), // #1a1408
|
||||
cursor: Color::Rgb(255, 209, 138),
|
||||
success: Color::Rgb(255, 207, 51), // #ffcf33
|
||||
warning: Color::Rgb(255, 158, 0), // #ff9e00
|
||||
error: Color::Rgb(255, 94, 58), // #ff5e3a
|
||||
info: Color::Rgb(255, 184, 77), // #ffb84d
|
||||
text: Color::Rgb(255, 209, 138),
|
||||
text_dim: Color::Rgb(163, 131, 77), // #a3834d
|
||||
text_bright: Color::Rgb(255, 241, 194), // #fff1c2
|
||||
keyword: Color::Rgb(255, 193, 77), // #ffc14d
|
||||
string: Color::Rgb(255, 224, 138), // #ffe08a
|
||||
comment: Color::Rgb(156, 124, 63), // #9c7c3f
|
||||
function: Color::Rgb(255, 176, 0),
|
||||
spinner: Color::Rgb(58, 45, 23), // #3a2d17
|
||||
progress: Color::Rgb(255, 176, 0),
|
||||
},
|
||||
|
||||
ThemeName::AuroraFlux => Theme {
|
||||
// Northern lights inspired with cool tones
|
||||
primary: Color::Rgb(142, 202, 255), // #8ecaff
|
||||
secondary: Color::Rgb(158, 228, 147), // #9ee493
|
||||
background: Color::Rgb(11, 16, 32), // #0b1020
|
||||
foreground: Color::Rgb(230, 241, 255), // #e6f1ff
|
||||
border: Color::Rgb(18, 26, 46), // #121a2e
|
||||
border_focused: Color::Rgb(142, 202, 255),
|
||||
selection: Color::Rgb(19, 26, 44), // #131a2c
|
||||
cursor: Color::Rgb(230, 241, 255),
|
||||
success: Color::Rgb(158, 228, 147),
|
||||
warning: Color::Rgb(255, 212, 121), // #ffd479
|
||||
error: Color::Rgb(255, 107, 129), // #ff6b81
|
||||
info: Color::Rgb(142, 202, 255),
|
||||
text: Color::Rgb(217, 230, 255), // #d9e6ff
|
||||
text_dim: Color::Rgb(127, 140, 168), // #7f8ca8
|
||||
text_bright: Color::White,
|
||||
keyword: Color::Rgb(194, 153, 255), // #c299ff
|
||||
string: Color::Rgb(160, 255, 179), // #a0ffb3
|
||||
comment: Color::Rgb(95, 106, 130), // #5f6a82
|
||||
function: Color::Rgb(142, 202, 255),
|
||||
spinner: Color::Rgb(37, 48, 74), // #25304a
|
||||
progress: Color::Rgb(142, 202, 255),
|
||||
},
|
||||
|
||||
ThemeName::CharcoalRainbow => Theme {
|
||||
// Accessible high-contrast with rainbow accents
|
||||
primary: Color::Rgb(26, 209, 255), // #1ad1ff
|
||||
secondary: Color::Rgb(255, 138, 0), // #ff8a00
|
||||
background: Color::Rgb(18, 18, 18), // #121212
|
||||
foreground: Color::Rgb(232, 232, 232), // #e8e8e8
|
||||
border: Color::Rgb(31, 31, 31), // #1f1f1f
|
||||
border_focused: Color::Rgb(26, 209, 255),
|
||||
selection: Color::Rgb(26, 26, 26), // #1a1a1a
|
||||
cursor: Color::Rgb(232, 232, 232),
|
||||
success: Color::Rgb(0, 194, 168), // #00c2a8
|
||||
warning: Color::Rgb(255, 160, 0), // #ffa000
|
||||
error: Color::Rgb(255, 77, 109), // #ff4d6d
|
||||
info: Color::Rgb(26, 209, 255),
|
||||
text: Color::Rgb(232, 232, 232),
|
||||
text_dim: Color::Rgb(154, 154, 154), // #9a9a9a
|
||||
text_bright: Color::White,
|
||||
keyword: Color::Rgb(255, 138, 0),
|
||||
string: Color::Rgb(0, 229, 255), // #00e5ff
|
||||
comment: Color::Rgb(108, 108, 108), // #6c6c6c
|
||||
function: Color::Rgb(179, 136, 255), // #b388ff
|
||||
spinner: Color::Rgb(42, 42, 42), // #2a2a2a
|
||||
progress: Color::Rgb(26, 209, 255),
|
||||
},
|
||||
|
||||
ThemeName::ZenGarden => Theme {
|
||||
// Calm, peaceful theme with mint and lavender
|
||||
primary: Color::Rgb(148, 226, 213), // #94e2d5
|
||||
secondary: Color::Rgb(242, 205, 205), // #f2cdcd
|
||||
background: Color::Rgb(16, 20, 23), // #101417
|
||||
foreground: Color::Rgb(220, 227, 234), // #dce3ea
|
||||
border: Color::Rgb(26, 33, 38), // #1a2126
|
||||
border_focused: Color::Rgb(148, 226, 213),
|
||||
selection: Color::Rgb(23, 32, 38), // #172026
|
||||
cursor: Color::Rgb(220, 227, 234),
|
||||
success: Color::Rgb(166, 227, 161), // #a6e3a1
|
||||
warning: Color::Rgb(249, 226, 175), // #f9e2af
|
||||
error: Color::Rgb(243, 139, 168), // #f38ba8
|
||||
info: Color::Rgb(137, 220, 235), // #89dceb
|
||||
text: Color::Rgb(220, 227, 234),
|
||||
text_dim: Color::Rgb(139, 155, 170), // #8b9baa
|
||||
text_bright: Color::White,
|
||||
keyword: Color::Rgb(203, 166, 247), // #cba6f7
|
||||
string: Color::Rgb(166, 227, 161),
|
||||
comment: Color::Rgb(108, 122, 136), // #6c7a88
|
||||
function: Color::Rgb(137, 220, 235),
|
||||
spinner: Color::Rgb(37, 49, 58), // #25313a
|
||||
progress: Color::Rgb(148, 226, 213),
|
||||
},
|
||||
|
||||
ThemeName::PaperLightPro => Theme {
|
||||
// Premium paper-like light theme
|
||||
primary: Color::Rgb(0, 95, 204), // #005fcc
|
||||
secondary: Color::Rgb(111, 66, 193), // #6f42c1
|
||||
background: Color::Rgb(247, 247, 245), // #f7f7f5
|
||||
foreground: Color::Rgb(27, 31, 35), // #1b1f23
|
||||
border: Color::Rgb(208, 215, 222), // #d0d7de
|
||||
border_focused: Color::Rgb(0, 95, 204),
|
||||
selection: Color::Rgb(231, 237, 243), // #e7edf3
|
||||
cursor: Color::Rgb(27, 31, 35),
|
||||
success: Color::Rgb(26, 127, 55), // #1a7f37
|
||||
warning: Color::Rgb(154, 103, 0), // #9a6700
|
||||
error: Color::Rgb(207, 34, 46), // #cf222e
|
||||
info: Color::Rgb(9, 105, 218), // #0969da
|
||||
text: Color::Rgb(36, 41, 47), // #24292f
|
||||
text_dim: Color::Rgb(87, 96, 106), // #57606a
|
||||
text_bright: Color::Black,
|
||||
keyword: Color::Rgb(111, 66, 193),
|
||||
string: Color::Rgb(11, 125, 105), // #0b7d69
|
||||
comment: Color::Rgb(140, 149, 159), // #8c959f
|
||||
function: Color::Rgb(0, 95, 204),
|
||||
spinner: Color::Rgb(140, 149, 159),
|
||||
progress: Color::Rgb(0, 95, 204),
|
||||
},
|
||||
|
||||
ThemeName::Custom => {
|
||||
// Use CarbonNight (dark default) as base for custom
|
||||
get_predefined_theme(ThemeName::CarbonNight)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -11,6 +11,7 @@ use crossterm::event::PopKeyboardEnhancementFlags;
|
||||
use crossterm::event::PushKeyboardEnhancementFlags;
|
||||
use crossterm::terminal::Clear;
|
||||
use crossterm::terminal::ClearType;
|
||||
use crossterm::style::SetColors;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::disable_raw_mode;
|
||||
@@ -21,9 +22,15 @@ use crate::custom_terminal::Terminal;
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||
pub fn init(_config: &Config) -> Result<Tui> {
|
||||
/// Initialize the terminal (full screen mode with alternate screen)
|
||||
pub fn init(config: &Config) -> Result<Tui> {
|
||||
// Initialize the theme based on config
|
||||
crate::theme::init_theme(&config.tui.theme);
|
||||
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
|
||||
// Enter alternate screen mode for full screen TUI
|
||||
execute!(stdout(), crossterm::terminal::EnterAlternateScreen)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
|
||||
@@ -42,8 +49,15 @@ pub fn init(_config: &Config) -> Result<Tui> {
|
||||
);
|
||||
set_panic_hook();
|
||||
|
||||
// Clear screen and move cursor to top-left before drawing UI
|
||||
execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
|
||||
// Clear screen with theme background color
|
||||
let theme_bg = crate::colors::background();
|
||||
let theme_fg = crate::colors::text();
|
||||
execute!(
|
||||
stdout(),
|
||||
SetColors(crossterm::style::Colors::new(theme_fg.into(), theme_bg.into())),
|
||||
Clear(ClearType::All),
|
||||
MoveTo(0, 0)
|
||||
)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let tui = Terminal::with_options(backend)?;
|
||||
@@ -64,5 +78,7 @@ pub fn restore() -> Result<()> {
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
execute!(stdout(), DisableBracketedPaste)?;
|
||||
disable_raw_mode()?;
|
||||
// Leave alternate screen mode
|
||||
execute!(stdout(), crossterm::terminal::LeaveAlternateScreen)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
.enumerate()
|
||||
.map(|(idx, opt)| {
|
||||
let style = if idx == self.selected_option {
|
||||
Style::new().bg(Color::Cyan).fg(Color::Black)
|
||||
Style::new().bg(crate::colors::light_blue()).fg(Color::Black)
|
||||
} else {
|
||||
Style::new().bg(Color::DarkGray)
|
||||
};
|
||||
@@ -379,7 +379,7 @@ impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
|
||||
Block::bordered()
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.border_style(Style::default().fg(crate::colors::light_blue()))
|
||||
.borders(Borders::LEFT)
|
||||
.render_ref(
|
||||
Rect::new(0, response_chunk.y, 1, response_chunk.height),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Theme Testing Guide
|
||||
|
||||
## Testing the /theme Command
|
||||
|
||||
The theme switching functionality has been successfully implemented. Here's how to test it:
|
||||
|
||||
### 1. Build and Install
|
||||
```bash
|
||||
npm run build:quick
|
||||
```
|
||||
|
||||
### 2. Run Codex TUI
|
||||
```bash
|
||||
codex
|
||||
```
|
||||
|
||||
### 3. Test Theme Switching
|
||||
- Type `/theme` in the compose field
|
||||
- Press Enter to open the theme selection UI
|
||||
- Use arrow keys (↑↓) to navigate between themes
|
||||
- Press Enter to select a theme
|
||||
- Press Esc to cancel
|
||||
|
||||
### Available Themes
|
||||
|
||||
1. **Carbon Night** - Sleek modern dark theme (default)
|
||||
2. **Photon Light** - Clean professional light theme
|
||||
3. **Shinobi Dusk** - Japanese-inspired twilight
|
||||
4. **OLED Black Pro** - True black for OLED displays
|
||||
5. **Amber Terminal** - Retro amber CRT aesthetic
|
||||
6. **Aurora Flux** - Northern lights inspired
|
||||
7. **Charcoal Rainbow** - High-contrast accessible
|
||||
8. **Zen Garden** - Calm and peaceful
|
||||
9. **Paper Light Pro** - Premium paper-like light
|
||||
|
||||
### What to Verify
|
||||
|
||||
1. **Theme Selection UI**:
|
||||
- The popup appears when typing `/theme`
|
||||
- All 9 themes are listed with descriptions
|
||||
- Current theme is marked with "(current)"
|
||||
- Arrow keys navigate the list
|
||||
- Selected item is highlighted with ">" and cyan color
|
||||
|
||||
2. **Theme Application**:
|
||||
- When a theme is selected, colors change immediately
|
||||
- A confirmation message appears: "✓ Theme changed to [Theme Name]"
|
||||
- The new theme persists across different views
|
||||
|
||||
3. **Configuration**:
|
||||
- Theme changes are logged (check terminal output)
|
||||
- To persist themes, add to config.toml:
|
||||
```toml
|
||||
[tui.theme]
|
||||
name = "carbon-night" # or any other theme name
|
||||
```
|
||||
|
||||
### Features Implemented
|
||||
|
||||
- ✅ Complete theme system with 9 predefined themes
|
||||
- ✅ Dynamic theme switching during active sessions
|
||||
- ✅ `/theme` slash command
|
||||
- ✅ Interactive theme selection UI
|
||||
- ✅ Immediate visual feedback
|
||||
- ✅ Theme configuration in config.toml
|
||||
- ✅ Custom color overrides support
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
- **Theme Module** (`tui/src/theme.rs`): Core theme definitions and switching logic
|
||||
- **Colors Module** (`tui/src/colors.rs`): Dynamic color functions using current theme
|
||||
- **Theme Selection View** (`tui/src/bottom_pane/theme_selection_view.rs`): Interactive UI
|
||||
- **Config Types** (`core/src/config_types.rs`): Theme configuration structures
|
||||
- **Slash Command** (`tui/src/slash_command.rs`): `/theme` command integration
|
||||
Reference in New Issue
Block a user