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::Result;
use crate::mode::ModeConfig;
use crate::ui::RoleLabelDisplay;
use serde::de::{self, Deserializer, Visitor};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -11,7 +14,7 @@ use std::time::Duration;
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/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
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -700,8 +703,13 @@ pub struct UiSettings {
pub word_wrap: bool,
#[serde(default = "UiSettings::default_max_history_lines")]
pub max_history_lines: usize,
#[serde(default = "UiSettings::default_show_role_labels")]
pub show_role_labels: bool,
#[serde(
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")]
pub wrap_column: u16,
#[serde(default = "UiSettings::default_show_onboarding")]
@@ -712,6 +720,8 @@ pub struct UiSettings {
pub scrollback_lines: usize,
#[serde(default = "UiSettings::default_show_cursor_outside_insert")]
pub show_cursor_outside_insert: bool,
#[serde(default = "UiSettings::default_syntax_highlighting")]
pub syntax_highlighting: bool,
}
impl UiSettings {
@@ -727,8 +737,8 @@ impl UiSettings {
2000
}
fn default_show_role_labels() -> bool {
true
const fn default_role_label_mode() -> RoleLabelDisplay {
RoleLabelDisplay::Above
}
fn default_wrap_column() -> u16 {
@@ -750,6 +760,62 @@ impl UiSettings {
const fn default_show_cursor_outside_insert() -> bool {
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 {
@@ -758,12 +824,13 @@ impl Default for UiSettings {
theme: Self::default_theme(),
word_wrap: Self::default_word_wrap(),
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(),
show_onboarding: Self::default_show_onboarding(),
input_max_rows: Self::default_input_max_rows(),
scrollback_lines: Self::default_scrollback_lines(),
show_cursor_outside_insert: Self::default_show_cursor_outside_insert(),
syntax_highlighting: Self::default_syntax_highlighting(),
}
}
}
@@ -919,7 +986,9 @@ mod tests {
#[test]
fn expand_provider_env_vars_resolves_api_key() {
std::env::set_var("OWLEN_TEST_API_KEY", "super-secret");
unsafe {
std::env::set_var("OWLEN_TEST_API_KEY", "super-secret");
}
let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") {
@@ -935,12 +1004,16 @@ mod tests {
Some("super-secret")
);
std::env::remove_var("OWLEN_TEST_API_KEY");
unsafe {
std::env::remove_var("OWLEN_TEST_API_KEY");
}
}
#[test]
fn expand_provider_env_vars_errors_for_missing_variable() {
std::env::remove_var("OWLEN_TEST_MISSING");
unsafe {
std::env::remove_var("OWLEN_TEST_MISSING");
}
let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") {