feat(ui): add configurable role label display and syntax highlighting support

- Introduce `RoleLabelDisplay` enum (inline, above, none) and integrate it into UI rendering and message formatting.
- Replace `show_role_labels` boolean with `role_label_mode` across config, formatter, session, and TUI components.
- Add `syntax_highlighting` boolean to UI settings with default `false` and support in message rendering.
- Update configuration schema version to 1.3.0 and provide deserialization handling for legacy boolean values.
- Extend theme definitions with code block styling fields (background, border, text, keyword, string, comment) and default values in `Theme`.
- Adjust related modules (`formatting.rs`, `ui.rs`, `session.rs`, `chat_app.rs`) to use the new settings and theme fields.
This commit is contained in:
2025-10-12 16:44:53 +02:00
parent ae9c3af096
commit 55e6b0583d
22 changed files with 1484 additions and 140 deletions

View File

@@ -1,8 +1,11 @@
use crate::ProviderConfig; use crate::ProviderConfig;
use crate::Result; use crate::Result;
use crate::mode::ModeConfig; use crate::mode::ModeConfig;
use crate::ui::RoleLabelDisplay;
use serde::de::{self, Deserializer, Visitor};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
@@ -11,7 +14,7 @@ use std::time::Duration;
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
/// Current schema version written to `config.toml`. /// Current schema version written to `config.toml`.
pub const CONFIG_SCHEMA_VERSION: &str = "1.2.0"; pub const CONFIG_SCHEMA_VERSION: &str = "1.3.0";
/// Core configuration shared by all OWLEN clients /// Core configuration shared by all OWLEN clients
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -700,8 +703,13 @@ pub struct UiSettings {
pub word_wrap: bool, pub word_wrap: bool,
#[serde(default = "UiSettings::default_max_history_lines")] #[serde(default = "UiSettings::default_max_history_lines")]
pub max_history_lines: usize, pub max_history_lines: usize,
#[serde(default = "UiSettings::default_show_role_labels")] #[serde(
pub show_role_labels: bool, rename = "role_label",
alias = "show_role_labels",
default = "UiSettings::default_role_label_mode",
deserialize_with = "UiSettings::deserialize_role_label_mode"
)]
pub role_label_mode: RoleLabelDisplay,
#[serde(default = "UiSettings::default_wrap_column")] #[serde(default = "UiSettings::default_wrap_column")]
pub wrap_column: u16, pub wrap_column: u16,
#[serde(default = "UiSettings::default_show_onboarding")] #[serde(default = "UiSettings::default_show_onboarding")]
@@ -712,6 +720,8 @@ pub struct UiSettings {
pub scrollback_lines: usize, pub scrollback_lines: usize,
#[serde(default = "UiSettings::default_show_cursor_outside_insert")] #[serde(default = "UiSettings::default_show_cursor_outside_insert")]
pub show_cursor_outside_insert: bool, pub show_cursor_outside_insert: bool,
#[serde(default = "UiSettings::default_syntax_highlighting")]
pub syntax_highlighting: bool,
} }
impl UiSettings { impl UiSettings {
@@ -727,8 +737,8 @@ impl UiSettings {
2000 2000
} }
fn default_show_role_labels() -> bool { const fn default_role_label_mode() -> RoleLabelDisplay {
true RoleLabelDisplay::Above
} }
fn default_wrap_column() -> u16 { fn default_wrap_column() -> u16 {
@@ -750,6 +760,62 @@ impl UiSettings {
const fn default_show_cursor_outside_insert() -> bool { const fn default_show_cursor_outside_insert() -> bool {
false false
} }
const fn default_syntax_highlighting() -> bool {
false
}
fn deserialize_role_label_mode<'de, D>(
deserializer: D,
) -> std::result::Result<RoleLabelDisplay, D::Error>
where
D: Deserializer<'de>,
{
struct RoleLabelModeVisitor;
impl<'de> Visitor<'de> for RoleLabelModeVisitor {
type Value = RoleLabelDisplay;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("`inline`, `above`, `none`, or a legacy boolean")
}
fn visit_str<E>(self, v: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
match v.trim().to_ascii_lowercase().as_str() {
"inline" => Ok(RoleLabelDisplay::Inline),
"above" => Ok(RoleLabelDisplay::Above),
"none" => Ok(RoleLabelDisplay::None),
other => Err(de::Error::unknown_variant(
other,
&["inline", "above", "none"],
)),
}
}
fn visit_string<E>(self, v: String) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&v)
}
fn visit_bool<E>(self, v: bool) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
if v {
Ok(RoleLabelDisplay::Above)
} else {
Ok(RoleLabelDisplay::None)
}
}
}
deserializer.deserialize_any(RoleLabelModeVisitor)
}
} }
impl Default for UiSettings { impl Default for UiSettings {
@@ -758,12 +824,13 @@ impl Default for UiSettings {
theme: Self::default_theme(), theme: Self::default_theme(),
word_wrap: Self::default_word_wrap(), word_wrap: Self::default_word_wrap(),
max_history_lines: Self::default_max_history_lines(), max_history_lines: Self::default_max_history_lines(),
show_role_labels: Self::default_show_role_labels(), role_label_mode: Self::default_role_label_mode(),
wrap_column: Self::default_wrap_column(), wrap_column: Self::default_wrap_column(),
show_onboarding: Self::default_show_onboarding(), show_onboarding: Self::default_show_onboarding(),
input_max_rows: Self::default_input_max_rows(), input_max_rows: Self::default_input_max_rows(),
scrollback_lines: Self::default_scrollback_lines(), scrollback_lines: Self::default_scrollback_lines(),
show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(),
syntax_highlighting: Self::default_syntax_highlighting(),
} }
} }
} }
@@ -919,7 +986,9 @@ mod tests {
#[test] #[test]
fn expand_provider_env_vars_resolves_api_key() { fn expand_provider_env_vars_resolves_api_key() {
unsafe {
std::env::set_var("OWLEN_TEST_API_KEY", "super-secret"); std::env::set_var("OWLEN_TEST_API_KEY", "super-secret");
}
let mut config = Config::default(); let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") { if let Some(ollama) = config.providers.get_mut("ollama") {
@@ -935,12 +1004,16 @@ mod tests {
Some("super-secret") Some("super-secret")
); );
unsafe {
std::env::remove_var("OWLEN_TEST_API_KEY"); std::env::remove_var("OWLEN_TEST_API_KEY");
} }
}
#[test] #[test]
fn expand_provider_env_vars_errors_for_missing_variable() { fn expand_provider_env_vars_errors_for_missing_variable() {
unsafe {
std::env::remove_var("OWLEN_TEST_MISSING"); std::env::remove_var("OWLEN_TEST_MISSING");
}
let mut config = Config::default(); let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") { if let Some(ollama) = config.providers.get_mut("ollama") {

View File

@@ -1,19 +1,20 @@
use crate::types::Message; use crate::types::Message;
use crate::ui::RoleLabelDisplay;
/// Formats messages for display across different clients. /// Formats messages for display across different clients.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MessageFormatter { pub struct MessageFormatter {
wrap_width: usize, wrap_width: usize,
show_role_labels: bool, role_label_mode: RoleLabelDisplay,
preserve_empty_lines: bool, preserve_empty_lines: bool,
} }
impl MessageFormatter { impl MessageFormatter {
/// Create a new formatter /// Create a new formatter
pub fn new(wrap_width: usize, show_role_labels: bool) -> Self { pub fn new(wrap_width: usize, role_label_mode: RoleLabelDisplay) -> Self {
Self { Self {
wrap_width: wrap_width.max(20), wrap_width: wrap_width.max(20),
show_role_labels, role_label_mode,
preserve_empty_lines: false, preserve_empty_lines: false,
} }
} }
@@ -29,9 +30,19 @@ impl MessageFormatter {
self.wrap_width = width.max(20); self.wrap_width = width.max(20);
} }
/// Whether role labels should be shown alongside messages /// The configured role label layout preference.
pub fn role_label_mode(&self) -> RoleLabelDisplay {
self.role_label_mode
}
/// Whether any role label should be shown alongside messages.
pub fn show_role_labels(&self) -> bool { pub fn show_role_labels(&self) -> bool {
self.show_role_labels !matches!(self.role_label_mode, RoleLabelDisplay::None)
}
/// Update the role label layout preference.
pub fn set_role_label_mode(&mut self, mode: RoleLabelDisplay) {
self.role_label_mode = mode;
} }
pub fn format_message(&self, message: &Message) -> Vec<String> { pub fn format_message(&self, message: &Message) -> Vec<String> {

View File

@@ -1031,13 +1031,17 @@ mod tests {
#[test] #[test]
fn resolve_api_key_expands_env_var() { fn resolve_api_key_expands_env_var() {
unsafe {
std::env::set_var("OLLAMA_TEST_KEY", "secret"); std::env::set_var("OLLAMA_TEST_KEY", "secret");
}
assert_eq!( assert_eq!(
resolve_api_key(Some("${OLLAMA_TEST_KEY}".into())), resolve_api_key(Some("${OLLAMA_TEST_KEY}".into())),
Some("secret".into()) Some("secret".into())
); );
unsafe {
std::env::remove_var("OLLAMA_TEST_KEY"); std::env::remove_var("OLLAMA_TEST_KEY");
} }
}
#[test] #[test]
fn normalize_base_url_removes_api_path() { fn normalize_base_url_removes_api_path() {

View File

@@ -16,7 +16,7 @@ use crate::storage::{SessionMeta, StorageManager};
use crate::types::{ use crate::types::{
ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall, ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall,
}; };
use crate::ui::UiController; use crate::ui::{RoleLabelDisplay, UiController};
use crate::validation::{SchemaValidator, get_builtin_schemas}; use crate::validation::{SchemaValidator, get_builtin_schemas};
use crate::{ChatStream, Provider}; use crate::{ChatStream, Provider};
use crate::{ use crate::{
@@ -264,7 +264,7 @@ impl SessionController {
); );
let formatter = MessageFormatter::new( let formatter = MessageFormatter::new(
config_guard.ui.wrap_column as usize, config_guard.ui.wrap_column as usize,
config_guard.ui.show_role_labels, config_guard.ui.role_label_mode,
) )
.with_preserve_empty(config_guard.ui.word_wrap); .with_preserve_empty(config_guard.ui.word_wrap);
let input_buffer = InputBuffer::new( let input_buffer = InputBuffer::new(
@@ -351,6 +351,10 @@ impl SessionController {
self.formatter.set_wrap_width(width); self.formatter.set_wrap_width(width);
} }
pub fn set_role_label_mode(&mut self, mode: RoleLabelDisplay) {
self.formatter.set_role_label_mode(mode);
}
// Asynchronous access to the configuration (used internally). // Asynchronous access to the configuration (used internally).
pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, Config> { pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, Config> {
self.config.lock().await self.config.lock().await

View File

@@ -116,6 +116,42 @@ pub struct Theme {
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
pub cursor: Color, pub cursor: Color,
/// Code block background color
#[serde(default = "Theme::default_code_block_background")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub code_block_background: Color,
/// Code block border color
#[serde(default = "Theme::default_code_block_border")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub code_block_border: Color,
/// Code block text color
#[serde(default = "Theme::default_code_block_text")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub code_block_text: Color,
/// Code block keyword color
#[serde(default = "Theme::default_code_block_keyword")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub code_block_keyword: Color,
/// Code block string literal color
#[serde(default = "Theme::default_code_block_string")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub code_block_string: Color,
/// Code block comment color
#[serde(default = "Theme::default_code_block_comment")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub code_block_comment: Color,
/// Placeholder text color /// Placeholder text color
#[serde(deserialize_with = "deserialize_color")] #[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
@@ -217,6 +253,30 @@ impl Default for Theme {
} }
impl Theme { impl Theme {
const fn default_code_block_background() -> Color {
Color::Black
}
const fn default_code_block_border() -> Color {
Color::Gray
}
const fn default_code_block_text() -> Color {
Color::White
}
const fn default_code_block_keyword() -> Color {
Color::Yellow
}
const fn default_code_block_string() -> Color {
Color::LightGreen
}
const fn default_code_block_comment() -> Color {
Color::DarkGray
}
const fn default_agent_thought() -> Color { const fn default_agent_thought() -> Color {
Color::LightBlue Color::LightBlue
} }
@@ -430,6 +490,12 @@ fn default_dark() -> Theme {
selection_bg: Color::LightBlue, selection_bg: Color::LightBlue,
selection_fg: Color::Black, selection_fg: Color::Black,
cursor: Color::Magenta, cursor: Color::Magenta,
code_block_background: Color::Rgb(25, 25, 25),
code_block_border: Color::LightMagenta,
code_block_text: Color::White,
code_block_keyword: Color::Yellow,
code_block_string: Color::LightGreen,
code_block_comment: Color::Gray,
placeholder: Color::DarkGray, placeholder: Color::DarkGray,
error: Color::Red, error: Color::Red,
info: Color::LightGreen, info: Color::LightGreen,
@@ -473,6 +539,12 @@ fn default_light() -> Theme {
selection_bg: Color::Rgb(164, 200, 240), selection_bg: Color::Rgb(164, 200, 240),
selection_fg: Color::Black, selection_fg: Color::Black,
cursor: Color::Rgb(217, 95, 2), cursor: Color::Rgb(217, 95, 2),
code_block_background: Color::Rgb(245, 245, 245),
code_block_border: Color::Rgb(142, 68, 173),
code_block_text: Color::Black,
code_block_keyword: Color::Rgb(181, 137, 0),
code_block_string: Color::Rgb(46, 139, 87),
code_block_comment: Color::Gray,
placeholder: Color::Gray, placeholder: Color::Gray,
error: Color::Rgb(192, 57, 43), error: Color::Rgb(192, 57, 43),
info: Color::Green, info: Color::Green,
@@ -516,6 +588,12 @@ fn gruvbox() -> Theme {
selection_bg: Color::Rgb(80, 73, 69), selection_bg: Color::Rgb(80, 73, 69),
selection_fg: Color::Rgb(235, 219, 178), selection_fg: Color::Rgb(235, 219, 178),
cursor: Color::Rgb(254, 128, 25), cursor: Color::Rgb(254, 128, 25),
code_block_background: Color::Rgb(60, 56, 54),
code_block_border: Color::Rgb(124, 111, 100),
code_block_text: Color::Rgb(235, 219, 178),
code_block_keyword: Color::Rgb(250, 189, 47),
code_block_string: Color::Rgb(142, 192, 124),
code_block_comment: Color::Rgb(124, 111, 100),
placeholder: Color::Rgb(102, 92, 84), placeholder: Color::Rgb(102, 92, 84),
error: Color::Rgb(251, 73, 52), // #fb4934 error: Color::Rgb(251, 73, 52), // #fb4934
info: Color::Rgb(184, 187, 38), info: Color::Rgb(184, 187, 38),
@@ -559,6 +637,12 @@ fn dracula() -> Theme {
selection_bg: Color::Rgb(68, 71, 90), selection_bg: Color::Rgb(68, 71, 90),
selection_fg: Color::Rgb(248, 248, 242), selection_fg: Color::Rgb(248, 248, 242),
cursor: Color::Rgb(255, 121, 198), cursor: Color::Rgb(255, 121, 198),
code_block_background: Color::Rgb(68, 71, 90),
code_block_border: Color::Rgb(189, 147, 249),
code_block_text: Color::Rgb(248, 248, 242),
code_block_keyword: Color::Rgb(255, 121, 198),
code_block_string: Color::Rgb(80, 250, 123),
code_block_comment: Color::Rgb(98, 114, 164),
placeholder: Color::Rgb(98, 114, 164), placeholder: Color::Rgb(98, 114, 164),
error: Color::Rgb(255, 85, 85), // #ff5555 error: Color::Rgb(255, 85, 85), // #ff5555
info: Color::Rgb(80, 250, 123), info: Color::Rgb(80, 250, 123),
@@ -602,6 +686,12 @@ fn solarized() -> Theme {
selection_bg: Color::Rgb(7, 54, 66), selection_bg: Color::Rgb(7, 54, 66),
selection_fg: Color::Rgb(147, 161, 161), selection_fg: Color::Rgb(147, 161, 161),
cursor: Color::Rgb(211, 54, 130), cursor: Color::Rgb(211, 54, 130),
code_block_background: Color::Rgb(7, 54, 66),
code_block_border: Color::Rgb(38, 139, 210),
code_block_text: Color::Rgb(147, 161, 161),
code_block_keyword: Color::Rgb(181, 137, 0),
code_block_string: Color::Rgb(133, 153, 0),
code_block_comment: Color::Rgb(88, 110, 117),
placeholder: Color::Rgb(88, 110, 117), placeholder: Color::Rgb(88, 110, 117),
error: Color::Rgb(220, 50, 47), // #dc322f (red) error: Color::Rgb(220, 50, 47), // #dc322f (red)
info: Color::Rgb(133, 153, 0), info: Color::Rgb(133, 153, 0),
@@ -645,6 +735,12 @@ fn midnight_ocean() -> Theme {
selection_bg: Color::Rgb(56, 139, 253), selection_bg: Color::Rgb(56, 139, 253),
selection_fg: Color::Rgb(13, 17, 23), selection_fg: Color::Rgb(13, 17, 23),
cursor: Color::Rgb(246, 140, 245), cursor: Color::Rgb(246, 140, 245),
code_block_background: Color::Rgb(22, 27, 34),
code_block_border: Color::Rgb(88, 166, 255),
code_block_text: Color::Rgb(192, 202, 245),
code_block_keyword: Color::Rgb(255, 212, 59),
code_block_string: Color::Rgb(158, 206, 106),
code_block_comment: Color::Rgb(110, 118, 129),
placeholder: Color::Rgb(110, 118, 129), placeholder: Color::Rgb(110, 118, 129),
error: Color::Rgb(248, 81, 73), error: Color::Rgb(248, 81, 73),
info: Color::Rgb(158, 206, 106), info: Color::Rgb(158, 206, 106),
@@ -688,6 +784,12 @@ fn rose_pine() -> Theme {
selection_bg: Color::Rgb(64, 61, 82), selection_bg: Color::Rgb(64, 61, 82),
selection_fg: Color::Rgb(224, 222, 244), selection_fg: Color::Rgb(224, 222, 244),
cursor: Color::Rgb(235, 111, 146), cursor: Color::Rgb(235, 111, 146),
code_block_background: Color::Rgb(38, 35, 58),
code_block_border: Color::Rgb(235, 111, 146),
code_block_text: Color::Rgb(224, 222, 244),
code_block_keyword: Color::Rgb(246, 193, 119),
code_block_string: Color::Rgb(156, 207, 216),
code_block_comment: Color::Rgb(110, 106, 134),
placeholder: Color::Rgb(110, 106, 134), placeholder: Color::Rgb(110, 106, 134),
error: Color::Rgb(235, 111, 146), error: Color::Rgb(235, 111, 146),
info: Color::Rgb(156, 207, 216), info: Color::Rgb(156, 207, 216),
@@ -731,6 +833,12 @@ fn monokai() -> Theme {
selection_bg: Color::Rgb(117, 113, 94), selection_bg: Color::Rgb(117, 113, 94),
selection_fg: Color::Rgb(248, 248, 242), selection_fg: Color::Rgb(248, 248, 242),
cursor: Color::Rgb(249, 38, 114), cursor: Color::Rgb(249, 38, 114),
code_block_background: Color::Rgb(50, 51, 46),
code_block_border: Color::Rgb(249, 38, 114),
code_block_text: Color::Rgb(248, 248, 242),
code_block_keyword: Color::Rgb(230, 219, 116),
code_block_string: Color::Rgb(166, 226, 46),
code_block_comment: Color::Rgb(117, 113, 94),
placeholder: Color::Rgb(117, 113, 94), placeholder: Color::Rgb(117, 113, 94),
error: Color::Rgb(249, 38, 114), error: Color::Rgb(249, 38, 114),
info: Color::Rgb(166, 226, 46), info: Color::Rgb(166, 226, 46),
@@ -774,6 +882,12 @@ fn material_dark() -> Theme {
selection_bg: Color::Rgb(84, 110, 122), selection_bg: Color::Rgb(84, 110, 122),
selection_fg: Color::Rgb(238, 255, 255), selection_fg: Color::Rgb(238, 255, 255),
cursor: Color::Rgb(255, 204, 0), cursor: Color::Rgb(255, 204, 0),
code_block_background: Color::Rgb(33, 43, 48),
code_block_border: Color::Rgb(128, 203, 196),
code_block_text: Color::Rgb(238, 255, 255),
code_block_keyword: Color::Rgb(255, 203, 107),
code_block_string: Color::Rgb(195, 232, 141),
code_block_comment: Color::Rgb(84, 110, 122),
placeholder: Color::Rgb(84, 110, 122), placeholder: Color::Rgb(84, 110, 122),
error: Color::Rgb(240, 113, 120), error: Color::Rgb(240, 113, 120),
info: Color::Rgb(195, 232, 141), info: Color::Rgb(195, 232, 141),
@@ -817,6 +931,12 @@ fn material_light() -> Theme {
selection_bg: Color::Rgb(176, 190, 197), selection_bg: Color::Rgb(176, 190, 197),
selection_fg: Color::Rgb(33, 33, 33), selection_fg: Color::Rgb(33, 33, 33),
cursor: Color::Rgb(194, 24, 91), cursor: Color::Rgb(194, 24, 91),
code_block_background: Color::Rgb(248, 249, 250),
code_block_border: Color::Rgb(0, 150, 136),
code_block_text: Color::Rgb(33, 33, 33),
code_block_keyword: Color::Rgb(245, 124, 0),
code_block_string: Color::Rgb(56, 142, 60),
code_block_comment: Color::Rgb(144, 164, 174),
placeholder: Color::Rgb(144, 164, 174), placeholder: Color::Rgb(144, 164, 174),
error: Color::Rgb(211, 47, 47), error: Color::Rgb(211, 47, 47),
info: Color::Rgb(56, 142, 60), info: Color::Rgb(56, 142, 60),
@@ -860,6 +980,12 @@ fn grayscale_high_contrast() -> Theme {
selection_bg: Color::Rgb(240, 240, 240), selection_bg: Color::Rgb(240, 240, 240),
selection_fg: Color::Black, selection_fg: Color::Black,
cursor: Color::White, cursor: Color::White,
code_block_background: Color::Rgb(15, 15, 15),
code_block_border: Color::White,
code_block_text: Color::Rgb(247, 247, 247),
code_block_keyword: Color::Rgb(204, 204, 204),
code_block_string: Color::Rgb(214, 214, 214),
code_block_comment: Color::Rgb(122, 122, 122),
placeholder: Color::Rgb(122, 122, 122), placeholder: Color::Rgb(122, 122, 122),
error: Color::White, error: Color::White,
info: Color::Rgb(200, 200, 200), info: Color::Rgb(200, 200, 200),

View File

@@ -18,6 +18,17 @@ pub use crate::state::AutoScroll;
/// Visual selection state for text selection /// Visual selection state for text selection
pub use crate::state::VisualSelection; pub use crate::state::VisualSelection;
use serde::{Deserialize, Serialize};
/// How role labels should be rendered alongside chat messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RoleLabelDisplay {
Inline,
Above,
None,
}
/// Extract text from a selection range in a list of lines /// Extract text from a selection range in a list of lines
pub fn extract_text_from_selection( pub fn extract_text_from_selection(
lines: &[String], lines: &[String],

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelect
use owlen_core::model::DetailedModelInfo; use owlen_core::model::DetailedModelInfo;
use owlen_core::theme::Theme; use owlen_core::theme::Theme;
use owlen_core::types::{ModelInfo, Role}; use owlen_core::types::{ModelInfo, Role};
use owlen_core::ui::{FocusedPanel, InputMode}; use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay};
const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1;
@@ -687,7 +687,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
// Build the lines for messages using cached rendering // Build the lines for messages using cached rendering
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
let show_role_labels = formatter.show_role_labels(); let role_label_mode = formatter.role_label_mode();
for message_index in 0..total_messages { for message_index in 0..total_messages {
let is_streaming = { let is_streaming = {
let conversation = app.conversation(); let conversation = app.conversation();
@@ -701,12 +701,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
message_index, message_index,
MessageRenderContext::new( MessageRenderContext::new(
&mut formatter, &mut formatter,
show_role_labels, role_label_mode,
content_width as usize, content_width as usize,
message_index + 1 == total_messages, message_index + 1 == total_messages,
is_streaming, is_streaming,
app.get_loading_indicator(), app.get_loading_indicator(),
&theme, &theme,
app.should_highlight_code(),
), ),
); );
lines.extend(message_lines); lines.extend(message_lines);
@@ -729,6 +730,37 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
}; };
if app.get_loading_indicator() != "" && last_message_is_user { if app.get_loading_indicator() != "" && last_message_is_user {
match role_label_mode {
RoleLabelDisplay::Inline => {
let (emoji, title) = crate::chat_app::role_label_parts(&Role::Assistant);
let inline_label = format!("{emoji} {title}:");
let label_width = UnicodeWidthStr::width(inline_label.as_str());
let max_label_width = crate::chat_app::max_inline_label_width();
let padding = max_label_width.saturating_sub(label_width);
let mut loading_spans = vec![
Span::raw(format!("{emoji} ")),
Span::styled(
format!("{title}:"),
Style::default()
.fg(theme.assistant_message_role)
.add_modifier(Modifier::BOLD),
),
];
if padding > 0 {
loading_spans.push(Span::raw(" ".repeat(padding)));
}
loading_spans.push(Span::raw(" "));
loading_spans.push(Span::styled(
app.get_loading_indicator().to_string(),
Style::default().fg(theme.info),
));
lines.push(Line::from(loading_spans));
}
_ => {
let loading_spans = vec![ let loading_spans = vec![
Span::raw("🤖 "), Span::raw("🤖 "),
Span::styled( Span::styled(
@@ -744,6 +776,8 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
]; ];
lines.push(Line::from(loading_spans)); lines.push(Line::from(loading_spans));
} }
}
}
if lines.is_empty() { if lines.is_empty() {
lines.push(Line::from("No messages yet. Press 'i' to start typing.")); lines.push(Line::from("No messages yet. Press 'i' to start typing."));

View File

@@ -58,8 +58,8 @@ These settings customize the look and feel of the terminal interface.
- `max_history_lines` (integer, default: `2000`) - `max_history_lines` (integer, default: `2000`)
The maximum number of lines to keep in the scrollback buffer for the chat history. The maximum number of lines to keep in the scrollback buffer for the chat history.
- `show_role_labels` (boolean, default: `true`) - `role_label` (string, default: `"above"`)
Whether to show the `user` and `bot` role labels next to messages. Controls how sender labels are rendered next to messages. Valid values are `"above"` (label on its own line), `"inline"` (label shares the first line of the message), and `"none"` (no label).
- `wrap_column` (integer, default: `100`) - `wrap_column` (integer, default: `100`)
The column at which to wrap text if `word_wrap` is enabled. The column at which to wrap text if `word_wrap` is enabled.
@@ -70,6 +70,9 @@ These settings customize the look and feel of the terminal interface.
- `scrollback_lines` (integer, default: `2000`) - `scrollback_lines` (integer, default: `2000`)
The maximum number of rendered lines the chat view keeps in memory. Set to `0` to disable trimming entirely if you prefer unlimited history. The maximum number of rendered lines the chat view keeps in memory. Set to `0` to disable trimming entirely if you prefer unlimited history.
- `syntax_highlighting` (boolean, default: `false`)
Enables lightweight syntax highlighting inside fenced code blocks when the terminal supports 256-color output.
## Storage Settings (`[storage]`) ## Storage Settings (`[storage]`)
These settings control how conversations are saved and loaded. These settings control how conversations are saved and loaded.

View File

@@ -55,6 +55,14 @@ selection_bg = "#0000ff" # Selection background
selection_fg = "#ffffff" # Selection foreground selection_fg = "#ffffff" # Selection foreground
cursor = "#ff0080" # Cursor color cursor = "#ff0080" # Cursor color
# Code block styling
code_block_background = "#111111"
code_block_border = "#ff00ff"
code_block_text = "#ffffff"
code_block_keyword = "#ffff00"
code_block_string = "#00ff00"
code_block_comment = "#808080"
# Status colors # Status colors
error = "#ff0000" # Error messages error = "#ff0000" # Error messages
info = "#00ff00" # Info/success messages info = "#00ff00" # Info/success messages

View File

@@ -19,6 +19,12 @@ mode_command = "yellow"
selection_bg = "blue" selection_bg = "blue"
selection_fg = "white" selection_fg = "white"
cursor = "white" cursor = "white"
code_block_background = "black"
code_block_border = "cyan"
code_block_text = "white"
code_block_keyword = "yellow"
code_block_string = "green"
code_block_comment = "darkgray"
placeholder = "darkgray" placeholder = "darkgray"
error = "red" error = "red"
info = "green" info = "green"

View File

@@ -19,6 +19,12 @@ mode_command = "yellow"
selection_bg = "lightblue" selection_bg = "lightblue"
selection_fg = "black" selection_fg = "black"
cursor = "magenta" cursor = "magenta"
code_block_background = "#191919"
code_block_border = "lightmagenta"
code_block_text = "white"
code_block_keyword = "yellow"
code_block_string = "lightgreen"
code_block_comment = "gray"
placeholder = "darkgray" placeholder = "darkgray"
error = "red" error = "red"
info = "lightgreen" info = "lightgreen"

View File

@@ -19,6 +19,12 @@ mode_command = "#b58900"
selection_bg = "#a4c8f0" selection_bg = "#a4c8f0"
selection_fg = "black" selection_fg = "black"
cursor = "#d95f02" cursor = "#d95f02"
code_block_background = "#f5f5f5"
code_block_border = "#009688"
code_block_text = "black"
code_block_keyword = "#b58900"
code_block_string = "#388e3c"
code_block_comment = "#90a4ae"
placeholder = "gray" placeholder = "gray"
error = "#c0392b" error = "#c0392b"
info = "green" info = "green"

View File

@@ -19,6 +19,12 @@ mode_command = "#f1fa8c"
selection_bg = "#44475a" selection_bg = "#44475a"
selection_fg = "#f8f8f2" selection_fg = "#f8f8f2"
cursor = "#ff79c6" cursor = "#ff79c6"
code_block_background = "#44475a"
code_block_border = "#bd93f9"
code_block_text = "#f8f8f2"
code_block_keyword = "#ff79c6"
code_block_string = "#50fa7b"
code_block_comment = "#6272a4"
placeholder = "#6272a4" placeholder = "#6272a4"
error = "#ff5555" error = "#ff5555"
info = "#50fa7b" info = "#50fa7b"

View File

@@ -19,6 +19,12 @@ mode_command = "#d0d0d0"
selection_bg = "#f0f0f0" selection_bg = "#f0f0f0"
selection_fg = "#000000" selection_fg = "#000000"
cursor = "#ffffff" cursor = "#ffffff"
code_block_background = "#0f0f0f"
code_block_border = "#ffffff"
code_block_text = "#f7f7f7"
code_block_keyword = "#cccccc"
code_block_string = "#d6d6d6"
code_block_comment = "#7a7a7a"
placeholder = "#7a7a7a" placeholder = "#7a7a7a"
error = "#ffffff" error = "#ffffff"
info = "#c8c8c8" info = "#c8c8c8"

View File

@@ -19,6 +19,12 @@ mode_command = "#fabd2f"
selection_bg = "#504945" selection_bg = "#504945"
selection_fg = "#ebdbb2" selection_fg = "#ebdbb2"
cursor = "#fe8019" cursor = "#fe8019"
code_block_background = "#3c3836"
code_block_border = "#7c6f64"
code_block_text = "#ebdbb2"
code_block_keyword = "#fabd2f"
code_block_string = "#8ec07c"
code_block_comment = "#7c6f64"
placeholder = "#665c54" placeholder = "#665c54"
error = "#fb4934" error = "#fb4934"
info = "#b8bb26" info = "#b8bb26"

View File

@@ -19,6 +19,12 @@ mode_command = "#ffcb6b"
selection_bg = "#546e7a" selection_bg = "#546e7a"
selection_fg = "#eeffff" selection_fg = "#eeffff"
cursor = "#ffcc00" cursor = "#ffcc00"
code_block_background = "#212b30"
code_block_border = "#80cbc4"
code_block_text = "#eeffff"
code_block_keyword = "#ffcb6b"
code_block_string = "#c3e88d"
code_block_comment = "#546e7a"
placeholder = "#546e7a" placeholder = "#546e7a"
error = "#f07178" error = "#f07178"
info = "#c3e88d" info = "#c3e88d"

View File

@@ -19,6 +19,12 @@ mode_command = "#f57c00"
selection_bg = "#b0bec5" selection_bg = "#b0bec5"
selection_fg = "#212121" selection_fg = "#212121"
cursor = "#c2185b" cursor = "#c2185b"
code_block_background = "#f8f9fa"
code_block_border = "#009688"
code_block_text = "#212121"
code_block_keyword = "#f57c00"
code_block_string = "#388e3c"
code_block_comment = "#90a4ae"
placeholder = "#90a4ae" placeholder = "#90a4ae"
error = "#d32f2f" error = "#d32f2f"
info = "#388e3c" info = "#388e3c"

View File

@@ -19,6 +19,12 @@ mode_command = "#ffd43b"
selection_bg = "#388bfd" selection_bg = "#388bfd"
selection_fg = "#0d1117" selection_fg = "#0d1117"
cursor = "#f68cf5" cursor = "#f68cf5"
code_block_background = "#161b22"
code_block_border = "#58a6ff"
code_block_text = "#c0caf5"
code_block_keyword = "#ffd43b"
code_block_string = "#9ece6a"
code_block_comment = "#6e7681"
placeholder = "#6e7681" placeholder = "#6e7681"
error = "#f85149" error = "#f85149"
info = "#9ece6a" info = "#9ece6a"

View File

@@ -19,6 +19,12 @@ mode_command = "#e6db74"
selection_bg = "#75715e" selection_bg = "#75715e"
selection_fg = "#f8f8f2" selection_fg = "#f8f8f2"
cursor = "#f92672" cursor = "#f92672"
code_block_background = "#32332e"
code_block_border = "#f92672"
code_block_text = "#f8f8f2"
code_block_keyword = "#e6db74"
code_block_string = "#a6e22e"
code_block_comment = "#75715e"
placeholder = "#75715e" placeholder = "#75715e"
error = "#f92672" error = "#f92672"
info = "#a6e22e" info = "#a6e22e"

View File

@@ -19,6 +19,12 @@ mode_command = "#f6c177"
selection_bg = "#403d52" selection_bg = "#403d52"
selection_fg = "#e0def4" selection_fg = "#e0def4"
cursor = "#eb6f92" cursor = "#eb6f92"
code_block_background = "#26233a"
code_block_border = "#eb6f92"
code_block_text = "#e0def4"
code_block_keyword = "#f6c177"
code_block_string = "#9ccfd8"
code_block_comment = "#6e6a86"
placeholder = "#6e6a86" placeholder = "#6e6a86"
error = "#eb6f92" error = "#eb6f92"
info = "#9ccfd8" info = "#9ccfd8"

View File

@@ -19,6 +19,12 @@ mode_command = "#b58900"
selection_bg = "#073642" selection_bg = "#073642"
selection_fg = "#93a1a1" selection_fg = "#93a1a1"
cursor = "#d33682" cursor = "#d33682"
code_block_background = "#073642"
code_block_border = "#268bd2"
code_block_text = "#93a1a1"
code_block_keyword = "#b58900"
code_block_string = "#859900"
code_block_comment = "#586e75"
placeholder = "#586e75" placeholder = "#586e75"
error = "#dc322f" error = "#dc322f"
info = "#859900" info = "#859900"