8 Commits

Author SHA1 Message Date
55e6b0583d 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.
2025-10-12 16:44:53 +02:00
ae9c3af096 feat(ui): add show_cursor_outside_insert setting and Unicode‑aware wrapping; introduce grayscale‑high‑contrast theme
- Added `show_cursor_outside_insert` (default false) to `UiSettings` and synced it from config.
- Cursor rendering now follows `cursor_should_be_visible`, allowing visibility outside insert mode based on the new setting.
- Replaced `textwrap::wrap` with `wrap_unicode`, which uses Unicode break properties for proper CJK and emoji handling.
- Added `grayscale-high-contrast.toml` theme, registered it in theme loading, and updated README and tests.
2025-10-12 15:47:22 +02:00
0bd560b408 feat(tui): display key hints in status bar and bind “?” to open help
- Add placeholder span showing shortcuts (i:Insert, m:Model, ?:Help, : Command) in the UI footer.
- Insert help section describing Enter key behavior in normal and insert modes.
- Extend F1 help shortcut to also trigger on “?” key (with no or Shift modifier).
2025-10-12 15:22:08 +02:00
083b621b7d feat(tui): replace hard‑coded colors with Theme values and propagate Theme through UI rendering
- Introduce `Theme` import and pass a cloned `theme` instance to UI helpers (e.g., `render_editable_textarea`).
- Remove direct `Color` usage; UI now derives colors from `Theme` fields for placeholders, selections, ReAct components (thought, action, input, observation, final answer), status badges, operating mode badges, and model info panel.
- Extend `Theme` with new color fields for agent ReAct stages, badge foreground/background, and operating mode colors.
- Update rendering logic to apply these theme colors throughout the TUI (input panel, help text, status lines, model selection UI, etc.).
- Adjust imports to drop unused `Color` references.
2025-10-12 15:16:20 +02:00
d2a193e5c1 feat(tui): cache rendered message lines and throttle streaming redraws to improve TUI responsiveness
- Introduce `MessageRenderContext` and `MessageCacheEntry` for caching wrapped lines per message.
- Implement `render_message_lines_cached` using cache, invalidating on updates.
- Add role/style helpers and content hashing for cache validation.
- Throttle UI redraws in the main loop during active streaming (50 ms interval) and adjust idle tick timing.
- Update drawing logic to use cached rendering and manage draw intervals.
- Remove unused `role_color` function and adjust imports accordingly.
2025-10-12 15:02:33 +02:00
acbfe47a4b feat(command-palette): add fuzzy model/provider filtering, expose ModelPaletteEntry, and show active model with provider in UI header
- Introduce `ModelPaletteEntry` and re‑export it for external use.
- Extend `CommandPalette` with dynamic sources (models, providers) and methods to refresh suggestions based on `:model` and `:provider` prefixes.
- Implement fuzzy matching via `match_score` and subsequence checks for richer suggestion ranking.
- Add `provider` command spec and completions.
- Update UI to display “Model (Provider)” in the header and use the new active model label helper.
- Wire catalog updates throughout `ChatApp` (model palette entries, command palette refresh on state changes, model picker integration).
2025-10-12 14:41:02 +02:00
60c859b3ab feat(ui): add configurable scrollback lines and new‑message alert badge
Introduce `ui.scrollback_lines` (default 2000) to cap the number of chat lines kept in memory, with `0` disabling trimming. Implement automatic trimming of older lines, maintain a scroll offset, and show a “↓ New messages (press G)” badge when new messages arrive off‑screen. Update core UI settings, TUI rendering, chat app state, migrations, documentation, and changelog to reflect the new feature.
2025-10-12 14:23:04 +02:00
82078afd6d feat(ui): add configurable input panel max rows and horizontal scrolling
- Introduce `ui.input_max_rows` (default 5) to control how many rows the input panel expands before scrolling.
- Bump `CONFIG_SCHEMA_VERSION` to **1.2.0** and update migration documentation.
- Update `configuration.md` and migration guide to describe the new setting.
- Adjust TUI height calculation to respect `input_max_rows` and add horizontal scrolling support for long lines.
- Add `unicode-segmentation` dependency for proper grapheme handling.
2025-10-12 14:06:10 +02:00
30 changed files with 2970 additions and 425 deletions

View File

@@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tabbed model selector that separates local and cloud providers, including cloud indicators in the UI.
- Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys).
- Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`.
- Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in.
- Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching.
- Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions.
- Chat history honors `ui.scrollback_lines`, trimming older rows to keep the TUI responsive and surfacing a "↓ New messages" badge whenever updates land off-screen.
### Changed
- The main `README.md` has been updated to be more concise and link to the new documentation.
@@ -31,8 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configuration loading performs structural validation and fails fast on missing default providers or invalid MCP definitions.
- Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures.
- `owlen` warns when the active terminal likely lacks 256-color support.
- `config.toml` now carries a schema version (`1.1.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures.
- `config.toml` now carries a schema version (`1.2.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures.
- Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state.
- Header displays the active model together with its provider (e.g., `Model (Provider)`), improving clarity when swapping backends.
---

View File

@@ -478,11 +478,25 @@ async fn run_app(
mut event_rx: mpsc::UnboundedReceiver<Event>,
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
) -> Result<()> {
let stream_draw_interval = tokio::time::Duration::from_millis(50);
let idle_tick = tokio::time::Duration::from_millis(100);
let mut last_draw = tokio::time::Instant::now() - stream_draw_interval;
loop {
// Advance loading animation frame
app.advance_loading_animation();
let streaming_active = app.streaming_count() > 0;
let draw_due = if streaming_active {
last_draw.elapsed() >= stream_draw_interval
} else {
true
};
if draw_due {
terminal.draw(|f| ui::render_chat(f, app))?;
last_draw = tokio::time::Instant::now();
}
// Process any pending LLM requests AFTER UI has been drawn
if let Err(e) = app.process_pending_llm_request().await {
@@ -494,6 +508,14 @@ async fn run_app(
eprintln!("Error processing tool execution: {}", e);
}
let sleep_duration = if streaming_active {
stream_draw_interval
.checked_sub(last_draw.elapsed())
.unwrap_or_else(|| tokio::time::Duration::from_millis(0))
} else {
idle_tick
};
tokio::select! {
Some(event) = event_rx.recv() => {
if let AppState::Quit = app.handle_event(event).await? {
@@ -503,10 +525,7 @@ async fn run_app(
Some(session_event) = session_rx.recv() => {
app.handle_session_event(session_event)?;
}
// Add a timeout to keep the animation going even when there are no events
_ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
// This will cause the loop to continue and advance the animation
}
_ = tokio::time::sleep(sleep_duration) => {}
}
}
}

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.1.0";
pub const CONFIG_SCHEMA_VERSION: &str = "1.3.0";
/// Core configuration shared by all OWLEN clients
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -700,12 +703,25 @@ 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")]
pub show_onboarding: bool,
#[serde(default = "UiSettings::default_input_max_rows")]
pub input_max_rows: u16,
#[serde(default = "UiSettings::default_scrollback_lines")]
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 {
@@ -721,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 {
@@ -732,6 +748,74 @@ impl UiSettings {
const fn default_show_onboarding() -> bool {
true
}
const fn default_input_max_rows() -> u16 {
5
}
const fn default_scrollback_lines() -> usize {
2000
}
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 {
@@ -740,9 +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(),
}
}
}
@@ -898,7 +986,9 @@ mod tests {
#[test]
fn expand_provider_env_vars_resolves_api_key() {
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") {
@@ -914,12 +1004,16 @@ mod tests {
Some("super-secret")
);
unsafe {
std::env::remove_var("OWLEN_TEST_API_KEY");
}
}
#[test]
fn expand_provider_env_vars_errors_for_missing_variable() {
unsafe {
std::env::remove_var("OWLEN_TEST_MISSING");
}
let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama") {

View File

@@ -1,19 +1,20 @@
use crate::types::Message;
use crate::ui::RoleLabelDisplay;
/// Formats messages for display across different clients.
#[derive(Debug, Clone)]
pub struct MessageFormatter {
wrap_width: usize,
show_role_labels: bool,
role_label_mode: RoleLabelDisplay,
preserve_empty_lines: bool,
}
impl MessageFormatter {
/// 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 {
wrap_width: wrap_width.max(20),
show_role_labels,
role_label_mode,
preserve_empty_lines: false,
}
}
@@ -29,9 +30,19 @@ impl MessageFormatter {
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 {
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> {

View File

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

View File

@@ -16,7 +16,7 @@ use crate::storage::{SessionMeta, StorageManager};
use crate::types::{
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::{ChatStream, Provider};
use crate::{
@@ -264,7 +264,7 @@ impl SessionController {
);
let formatter = MessageFormatter::new(
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);
let input_buffer = InputBuffer::new(
@@ -351,6 +351,10 @@ impl SessionController {
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).
pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, Config> {
self.config.lock().await

View File

@@ -8,6 +8,8 @@ use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub type ThemePalette = Theme;
/// A complete theme definition for OWLEN TUI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Theme {
@@ -114,6 +116,42 @@ pub struct Theme {
#[serde(serialize_with = "serialize_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
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
@@ -128,6 +166,84 @@ pub struct Theme {
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub info: Color,
/// Agent action coloring (ReAct THOUGHT)
#[serde(default = "Theme::default_agent_thought")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_thought: Color,
/// Agent action coloring (ReAct ACTION)
#[serde(default = "Theme::default_agent_action")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_action: Color,
/// Agent action coloring (ReAct ACTION_INPUT)
#[serde(default = "Theme::default_agent_action_input")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_action_input: Color,
/// Agent action coloring (ReAct OBSERVATION)
#[serde(default = "Theme::default_agent_observation")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_observation: Color,
/// Agent action coloring (ReAct FINAL_ANSWER)
#[serde(default = "Theme::default_agent_final_answer")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_final_answer: Color,
/// Status badge foreground when agent is running
#[serde(default = "Theme::default_agent_badge_running_fg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_badge_running_fg: Color,
/// Status badge background when agent is running
#[serde(default = "Theme::default_agent_badge_running_bg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_badge_running_bg: Color,
/// Status badge foreground when agent mode is idle
#[serde(default = "Theme::default_agent_badge_idle_fg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_badge_idle_fg: Color,
/// Status badge background when agent mode is idle
#[serde(default = "Theme::default_agent_badge_idle_bg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub agent_badge_idle_bg: Color,
/// Operating mode badge foreground (Chat)
#[serde(default = "Theme::default_operating_chat_fg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub operating_chat_fg: Color,
/// Operating mode badge background (Chat)
#[serde(default = "Theme::default_operating_chat_bg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub operating_chat_bg: Color,
/// Operating mode badge foreground (Code)
#[serde(default = "Theme::default_operating_code_fg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub operating_code_fg: Color,
/// Operating mode badge background (Code)
#[serde(default = "Theme::default_operating_code_bg")]
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub operating_code_bg: Color,
}
impl Default for Theme {
@@ -136,6 +252,84 @@ impl Default for 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 {
Color::LightBlue
}
const fn default_agent_action() -> Color {
Color::Yellow
}
const fn default_agent_action_input() -> Color {
Color::LightCyan
}
const fn default_agent_observation() -> Color {
Color::LightGreen
}
const fn default_agent_final_answer() -> Color {
Color::Magenta
}
const fn default_agent_badge_running_fg() -> Color {
Color::Black
}
const fn default_agent_badge_running_bg() -> Color {
Color::Yellow
}
const fn default_agent_badge_idle_fg() -> Color {
Color::Black
}
const fn default_agent_badge_idle_bg() -> Color {
Color::Cyan
}
const fn default_operating_chat_fg() -> Color {
Color::Black
}
const fn default_operating_chat_bg() -> Color {
Color::Blue
}
const fn default_operating_code_fg() -> Color {
Color::Black
}
const fn default_operating_code_bg() -> Color {
Color::Magenta
}
}
/// Get the default themes directory path
pub fn default_themes_dir() -> PathBuf {
let config_dir = PathBuf::from(shellexpand::tilde(crate::config::DEFAULT_CONFIG_PATH).as_ref())
@@ -213,6 +407,10 @@ pub fn built_in_themes() -> HashMap<String, Theme> {
"ansi_basic",
include_str!("../../../themes/ansi-basic.toml"),
),
(
"grayscale-high-contrast",
include_str!("../../../themes/grayscale-high-contrast.toml"),
),
("gruvbox", include_str!("../../../themes/gruvbox.toml")),
("dracula", include_str!("../../../themes/dracula.toml")),
("solarized", include_str!("../../../themes/solarized.toml")),
@@ -263,6 +461,7 @@ fn get_fallback_theme(name: &str) -> Option<Theme> {
"monokai" => Some(monokai()),
"material-dark" => Some(material_dark()),
"material-light" => Some(material_light()),
"grayscale-high-contrast" => Some(grayscale_high_contrast()),
_ => None,
}
}
@@ -291,9 +490,28 @@ fn default_dark() -> Theme {
selection_bg: Color::LightBlue,
selection_fg: Color::Black,
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,
error: Color::Red,
info: Color::LightGreen,
agent_thought: Color::LightBlue,
agent_action: Color::Yellow,
agent_action_input: Color::LightCyan,
agent_observation: Color::LightGreen,
agent_final_answer: Color::Magenta,
agent_badge_running_fg: Color::Black,
agent_badge_running_bg: Color::Yellow,
agent_badge_idle_fg: Color::Black,
agent_badge_idle_bg: Color::Cyan,
operating_chat_fg: Color::Black,
operating_chat_bg: Color::Blue,
operating_code_fg: Color::Black,
operating_code_bg: Color::Magenta,
}
}
@@ -321,9 +539,28 @@ fn default_light() -> Theme {
selection_bg: Color::Rgb(164, 200, 240),
selection_fg: Color::Black,
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,
error: Color::Rgb(192, 57, 43),
info: Color::Green,
agent_thought: Color::Rgb(0, 85, 164),
agent_action: Color::Rgb(181, 137, 0),
agent_action_input: Color::Rgb(0, 139, 139),
agent_observation: Color::Rgb(46, 139, 87),
agent_final_answer: Color::Rgb(142, 68, 173),
agent_badge_running_fg: Color::White,
agent_badge_running_bg: Color::Rgb(241, 196, 15),
agent_badge_idle_fg: Color::White,
agent_badge_idle_bg: Color::Rgb(0, 150, 136),
operating_chat_fg: Color::White,
operating_chat_bg: Color::Rgb(0, 85, 164),
operating_code_fg: Color::White,
operating_code_bg: Color::Rgb(142, 68, 173),
}
}
@@ -351,9 +588,28 @@ fn gruvbox() -> Theme {
selection_bg: Color::Rgb(80, 73, 69),
selection_fg: Color::Rgb(235, 219, 178),
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),
error: Color::Rgb(251, 73, 52), // #fb4934
info: Color::Rgb(184, 187, 38),
agent_thought: Color::Rgb(131, 165, 152),
agent_action: Color::Rgb(250, 189, 47),
agent_action_input: Color::Rgb(142, 192, 124),
agent_observation: Color::Rgb(184, 187, 38),
agent_final_answer: Color::Rgb(211, 134, 155),
agent_badge_running_fg: Color::Rgb(40, 40, 40),
agent_badge_running_bg: Color::Rgb(250, 189, 47),
agent_badge_idle_fg: Color::Rgb(40, 40, 40),
agent_badge_idle_bg: Color::Rgb(131, 165, 152),
operating_chat_fg: Color::Rgb(40, 40, 40),
operating_chat_bg: Color::Rgb(131, 165, 152),
operating_code_fg: Color::Rgb(40, 40, 40),
operating_code_bg: Color::Rgb(211, 134, 155),
}
}
@@ -381,9 +637,28 @@ fn dracula() -> Theme {
selection_bg: Color::Rgb(68, 71, 90),
selection_fg: Color::Rgb(248, 248, 242),
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),
error: Color::Rgb(255, 85, 85), // #ff5555
info: Color::Rgb(80, 250, 123),
agent_thought: Color::Rgb(139, 233, 253),
agent_action: Color::Rgb(241, 250, 140),
agent_action_input: Color::Rgb(189, 147, 249),
agent_observation: Color::Rgb(80, 250, 123),
agent_final_answer: Color::Rgb(255, 121, 198),
agent_badge_running_fg: Color::Rgb(40, 42, 54),
agent_badge_running_bg: Color::Rgb(241, 250, 140),
agent_badge_idle_fg: Color::Rgb(40, 42, 54),
agent_badge_idle_bg: Color::Rgb(139, 233, 253),
operating_chat_fg: Color::Rgb(40, 42, 54),
operating_chat_bg: Color::Rgb(139, 233, 253),
operating_code_fg: Color::Rgb(40, 42, 54),
operating_code_bg: Color::Rgb(189, 147, 249),
}
}
@@ -411,9 +686,28 @@ fn solarized() -> Theme {
selection_bg: Color::Rgb(7, 54, 66),
selection_fg: Color::Rgb(147, 161, 161),
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),
error: Color::Rgb(220, 50, 47), // #dc322f (red)
info: Color::Rgb(133, 153, 0),
agent_thought: Color::Rgb(42, 161, 152),
agent_action: Color::Rgb(181, 137, 0),
agent_action_input: Color::Rgb(38, 139, 210),
agent_observation: Color::Rgb(133, 153, 0),
agent_final_answer: Color::Rgb(108, 113, 196),
agent_badge_running_fg: Color::Rgb(0, 43, 54),
agent_badge_running_bg: Color::Rgb(181, 137, 0),
agent_badge_idle_fg: Color::Rgb(0, 43, 54),
agent_badge_idle_bg: Color::Rgb(42, 161, 152),
operating_chat_fg: Color::Rgb(0, 43, 54),
operating_chat_bg: Color::Rgb(42, 161, 152),
operating_code_fg: Color::Rgb(0, 43, 54),
operating_code_bg: Color::Rgb(108, 113, 196),
}
}
@@ -441,9 +735,28 @@ fn midnight_ocean() -> Theme {
selection_bg: Color::Rgb(56, 139, 253),
selection_fg: Color::Rgb(13, 17, 23),
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),
error: Color::Rgb(248, 81, 73),
info: Color::Rgb(158, 206, 106),
agent_thought: Color::Rgb(121, 192, 255),
agent_action: Color::Rgb(255, 212, 59),
agent_action_input: Color::Rgb(137, 221, 255),
agent_observation: Color::Rgb(158, 206, 106),
agent_final_answer: Color::Rgb(246, 140, 245),
agent_badge_running_fg: Color::Rgb(13, 17, 23),
agent_badge_running_bg: Color::Rgb(255, 212, 59),
agent_badge_idle_fg: Color::Rgb(13, 17, 23),
agent_badge_idle_bg: Color::Rgb(137, 221, 255),
operating_chat_fg: Color::Rgb(13, 17, 23),
operating_chat_bg: Color::Rgb(121, 192, 255),
operating_code_fg: Color::Rgb(13, 17, 23),
operating_code_bg: Color::Rgb(246, 140, 245),
}
}
@@ -471,9 +784,28 @@ fn rose_pine() -> Theme {
selection_bg: Color::Rgb(64, 61, 82),
selection_fg: Color::Rgb(224, 222, 244),
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),
error: Color::Rgb(235, 111, 146),
info: Color::Rgb(156, 207, 216),
agent_thought: Color::Rgb(156, 207, 216),
agent_action: Color::Rgb(246, 193, 119),
agent_action_input: Color::Rgb(196, 167, 231),
agent_observation: Color::Rgb(235, 188, 186),
agent_final_answer: Color::Rgb(235, 111, 146),
agent_badge_running_fg: Color::Rgb(25, 23, 36),
agent_badge_running_bg: Color::Rgb(246, 193, 119),
agent_badge_idle_fg: Color::Rgb(25, 23, 36),
agent_badge_idle_bg: Color::Rgb(156, 207, 216),
operating_chat_fg: Color::Rgb(25, 23, 36),
operating_chat_bg: Color::Rgb(156, 207, 216),
operating_code_fg: Color::Rgb(25, 23, 36),
operating_code_bg: Color::Rgb(196, 167, 231),
}
}
@@ -501,9 +833,28 @@ fn monokai() -> Theme {
selection_bg: Color::Rgb(117, 113, 94),
selection_fg: Color::Rgb(248, 248, 242),
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),
error: Color::Rgb(249, 38, 114),
info: Color::Rgb(166, 226, 46),
agent_thought: Color::Rgb(102, 217, 239),
agent_action: Color::Rgb(230, 219, 116),
agent_action_input: Color::Rgb(174, 129, 255),
agent_observation: Color::Rgb(166, 226, 46),
agent_final_answer: Color::Rgb(249, 38, 114),
agent_badge_running_fg: Color::Rgb(39, 40, 34),
agent_badge_running_bg: Color::Rgb(230, 219, 116),
agent_badge_idle_fg: Color::Rgb(39, 40, 34),
agent_badge_idle_bg: Color::Rgb(102, 217, 239),
operating_chat_fg: Color::Rgb(39, 40, 34),
operating_chat_bg: Color::Rgb(102, 217, 239),
operating_code_fg: Color::Rgb(39, 40, 34),
operating_code_bg: Color::Rgb(174, 129, 255),
}
}
@@ -531,9 +882,28 @@ fn material_dark() -> Theme {
selection_bg: Color::Rgb(84, 110, 122),
selection_fg: Color::Rgb(238, 255, 255),
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),
error: Color::Rgb(240, 113, 120),
info: Color::Rgb(195, 232, 141),
agent_thought: Color::Rgb(128, 203, 196),
agent_action: Color::Rgb(255, 203, 107),
agent_action_input: Color::Rgb(199, 146, 234),
agent_observation: Color::Rgb(195, 232, 141),
agent_final_answer: Color::Rgb(240, 113, 120),
agent_badge_running_fg: Color::Rgb(38, 50, 56),
agent_badge_running_bg: Color::Rgb(255, 203, 107),
agent_badge_idle_fg: Color::Rgb(38, 50, 56),
agent_badge_idle_bg: Color::Rgb(128, 203, 196),
operating_chat_fg: Color::Rgb(38, 50, 56),
operating_chat_bg: Color::Rgb(130, 170, 255),
operating_code_fg: Color::Rgb(38, 50, 56),
operating_code_bg: Color::Rgb(199, 146, 234),
}
}
@@ -561,9 +931,77 @@ fn material_light() -> Theme {
selection_bg: Color::Rgb(176, 190, 197),
selection_fg: Color::Rgb(33, 33, 33),
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),
error: Color::Rgb(211, 47, 47),
info: Color::Rgb(56, 142, 60),
agent_thought: Color::Rgb(68, 138, 255),
agent_action: Color::Rgb(245, 124, 0),
agent_action_input: Color::Rgb(124, 77, 255),
agent_observation: Color::Rgb(56, 142, 60),
agent_final_answer: Color::Rgb(211, 47, 47),
agent_badge_running_fg: Color::White,
agent_badge_running_bg: Color::Rgb(245, 124, 0),
agent_badge_idle_fg: Color::White,
agent_badge_idle_bg: Color::Rgb(0, 150, 136),
operating_chat_fg: Color::White,
operating_chat_bg: Color::Rgb(68, 138, 255),
operating_code_fg: Color::White,
operating_code_bg: Color::Rgb(124, 77, 255),
}
}
/// Grayscale high-contrast theme
fn grayscale_high_contrast() -> Theme {
Theme {
name: "grayscale_high_contrast".to_string(),
text: Color::Rgb(247, 247, 247),
background: Color::Black,
focused_panel_border: Color::White,
unfocused_panel_border: Color::Rgb(76, 76, 76),
user_message_role: Color::Rgb(240, 240, 240),
assistant_message_role: Color::Rgb(214, 214, 214),
tool_output: Color::Rgb(189, 189, 189),
thinking_panel_title: Color::Rgb(224, 224, 224),
command_bar_background: Color::Black,
status_background: Color::Rgb(15, 15, 15),
mode_normal: Color::White,
mode_editing: Color::Rgb(230, 230, 230),
mode_model_selection: Color::Rgb(204, 204, 204),
mode_provider_selection: Color::Rgb(179, 179, 179),
mode_help: Color::Rgb(153, 153, 153),
mode_visual: Color::Rgb(242, 242, 242),
mode_command: Color::Rgb(208, 208, 208),
selection_bg: Color::Rgb(240, 240, 240),
selection_fg: Color::Black,
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),
error: Color::White,
info: Color::Rgb(200, 200, 200),
agent_thought: Color::Rgb(230, 230, 230),
agent_action: Color::Rgb(204, 204, 204),
agent_action_input: Color::Rgb(176, 176, 176),
agent_observation: Color::Rgb(153, 153, 153),
agent_final_answer: Color::White,
agent_badge_running_fg: Color::Black,
agent_badge_running_bg: Color::Rgb(247, 247, 247),
agent_badge_idle_fg: Color::Black,
agent_badge_idle_bg: Color::Rgb(189, 189, 189),
operating_chat_fg: Color::Black,
operating_chat_bg: Color::Rgb(242, 242, 242),
operating_code_fg: Color::Black,
operating_code_bg: Color::Rgb(191, 191, 191),
}
}
@@ -660,5 +1098,6 @@ mod tests {
assert!(themes.contains_key("default_dark"));
assert!(themes.contains_key("gruvbox"));
assert!(themes.contains_key("dracula"));
assert!(themes.contains_key("grayscale-high-contrast"));
}
}

View File

@@ -18,6 +18,17 @@ pub use crate::state::AutoScroll;
/// Visual selection state for text selection
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
pub fn extract_text_from_selection(
lines: &[String],

View File

@@ -18,6 +18,7 @@ crossterm = { workspace = true }
tui-textarea = { workspace = true }
textwrap = { workspace = true }
unicode-width = "0.1"
unicode-segmentation = "1.11"
async-trait = "0.1"
# Async runtime

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,10 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "model",
description: "Select a model",
},
CommandSpec {
keyword: "provider",
description: "Switch active provider",
},
CommandSpec {
keyword: "model info",
description: "Show detailed information for a model",
@@ -177,7 +181,6 @@ pub fn suggestions(input: &str) -> Vec<String> {
if trimmed.is_empty() {
return default_suggestions();
}
COMMANDS
.iter()
.filter_map(|spec| {
@@ -189,3 +192,52 @@ pub fn suggestions(input: &str) -> Vec<String> {
})
.collect()
}
pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> {
let query = query.trim();
if query.is_empty() {
return Some((usize::MAX, candidate.len()));
}
let candidate_normalized = candidate.trim().to_lowercase();
if candidate_normalized.is_empty() {
return None;
}
let query_normalized = query.to_lowercase();
if candidate_normalized == query_normalized {
Some((0, candidate.len()))
} else if candidate_normalized.starts_with(&query_normalized) {
Some((1, candidate.len()))
} else if let Some(pos) = candidate_normalized.find(&query_normalized) {
Some((2, pos))
} else if is_subsequence(&candidate_normalized, &query_normalized) {
Some((3, candidate.len()))
} else {
None
}
}
fn is_subsequence(text: &str, pattern: &str) -> bool {
if pattern.is_empty() {
return true;
}
let mut pattern_chars = pattern.chars();
let mut current = match pattern_chars.next() {
Some(ch) => ch,
None => return true,
};
for ch in text.chars() {
if ch == current {
match pattern_chars.next() {
Some(next_ch) => current = next_ch,
None => return true,
}
}
}
false
}

View File

@@ -3,7 +3,7 @@ use owlen_core::theme::Theme;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
style::{Modifier, Style},
widgets::{Block, Borders, Paragraph, Wrap},
};
@@ -57,7 +57,7 @@ impl ModelInfoPanel {
.block(block)
.style(
Style::default()
.fg(Color::DarkGray)
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
)
.wrap(Wrap { trim: true });

View File

@@ -5,11 +5,30 @@ use crate::commands;
/// The palette keeps track of the raw buffer, matching suggestions, and the
/// currently highlighted suggestion index. It contains no terminal-specific
/// logic which makes it straightforward to unit test.
#[derive(Debug, Clone)]
pub struct ModelPaletteEntry {
pub id: String,
pub name: String,
pub provider: String,
}
impl ModelPaletteEntry {
fn display_name(&self) -> &str {
if self.name.is_empty() {
&self.id
} else {
&self.name
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CommandPalette {
buffer: String,
suggestions: Vec<String>,
selected: usize,
models: Vec<ModelPaletteEntry>,
providers: Vec<String>,
}
impl CommandPalette {
@@ -50,6 +69,16 @@ impl CommandPalette {
self.refresh_suggestions();
}
pub fn update_dynamic_sources(
&mut self,
models: Vec<ModelPaletteEntry>,
providers: Vec<String>,
) {
self.models = models;
self.providers = providers;
self.refresh_suggestions();
}
pub fn select_previous(&mut self) {
if !self.suggestions.is_empty() {
self.selected = self.selected.saturating_sub(1);
@@ -78,7 +107,7 @@ impl CommandPalette {
pub fn refresh_suggestions(&mut self) {
let trimmed = self.buffer.trim();
self.suggestions = commands::suggestions(trimmed);
self.suggestions = self.dynamic_suggestions(trimmed);
if self.selected >= self.suggestions.len() {
self.selected = 0;
}
@@ -89,4 +118,90 @@ impl CommandPalette {
self.refresh_suggestions();
}
}
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<String> {
if let Some(rest) = trimmed.strip_prefix("model ") {
let suggestions = self.model_suggestions("model", rest.trim());
if suggestions.is_empty() {
commands::suggestions(trimmed)
} else {
suggestions
}
} else if let Some(rest) = trimmed.strip_prefix("m ") {
let suggestions = self.model_suggestions("m", rest.trim());
if suggestions.is_empty() {
commands::suggestions(trimmed)
} else {
suggestions
}
} else if let Some(rest) = trimmed.strip_prefix("provider ") {
let suggestions = self.provider_suggestions("provider", rest.trim());
if suggestions.is_empty() {
commands::suggestions(trimmed)
} else {
suggestions
}
} else {
commands::suggestions(trimmed)
}
}
fn model_suggestions(&self, keyword: &str, query: &str) -> Vec<String> {
if query.is_empty() {
return self
.models
.iter()
.take(15)
.map(|entry| format!("{keyword} {}", entry.id))
.collect();
}
let mut matches: Vec<(usize, usize, &ModelPaletteEntry)> = self
.models
.iter()
.filter_map(|entry| {
commands::match_score(entry.id.as_str(), query)
.or_else(|| commands::match_score(entry.name.as_str(), query))
.or_else(|| {
let composite = format!("{} {}", entry.provider, entry.display_name());
commands::match_score(composite.as_str(), query)
})
.map(|score| (score.0, score.1, entry))
})
.collect();
matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.id.cmp(&b.2.id)));
matches
.into_iter()
.take(15)
.map(|(_, _, entry)| format!("{keyword} {}", entry.id))
.collect()
}
fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<String> {
if query.is_empty() {
return self
.providers
.iter()
.take(15)
.map(|provider| format!("{keyword} {}", provider))
.collect();
}
let mut matches: Vec<(usize, usize, &String)> = self
.providers
.iter()
.filter_map(|provider| {
commands::match_score(provider.as_str(), query)
.map(|score| (score.0, score.1, provider))
})
.collect();
matches.sort();
matches
.into_iter()
.take(15)
.map(|(_, _, provider)| format!("{keyword} {}", provider))
.collect()
}
}

View File

@@ -7,4 +7,4 @@
mod command_palette;
pub use command_palette::CommandPalette;
pub use command_palette::{CommandPalette, ModelPaletteEntry};

View File

@@ -1,17 +1,18 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use serde_json;
use textwrap::{Options, wrap};
use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, ModelSelectorItemKind};
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind};
use owlen_core::model::DetailedModelInfo;
use owlen_core::theme::Theme;
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;
@@ -37,22 +38,23 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Calculate dynamic input height based on textarea content
let available_width = chat_area.width;
let input_height = if matches!(app.mode(), InputMode::Editing) {
let visual_lines = calculate_wrapped_line_count(
let max_input_rows = usize::from(app.input_max_rows()).max(1);
let visual_lines = if matches!(app.mode(), InputMode::Editing | InputMode::Visual) {
calculate_wrapped_line_count(
app.textarea().lines().iter().map(|s| s.as_str()),
available_width,
);
(visual_lines as u16).min(10) + 2 // +2 for borders
)
} else {
let buffer_text = app.input_buffer().text();
let lines: Vec<&str> = if buffer_text.is_empty() {
vec![""]
} else {
buffer_text.lines().collect()
buffer_text.split('\n').collect()
};
let visual_lines = calculate_wrapped_line_count(lines, available_width);
(visual_lines as u16).min(10) + 2 // +2 for borders
calculate_wrapped_line_count(lines, available_width)
};
let visible_rows = visual_lines.max(1).min(max_input_rows);
let input_height = visible_rows as u16 + 2; // +2 for borders
// Calculate thinking section height
let thinking_height = if let Some(thinking) = app.current_thinking() {
@@ -158,6 +160,8 @@ fn render_editable_textarea(
area: Rect,
textarea: &mut TextArea<'static>,
mut wrap_lines: bool,
show_cursor: bool,
theme: &Theme,
) {
let block = textarea.block().cloned();
let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area);
@@ -181,7 +185,7 @@ fn render_editable_textarea(
if is_empty {
if !placeholder_text.is_empty() {
let style = placeholder_style.unwrap_or_else(|| Style::default().fg(Color::DarkGray));
let style = placeholder_style.unwrap_or_else(|| Style::default().fg(theme.placeholder));
render_lines.push(Line::from(vec![Span::styled(placeholder_text, style)]));
} else {
render_lines.push(Line::default());
@@ -233,10 +237,11 @@ fn render_editable_textarea(
let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines);
if let Some(ref metrics) = metrics
&& metrics.scroll_top > 0
if let Some(metrics) = metrics
.as_ref()
.filter(|metrics| metrics.scroll_top > 0 || metrics.scroll_left > 0)
{
paragraph = paragraph.scroll((metrics.scroll_top, 0));
paragraph = paragraph.scroll((metrics.scroll_top, metrics.scroll_left));
}
if let Some(block) = block {
@@ -245,7 +250,7 @@ fn render_editable_textarea(
frame.render_widget(paragraph, area);
if let Some(metrics) = metrics {
if let Some(metrics) = metrics.filter(|_| show_cursor) {
frame.set_cursor_position((metrics.cursor_x, metrics.cursor_y));
}
}
@@ -322,6 +327,7 @@ struct CursorMetrics {
cursor_x: u16,
cursor_y: u16,
scroll_top: u16,
scroll_left: u16,
}
fn compute_cursor_metrics(
@@ -348,6 +354,7 @@ fn compute_cursor_metrics(
let mut cursor_visual_row = 0usize;
let mut cursor_col_width = 0usize;
let mut cursor_found = false;
let mut cursor_line_total_width = 0usize;
for (row_idx, line) in lines.iter().enumerate() {
let display_owned = mask_char.map(|mask| mask_line(line, mask));
@@ -364,32 +371,40 @@ fn compute_cursor_metrics(
}
if row_idx == cursor_row && !cursor_found {
cursor_line_total_width = segments
.iter()
.map(|segment| UnicodeWidthStr::width(segment.as_str()))
.sum();
let mut remaining = cursor_col;
let mut segment_base_row = total_visual_rows;
for (segment_idx, segment) in segments.iter().enumerate() {
let segment_len = segment.chars().count();
let is_last_segment = segment_idx + 1 == segments.len();
if remaining > segment_len {
remaining -= segment_len;
segment_base_row += 1;
continue;
}
if remaining == segment_len && !is_last_segment {
cursor_visual_row = total_visual_rows + segment_idx + 1;
cursor_visual_row = segment_base_row + 1;
cursor_col_width = 0;
cursor_found = true;
break;
}
let prefix: String = segment.chars().take(remaining).collect();
cursor_visual_row = total_visual_rows + segment_idx;
cursor_col_width = UnicodeWidthStr::width(prefix.as_str());
let prefix_byte = char_to_byte_idx(segment, remaining);
let prefix = &segment[..prefix_byte];
cursor_visual_row = segment_base_row;
cursor_col_width = UnicodeWidthStr::width(prefix);
cursor_found = true;
break;
}
if !cursor_found && let Some(last_segment) = segments.last() {
cursor_visual_row = total_visual_rows + segments.len().saturating_sub(1);
cursor_visual_row = segment_base_row + segments.len().saturating_sub(1);
cursor_col_width = UnicodeWidthStr::width(last_segment.as_str());
cursor_found = true;
}
@@ -413,14 +428,28 @@ fn compute_cursor_metrics(
scroll_top = max_scroll;
}
let mut scroll_left = 0usize;
if !wrap_lines && content_width > 0 {
let max_scroll_left = cursor_line_total_width.saturating_sub(content_width);
if cursor_col_width + 1 > content_width {
scroll_left = cursor_col_width + 1 - content_width;
}
if scroll_left > max_scroll_left {
scroll_left = max_scroll_left;
}
}
let visible_cursor_col = cursor_col_width.saturating_sub(scroll_left);
let cursor_visible_row = cursor_visual_row.saturating_sub(scroll_top);
let max_x = content_width.saturating_sub(1);
let cursor_y = inner.y + cursor_visible_row.min(visible_height.saturating_sub(1)) as u16;
let cursor_x = inner.x + cursor_col_width.min(content_width.saturating_sub(1)) as u16;
let cursor_x = inner.x + visible_cursor_col.min(max_x) as u16;
Some(CursorMetrics {
cursor_x,
cursor_y,
scroll_top: scroll_top as u16,
scroll_left: scroll_left.min(u16::MAX as usize) as u16,
})
}
@@ -438,28 +467,24 @@ fn wrap_line_segments(line: &str, width: usize) -> Vec<String> {
let mut current = String::new();
let mut current_width = 0usize;
for ch in line.chars() {
let ch_width = UnicodeWidthStr::width(ch.to_string().as_str());
for grapheme in line.graphemes(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
// If adding this character would exceed width, wrap to next line
if current_width + ch_width > width {
if !current.is_empty() {
if current_width + grapheme_width > width && !current.is_empty() {
result.push(current);
current = String::new();
current_width = 0;
}
// If even a single character is too wide, add it anyway to avoid infinite loop
if ch_width > width {
current.push(ch);
result.push(current);
current = String::new();
current_width = 0;
continue;
}
}
current.push(ch);
current_width += ch_width;
// If even a single grapheme is too wide, add it as its own line
if grapheme_width > width {
result.push(grapheme.to_string());
continue;
}
current.push_str(grapheme);
current_width += grapheme_width;
}
if !current.is_empty() {
@@ -481,12 +506,9 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
);
let provider_span = Span::styled(
app.current_provider().to_string(),
Style::default().fg(theme.text),
);
let model_span = Span::styled(
app.selected_model().to_string(),
let model_label = app.active_model_label();
let model_with_provider_span = Span::styled(
format!("{} ({})", model_label, app.current_provider()),
Style::default()
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
@@ -504,11 +526,8 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
Line::default(),
Line::from(vec![
Span::raw(" "),
Span::styled("Provider: ", Style::default().fg(theme.placeholder)),
provider_span,
Span::raw(" "),
Span::styled("Model: ", Style::default().fg(theme.placeholder)),
model_span,
Span::styled("Model (Provider): ", Style::default().fg(theme.placeholder)),
model_with_provider_span,
]),
];
@@ -660,129 +679,88 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let content_width = area.width.saturating_sub(4).max(20);
app.set_viewport_dimensions(viewport_height, usize::from(content_width));
let conversation = app.conversation();
let total_messages = app.message_count();
let mut formatter = app.formatter().clone();
// Reserve space for borders and the message indent so text fits within the block
formatter.set_wrap_width(usize::from(content_width));
// Build the lines for messages
let mut lines: Vec<Line> = Vec::new();
for (message_index, message) in conversation.messages.iter().enumerate() {
let role = &message.role;
let (emoji, name) = match role {
Role::User => ("👤 ", "You: "),
Role::Assistant => ("🤖 ", "Assistant: "),
Role::System => ("⚙️ ", "System: "),
Role::Tool => ("🔧 ", "Tool: "),
};
// Extract content without thinking tags for assistant messages
let content_to_display = if matches!(role, Role::Assistant) {
let (content_without_think, _) = formatter.extract_thinking(&message.content);
content_without_think
} else if matches!(role, Role::Tool) {
// Format tool results nicely
format_tool_output(&message.content)
} else {
message.content.clone()
};
let formatted: Vec<String> = content_to_display
.trim()
.lines()
.map(|s| s.to_string())
.collect();
let is_streaming = message
// Build the lines for messages using cached rendering
let mut lines: Vec<Line<'static>> = Vec::new();
let role_label_mode = formatter.role_label_mode();
for message_index in 0..total_messages {
let is_streaming = {
let conversation = app.conversation();
conversation.messages[message_index]
.metadata
.get("streaming")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let show_role_labels = formatter.show_role_labels();
if show_role_labels {
// Role name line
let mut role_line_spans = vec![
Span::raw(emoji),
Span::styled(name, role_color(role, &theme).add_modifier(Modifier::BOLD)),
];
// Add loading indicator if applicable
if matches!(role, Role::Assistant)
&& app.get_loading_indicator() != ""
&& message_index == conversation.messages.len() - 1
&& is_streaming
{
role_line_spans.push(Span::styled(
format!(" {}", app.get_loading_indicator()),
Style::default().fg(theme.info),
));
}
lines.push(Line::from(role_line_spans));
// Join all formatted lines into single content string
let content = formatted.join("\n");
// Wrap content with available width minus indent (2 spaces)
let indent = " ";
let available_width = (content_width as usize).saturating_sub(2);
let chunks = if available_width > 0 {
wrap(&content, available_width)
} else {
vec![]
.unwrap_or(false)
};
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(format!("{indent}{}", seg), style)];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
lines.push(Line::from(spans));
}
} else {
// No role labels - just show content
let content = formatted.join("\n");
let chunks = wrap(&content, content_width as usize);
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(seg.into_owned(), style)];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
lines.push(Line::from(spans));
}
}
// Add an empty line after each message, except the last one
if message_index < conversation.messages.len() - 1 {
lines.push(Line::from(""));
let message_lines = app.render_message_lines_cached(
message_index,
MessageRenderContext::new(
&mut formatter,
role_label_mode,
content_width as usize,
message_index + 1 == total_messages,
is_streaming,
app.get_loading_indicator(),
&theme,
app.should_highlight_code(),
),
);
lines.extend(message_lines);
if message_index + 1 < total_messages {
lines.push(Line::from(String::new()));
}
}
// Add loading indicator ONLY if we're loading and there are no messages at all,
// or if the last message is from the user (no Assistant response started yet)
let last_message_is_user = conversation
let last_message_is_user = if total_messages == 0 {
true
} else {
let conversation = app.conversation();
conversation
.messages
.last()
.map(|msg| matches!(msg.role, Role::User))
.unwrap_or(true);
.unwrap_or(true)
};
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![
Span::raw("🤖 "),
Span::styled(
@@ -798,11 +776,22 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
];
lines.push(Line::from(loading_spans));
}
}
}
if lines.is_empty() {
lines.push(Line::from("No messages yet. Press 'i' to start typing."));
}
let scrollback_limit = app.scrollback_limit();
if scrollback_limit != usize::MAX && lines.len() > scrollback_limit {
let removed = lines.len() - scrollback_limit;
lines = lines.into_iter().skip(removed).collect();
app.apply_chat_scrollback_trim(removed, lines.len());
} else {
app.apply_chat_scrollback_trim(0, lines.len());
}
// Apply visual selection highlighting if in visual mode and Chat panel is focused
if matches!(app.mode(), InputMode::Visual)
&& matches!(app.focused_panel(), FocusedPanel::Chat)
@@ -837,8 +826,35 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
frame.render_widget(paragraph, area);
if app.has_new_message_alert() {
let badge_text = "↓ New messages (press G)";
let text_width = badge_text.chars().count() as u16;
let badge_width = text_width.saturating_add(2);
if area.width > badge_width + 1 && area.height > 2 {
let badge_x = area.x + area.width.saturating_sub(badge_width + 1);
let badge_y = area.y + 1;
let badge_area = Rect::new(badge_x, badge_y, badge_width, 1);
frame.render_widget(Clear, badge_area);
let badge_line = Line::from(Span::styled(
format!(" {badge_text} "),
Style::default()
.fg(theme.background)
.bg(theme.info)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(
Paragraph::new(badge_line)
.style(Style::default().bg(theme.info).fg(theme.background))
.alignment(Alignment::Center),
badge_area,
);
}
}
// Render cursor if Chat panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal)
if app.cursor_should_be_visible()
&& matches!(app.focused_panel(), FocusedPanel::Chat)
&& matches!(app.mode(), InputMode::Normal)
{
let cursor = app.chat_cursor();
let cursor_row = cursor.0;
@@ -875,13 +891,13 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
app.set_thinking_viewport_height(viewport_height);
let chunks = wrap(&thinking, content_width as usize);
let chunks = crate::chat_app::wrap_unicode(&thinking, content_width as usize);
let mut lines: Vec<Line> = chunks
.into_iter()
.map(|seg| {
Line::from(Span::styled(
seg.into_owned(),
seg,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
@@ -931,7 +947,8 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
frame.render_widget(paragraph, area);
// Render cursor if Thinking panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Thinking)
if app.cursor_should_be_visible()
&& matches!(app.focused_panel(), FocusedPanel::Thinking)
&& matches!(app.mode(), InputMode::Normal)
{
let cursor = app.thinking_cursor();
@@ -976,9 +993,10 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
// Detect ReAct components and apply color coding
if line_trimmed.starts_with("THOUGHT:") {
// Blue for THOUGHT
let thought_color = theme.agent_thought;
let thought_content = line_trimmed.strip_prefix("THOUGHT:").unwrap_or("").trim();
let wrapped = wrap(thought_content, content_width as usize);
let wrapped =
crate::chat_app::wrap_unicode(thought_content, content_width as usize);
// First line with label
if let Some(first) = wrapped.first() {
@@ -986,10 +1004,10 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
Span::styled(
"THOUGHT: ",
Style::default()
.fg(Color::Blue)
.fg(thought_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.to_string(), Style::default().fg(Color::Blue)),
Span::styled(first.to_string(), Style::default().fg(thought_color)),
]));
}
@@ -997,98 +1015,98 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Blue),
Style::default().fg(thought_color),
)));
}
} else if line_trimmed.starts_with("ACTION:") {
// Yellow for ACTION
let action_color = theme.agent_action;
let action_content = line_trimmed.strip_prefix("ACTION:").unwrap_or("").trim();
lines.push(Line::from(vec![
Span::styled(
"ACTION: ",
Style::default()
.fg(Color::Yellow)
.fg(action_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
action_content,
Style::default()
.fg(Color::Yellow)
.fg(action_color)
.add_modifier(Modifier::BOLD),
),
]));
} else if line_trimmed.starts_with("ACTION_INPUT:") {
// Cyan for ACTION_INPUT
let input_color = theme.agent_action_input;
let input_content = line_trimmed
.strip_prefix("ACTION_INPUT:")
.unwrap_or("")
.trim();
let wrapped = wrap(input_content, content_width as usize);
let wrapped = crate::chat_app::wrap_unicode(input_content, content_width as usize);
if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![
Span::styled(
"ACTION_INPUT: ",
Style::default()
.fg(Color::Cyan)
.fg(input_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.to_string(), Style::default().fg(Color::Cyan)),
Span::styled(first.to_string(), Style::default().fg(input_color)),
]));
}
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Cyan),
Style::default().fg(input_color),
)));
}
} else if line_trimmed.starts_with("OBSERVATION:") {
// Green for OBSERVATION
let observation_color = theme.agent_observation;
let obs_content = line_trimmed
.strip_prefix("OBSERVATION:")
.unwrap_or("")
.trim();
let wrapped = wrap(obs_content, content_width as usize);
let wrapped = crate::chat_app::wrap_unicode(obs_content, content_width as usize);
if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![
Span::styled(
"OBSERVATION: ",
Style::default()
.fg(Color::Green)
.fg(observation_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.to_string(), Style::default().fg(Color::Green)),
Span::styled(first.to_string(), Style::default().fg(observation_color)),
]));
}
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Green),
Style::default().fg(observation_color),
)));
}
} else if line_trimmed.starts_with("FINAL_ANSWER:") {
// Magenta for FINAL_ANSWER
let answer_color = theme.agent_final_answer;
let answer_content = line_trimmed
.strip_prefix("FINAL_ANSWER:")
.unwrap_or("")
.trim();
let wrapped = wrap(answer_content, content_width as usize);
let wrapped = crate::chat_app::wrap_unicode(answer_content, content_width as usize);
if let Some(first) = wrapped.first() {
lines.push(Line::from(vec![
Span::styled(
"FINAL_ANSWER: ",
Style::default()
.fg(Color::Magenta)
.fg(answer_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
first.to_string(),
Style::default()
.fg(Color::Magenta)
.fg(answer_color)
.add_modifier(Modifier::BOLD),
),
]));
@@ -1097,15 +1115,15 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
for chunk in wrapped.iter().skip(1) {
lines.push(Line::from(Span::styled(
format!(" {}", chunk),
Style::default().fg(Color::Magenta),
Style::default().fg(answer_color),
)));
}
} else if !line_trimmed.is_empty() {
// Regular text
let wrapped = wrap(line_trimmed, content_width as usize);
let wrapped = crate::chat_app::wrap_unicode(line_trimmed, content_width as usize);
for chunk in wrapped {
lines.push(Line::from(Span::styled(
chunk.into_owned(),
chunk,
Style::default().fg(theme.text),
)));
}
@@ -1145,7 +1163,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
}
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme();
let theme = app.theme().clone();
let title = match app.mode() {
InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ",
InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ",
@@ -1173,16 +1191,18 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
if matches!(app.mode(), InputMode::Editing) {
// Use the textarea directly to preserve selection state
let show_cursor = app.cursor_should_be_visible();
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, textarea, true);
render_editable_textarea(frame, area, textarea, true, show_cursor, &theme);
} else if matches!(app.mode(), InputMode::Visual) {
// In visual mode, render textarea in read-only mode with selection
let show_cursor = app.cursor_should_be_visible();
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, textarea, true);
render_editable_textarea(frame, area, textarea, true, show_cursor, &theme);
} else if matches!(app.mode(), InputMode::Command) {
// In command mode, show the command buffer with : prefix
let command_text = format!(":{}", app.command_buffer());
@@ -1268,27 +1288,17 @@ fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
where
I: IntoIterator<Item = &'a str>,
{
let content_width = available_width.saturating_sub(2); // subtract block borders
if content_width == 0 {
let mut count = 0;
for _ in lines.into_iter() {
count += 1;
}
return count.max(1);
}
let options = Options::new(content_width as usize).break_words(false);
let content_width = available_width.saturating_sub(2) as usize; // subtract block borders
let mut total = 0usize;
let mut seen = false;
for line in lines.into_iter() {
seen = true;
if line.is_empty() {
if content_width == 0 || line.is_empty() {
total += 1;
continue;
}
let wrapped = wrap(line, &options);
total += wrapped.len().max(1);
total += wrap_line_segments(line, content_width).len().max(1);
}
if !seen { 1 } else { total.max(1) }
@@ -1323,31 +1333,35 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
spans.push(Span::styled(
" 🤖 AGENT RUNNING ",
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.fg(theme.agent_badge_running_fg)
.bg(theme.agent_badge_running_bg)
.add_modifier(Modifier::BOLD),
));
} else if app.is_agent_mode() {
spans.push(Span::styled(
" 🤖 AGENT MODE ",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.fg(theme.agent_badge_idle_fg)
.bg(theme.agent_badge_idle_bg)
.add_modifier(Modifier::BOLD),
));
}
// Add operating mode indicator
let operating_mode = app.get_mode();
let (op_mode_text, op_mode_color) = match operating_mode {
owlen_core::mode::Mode::Chat => (" 💬 CHAT", Color::Blue),
owlen_core::mode::Mode::Code => (" 💻 CODE", Color::Magenta),
let (op_mode_text, op_mode_fg, op_mode_bg) = match operating_mode {
owlen_core::mode::Mode::Chat => {
(" 💬 CHAT", theme.operating_chat_fg, theme.operating_chat_bg)
}
owlen_core::mode::Mode::Code => {
(" 💻 CODE", theme.operating_code_fg, theme.operating_code_bg)
}
};
spans.push(Span::styled(
op_mode_text,
Style::default()
.fg(Color::Black)
.bg(op_mode_color)
.fg(op_mode_fg)
.bg(op_mode_bg)
.add_modifier(Modifier::BOLD),
));
@@ -1362,6 +1376,12 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
app.current_provider().to_string(),
Style::default().fg(theme.text),
));
spans.push(Span::styled(
" i:Insert m:Model ?:Help : Command",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
));
spans.push(Span::styled(" ", Style::default().fg(theme.text)));
spans.push(Span::styled(
"Model: ",
@@ -1739,7 +1759,7 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
Span::styled(
"[1] ",
Style::default()
.fg(Color::Cyan)
.fg(theme.mode_provider_selection)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow once "),
@@ -1752,7 +1772,7 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
Span::styled(
"[2] ",
Style::default()
.fg(Color::Green)
.fg(theme.mode_editing)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow session "),
@@ -1765,7 +1785,7 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
Span::styled(
"[3] ",
Style::default()
.fg(Color::Yellow)
.fg(theme.mode_model_selection)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow always "),
@@ -1777,7 +1797,9 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
lines.push(Line::from(vec![
Span::styled(
"[4] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
),
Span::raw("Deny "),
Span::styled(
@@ -1790,7 +1812,7 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
Span::styled(
"[Esc] ",
Style::default()
.fg(Color::DarkGray)
.fg(theme.placeholder)
.add_modifier(Modifier::BOLD),
),
Span::raw("Cancel"),
@@ -1982,7 +2004,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
}
}
let help_text = match tab_index {
let mut help_text = match tab_index {
0 => vec![
// Navigation
Line::from(""),
@@ -2031,6 +2053,13 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" o → insert line below and enter insert mode"),
Line::from(" O → insert line above and enter insert mode"),
Line::from(""),
Line::from(vec![Span::styled(
"ENTER KEY BEHAVIOUR",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" Normal mode → press Enter to send the current message"),
Line::from(" Insert mode → Enter sends · Shift+Enter inserts newline"),
Line::from(""),
Line::from(vec![Span::styled(
"INSERT MODE KEYS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
@@ -2251,6 +2280,25 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
_ => vec![],
};
help_text.insert(
0,
Line::from(vec![
Span::styled(
"Current Theme: ",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
),
Span::styled(
theme.name.clone(),
Style::default()
.fg(theme.mode_model_selection)
.add_modifier(Modifier::BOLD),
),
]),
);
help_text.insert(1, Line::from(""));
// Create layout for tabs and content
let layout = Layout::default()
.direction(Direction::Vertical)
@@ -2663,15 +2711,6 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
.split(vertical[1])[1]
}
fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
match role {
Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.unfocused_panel_border),
Role::Tool => Style::default().fg(theme.info),
}
}
/// Format tool output JSON into a nice human-readable format
pub(crate) fn format_tool_output(content: &str) -> String {
// Try to parse as JSON

View File

@@ -58,12 +58,21 @@ These settings customize the look and feel of the terminal interface.
- `max_history_lines` (integer, default: `2000`)
The maximum number of lines to keep in the scrollback buffer for the chat history.
- `show_role_labels` (boolean, default: `true`)
Whether to show the `user` and `bot` role labels next to messages.
- `role_label` (string, default: `"above"`)
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`)
The column at which to wrap text if `word_wrap` is enabled.
- `input_max_rows` (integer, default: `5`)
The maximum number of rows the input panel will expand to before it starts scrolling internally. Increase this value if you prefer to see more of long prompts while editing.
- `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.
- `syntax_highlighting` (boolean, default: `false`)
Enables lightweight syntax highlighting inside fenced code blocks when the terminal supports 256-color output.
## Storage Settings (`[storage]`)
These settings control how conversations are saved and loaded.

View File

@@ -2,6 +2,10 @@
Owlen is still in alpha, so configuration and storage formats may change between releases. This directory collects short guides that explain how to update a local environment when breaking changes land.
### Schema 1.2.0 (November 2025)
`config.toml` now records `schema_version = "1.2.0"` and introduces the optional `ui.input_max_rows` and `ui.scrollback_lines` settings. The new keys default to `5` and `2000` respectively, so no manual edits are required unless you want a taller input panel or a different scrollback cap. Existing files are updated automatically on load/save.
### Schema 1.1.0 (October 2025)
Owlen `config.toml` files now carry a `schema_version`. On startup the loader upgrades any existing file and warns when deprecated keys are present. No manual changes are required, but if you track the file in version control you may notice `schema_version = "1.1.0"` added near the top.

View File

@@ -6,6 +6,7 @@ This directory contains the built-in themes that are embedded into the OWLEN bin
- **default_dark** - High-contrast dark theme (default)
- **default_light** - Clean light theme
- **grayscale-high-contrast** - Monochrome palette tuned for color-blind accessibility
- **gruvbox** - Popular retro color scheme with warm tones
- **dracula** - Dark theme with vibrant purple and cyan colors
- **solarized** - Precision colors for optimal readability
@@ -54,6 +55,14 @@ selection_bg = "#0000ff" # Selection background
selection_fg = "#ffffff" # Selection foreground
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
error = "#ff0000" # Error messages
info = "#00ff00" # Info/success messages

View File

@@ -19,6 +19,12 @@ mode_command = "yellow"
selection_bg = "blue"
selection_fg = "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"
error = "red"
info = "green"

View File

@@ -19,6 +19,12 @@ mode_command = "yellow"
selection_bg = "lightblue"
selection_fg = "black"
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"
error = "red"
info = "lightgreen"

View File

@@ -19,6 +19,12 @@ mode_command = "#b58900"
selection_bg = "#a4c8f0"
selection_fg = "black"
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"
error = "#c0392b"
info = "green"

View File

@@ -19,6 +19,12 @@ mode_command = "#f1fa8c"
selection_bg = "#44475a"
selection_fg = "#f8f8f2"
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"
error = "#ff5555"
info = "#50fa7b"

View File

@@ -0,0 +1,43 @@
name = "grayscale_high_contrast"
text = "#f7f7f7"
background = "#000000"
focused_panel_border = "#ffffff"
unfocused_panel_border = "#4c4c4c"
user_message_role = "#f0f0f0"
assistant_message_role = "#d6d6d6"
tool_output = "#bdbdbd"
thinking_panel_title = "#e0e0e0"
command_bar_background = "#000000"
status_background = "#0f0f0f"
mode_normal = "#ffffff"
mode_editing = "#e6e6e6"
mode_model_selection = "#cccccc"
mode_provider_selection = "#b3b3b3"
mode_help = "#999999"
mode_visual = "#f2f2f2"
mode_command = "#d0d0d0"
selection_bg = "#f0f0f0"
selection_fg = "#000000"
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"
error = "#ffffff"
info = "#c8c8c8"
agent_thought = "#e6e6e6"
agent_action = "#cccccc"
agent_action_input = "#b0b0b0"
agent_observation = "#999999"
agent_final_answer = "#ffffff"
agent_badge_running_fg = "#000000"
agent_badge_running_bg = "#f7f7f7"
agent_badge_idle_fg = "#000000"
agent_badge_idle_bg = "#bdbdbd"
operating_chat_fg = "#000000"
operating_chat_bg = "#f2f2f2"
operating_code_fg = "#000000"
operating_code_bg = "#bfbfbf"

View File

@@ -19,6 +19,12 @@ mode_command = "#fabd2f"
selection_bg = "#504945"
selection_fg = "#ebdbb2"
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"
error = "#fb4934"
info = "#b8bb26"

View File

@@ -19,6 +19,12 @@ mode_command = "#ffcb6b"
selection_bg = "#546e7a"
selection_fg = "#eeffff"
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"
error = "#f07178"
info = "#c3e88d"

View File

@@ -19,6 +19,12 @@ mode_command = "#f57c00"
selection_bg = "#b0bec5"
selection_fg = "#212121"
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"
error = "#d32f2f"
info = "#388e3c"

View File

@@ -19,6 +19,12 @@ mode_command = "#ffd43b"
selection_bg = "#388bfd"
selection_fg = "#0d1117"
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"
error = "#f85149"
info = "#9ece6a"

View File

@@ -19,6 +19,12 @@ mode_command = "#e6db74"
selection_bg = "#75715e"
selection_fg = "#f8f8f2"
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"
error = "#f92672"
info = "#a6e22e"

View File

@@ -19,6 +19,12 @@ mode_command = "#f6c177"
selection_bg = "#403d52"
selection_fg = "#e0def4"
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"
error = "#eb6f92"
info = "#9ccfd8"

View File

@@ -19,6 +19,12 @@ mode_command = "#b58900"
selection_bg = "#073642"
selection_fg = "#93a1a1"
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"
error = "#dc322f"
info = "#859900"