Compare commits
8 Commits
7851af14a9
...
55e6b0583d
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e6b0583d | |||
| ae9c3af096 | |||
| 0bd560b408 | |||
| 083b621b7d | |||
| d2a193e5c1 | |||
| acbfe47a4b | |||
| 60c859b3ab | |||
| 82078afd6d |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
terminal.draw(|f| ui::render_chat(f, app))?;
|
||||
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) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
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") {
|
||||
@@ -914,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") {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1031,12 +1031,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_expands_env_var() {
|
||||
std::env::set_var("OLLAMA_TEST_KEY", "secret");
|
||||
unsafe {
|
||||
std::env::set_var("OLLAMA_TEST_KEY", "secret");
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_api_key(Some("${OLLAMA_TEST_KEY}".into())),
|
||||
Some("secret".into())
|
||||
);
|
||||
std::env::remove_var("OLLAMA_TEST_KEY");
|
||||
unsafe {
|
||||
std::env::remove_var("OLLAMA_TEST_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
|
||||
mod command_palette;
|
||||
|
||||
pub use command_palette::CommandPalette;
|
||||
pub use command_palette::{CommandPalette, ModelPaletteEntry};
|
||||
|
||||
@@ -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() {
|
||||
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;
|
||||
}
|
||||
if current_width + grapheme_width > width && !current.is_empty() {
|
||||
result.push(current);
|
||||
current = String::new();
|
||||
current_width = 0;
|
||||
}
|
||||
|
||||
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,149 +679,119 @@ 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: "),
|
||||
// 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)
|
||||
};
|
||||
|
||||
// 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
|
||||
.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![]
|
||||
};
|
||||
|
||||
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
|
||||
.messages
|
||||
.last()
|
||||
.map(|msg| matches!(msg.role, Role::User))
|
||||
.unwrap_or(true);
|
||||
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)
|
||||
};
|
||||
|
||||
if app.get_loading_indicator() != "" && last_message_is_user {
|
||||
let loading_spans = vec![
|
||||
Span::raw("🤖 "),
|
||||
Span::styled(
|
||||
"Assistant:",
|
||||
Style::default()
|
||||
.fg(theme.assistant_message_role)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", app.get_loading_indicator()),
|
||||
Style::default().fg(theme.info),
|
||||
),
|
||||
];
|
||||
lines.push(Line::from(loading_spans));
|
||||
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(
|
||||
"Assistant:",
|
||||
Style::default()
|
||||
.fg(theme.assistant_message_role)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {}", app.get_loading_indicator()),
|
||||
Style::default().fg(theme.info),
|
||||
),
|
||||
];
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
43
themes/grayscale-high-contrast.toml
Normal file
43
themes/grayscale-high-contrast.toml
Normal 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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user