feat(tui): status surface & toast overhaul
This commit is contained in:
@@ -62,7 +62,7 @@ use crate::state::{
|
||||
SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task,
|
||||
spawn_symbol_search_task,
|
||||
};
|
||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||
use crate::toast::{Toast, ToastHistoryEntry, ToastLevel, ToastManager};
|
||||
use crate::ui::{format_token_short, format_tool_output};
|
||||
use crate::widgets::model_picker::FilterMode;
|
||||
use crate::{commands, highlight};
|
||||
@@ -2581,10 +2581,23 @@ impl ChatApp {
|
||||
self.toasts.iter()
|
||||
}
|
||||
|
||||
pub fn toast_history(&self) -> impl Iterator<Item = &ToastHistoryEntry> {
|
||||
self.toasts.history()
|
||||
}
|
||||
|
||||
pub fn push_toast(&mut self, level: ToastLevel, message: impl Into<String>) {
|
||||
self.toasts.push(message, level);
|
||||
}
|
||||
|
||||
pub fn push_toast_with_hint(
|
||||
&mut self,
|
||||
level: ToastLevel,
|
||||
message: impl Into<String>,
|
||||
shortcut_hint: impl Into<String>,
|
||||
) {
|
||||
self.toasts.push_with_hint(message, level, shortcut_hint);
|
||||
}
|
||||
|
||||
fn prune_toasts(&mut self) {
|
||||
self.toasts.retain_active();
|
||||
}
|
||||
@@ -2606,7 +2619,7 @@ impl ChatApp {
|
||||
|
||||
let summary = format!("{}: {}", entry.target, entry.message);
|
||||
let clipped = Self::ellipsize(&summary, 120);
|
||||
self.push_toast(toast_level, clipped.clone());
|
||||
self.push_toast_with_hint(toast_level, clipped.clone(), "F12 · Debug log");
|
||||
latest_summary = Some((entry.level, clipped));
|
||||
}
|
||||
|
||||
@@ -9503,7 +9516,7 @@ impl ChatApp {
|
||||
} else {
|
||||
ToastLevel::Warning
|
||||
};
|
||||
self.push_toast(level, message);
|
||||
self.push_toast_with_hint(level, message, ":limits");
|
||||
}
|
||||
} else if current == UsageBand::Normal && previous != UsageBand::Normal {
|
||||
self.usage_thresholds.insert(key.clone(), UsageBand::Normal);
|
||||
|
||||
@@ -16,21 +16,74 @@ pub struct Toast {
|
||||
pub level: ToastLevel,
|
||||
created: Instant,
|
||||
duration: Duration,
|
||||
shortcut_hint: Option<String>,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
fn new(message: String, level: ToastLevel, lifetime: Duration) -> Self {
|
||||
fn new(
|
||||
message: String,
|
||||
level: ToastLevel,
|
||||
lifetime: Duration,
|
||||
shortcut_hint: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
message,
|
||||
level,
|
||||
created: Instant::now(),
|
||||
duration: lifetime,
|
||||
shortcut_hint,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(&self, now: Instant) -> bool {
|
||||
now.duration_since(self.created) >= self.duration
|
||||
}
|
||||
|
||||
pub fn shortcut(&self) -> Option<&str> {
|
||||
self.shortcut_hint.as_deref()
|
||||
}
|
||||
|
||||
pub fn elapsed_fraction(&self, now: Instant) -> f32 {
|
||||
let elapsed = now.saturating_duration_since(self.created).as_secs_f32();
|
||||
let total = self.duration.as_secs_f32().max(f32::MIN_POSITIVE);
|
||||
(elapsed / total).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub fn remaining_fraction(&self, now: Instant) -> f32 {
|
||||
1.0 - self.elapsed_fraction(now)
|
||||
}
|
||||
|
||||
pub fn remaining_duration(&self, now: Instant) -> Duration {
|
||||
let elapsed = now.saturating_duration_since(self.created);
|
||||
if elapsed >= self.duration {
|
||||
Duration::from_secs(0)
|
||||
} else {
|
||||
self.duration - elapsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToastHistoryEntry {
|
||||
pub message: String,
|
||||
pub level: ToastLevel,
|
||||
recorded: Instant,
|
||||
pub shortcut_hint: Option<String>,
|
||||
}
|
||||
|
||||
impl ToastHistoryEntry {
|
||||
fn new(message: String, level: ToastLevel, shortcut_hint: Option<String>) -> Self {
|
||||
Self {
|
||||
message,
|
||||
level,
|
||||
recorded: Instant::now(),
|
||||
shortcut_hint,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recorded(&self) -> Instant {
|
||||
self.recorded
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed-size toast queue with automatic expiration.
|
||||
@@ -39,6 +92,8 @@ pub struct ToastManager {
|
||||
items: VecDeque<Toast>,
|
||||
max_active: usize,
|
||||
lifetime: Duration,
|
||||
history: VecDeque<ToastHistoryEntry>,
|
||||
history_limit: usize,
|
||||
}
|
||||
|
||||
impl Default for ToastManager {
|
||||
@@ -53,6 +108,8 @@ impl ToastManager {
|
||||
items: VecDeque::new(),
|
||||
max_active: 3,
|
||||
lifetime: Duration::from_secs(3),
|
||||
history: VecDeque::new(),
|
||||
history_limit: 20,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,11 +119,33 @@ impl ToastManager {
|
||||
}
|
||||
|
||||
pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) {
|
||||
let toast = Toast::new(message.into(), level, self.lifetime);
|
||||
self.push_internal(message.into(), level, None);
|
||||
}
|
||||
|
||||
pub fn push_with_hint(
|
||||
&mut self,
|
||||
message: impl Into<String>,
|
||||
level: ToastLevel,
|
||||
shortcut_hint: impl Into<String>,
|
||||
) {
|
||||
self.push_internal(
|
||||
message.into(),
|
||||
level,
|
||||
Some(shortcut_hint.into().trim().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn push_internal(&mut self, message: String, level: ToastLevel, shortcut_hint: Option<String>) {
|
||||
let toast = Toast::new(message.clone(), level, self.lifetime, shortcut_hint.clone());
|
||||
self.items.push_front(toast);
|
||||
while self.items.len() > self.max_active {
|
||||
self.items.pop_back();
|
||||
}
|
||||
self.history
|
||||
.push_front(ToastHistoryEntry::new(message, level, shortcut_hint));
|
||||
if self.history.len() > self.history_limit {
|
||||
self.history.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retain_active(&mut self) {
|
||||
@@ -78,6 +157,10 @@ impl ToastManager {
|
||||
self.items.iter()
|
||||
}
|
||||
|
||||
pub fn history(&self) -> impl Iterator<Item = &ToastHistoryEntry> {
|
||||
self.history.iter()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
@@ -111,4 +194,16 @@ mod tests {
|
||||
manager.retain_active();
|
||||
assert!(manager.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_tracks_history_and_hints() {
|
||||
let mut manager = ToastManager::new();
|
||||
manager.push_with_hint("saving project", ToastLevel::Info, "Ctrl+S");
|
||||
manager.push("all good", ToastLevel::Success);
|
||||
|
||||
let latest = manager.history().next().expect("history entry");
|
||||
assert_eq!(latest.level, ToastLevel::Success);
|
||||
assert!(latest.shortcut_hint.is_none());
|
||||
assert!(manager.history().nth(1).unwrap().shortcut_hint.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragra
|
||||
use serde_json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use tui_textarea::TextArea;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
@@ -63,6 +64,56 @@ fn progress_band_color(band: ProgressBand, theme: &Theme) -> Color {
|
||||
band.color(theme)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum StatusLevel {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
fn status_level_colors(level: StatusLevel, theme: &Theme) -> (Style, Style) {
|
||||
match level {
|
||||
StatusLevel::Info => (
|
||||
Style::default()
|
||||
.bg(theme.info)
|
||||
.fg(theme.background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.info),
|
||||
),
|
||||
StatusLevel::Success => (
|
||||
Style::default()
|
||||
.bg(theme.agent_badge_idle_bg)
|
||||
.fg(theme.background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.agent_badge_idle_bg),
|
||||
),
|
||||
StatusLevel::Warning => (
|
||||
Style::default()
|
||||
.bg(theme.agent_action)
|
||||
.fg(theme.background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.agent_action),
|
||||
),
|
||||
StatusLevel::Error => (
|
||||
Style::default()
|
||||
.bg(theme.error)
|
||||
.fg(theme.background)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.error),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_level_icon(level: StatusLevel) -> &'static str {
|
||||
match level {
|
||||
StatusLevel::Info => "ⓘ",
|
||||
StatusLevel::Success => "✔",
|
||||
StatusLevel::Warning => "⚠",
|
||||
StatusLevel::Error => "✖",
|
||||
}
|
||||
}
|
||||
|
||||
fn context_progress_band(ratio: f64) -> ProgressBand {
|
||||
if ratio >= 0.85 {
|
||||
ProgressBand::Critical
|
||||
@@ -1362,24 +1413,13 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
|
||||
}
|
||||
|
||||
let theme = app.theme();
|
||||
let now = Instant::now();
|
||||
let available_width = usize::from(full_area.width.saturating_sub(2));
|
||||
if available_width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_text_width = toasts
|
||||
.iter()
|
||||
.map(|toast| UnicodeWidthStr::width(toast.message.as_str()))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut width = max_text_width.saturating_add(6); // padding + badge
|
||||
width = width.clamp(14, available_width);
|
||||
width = width.min(48);
|
||||
if width == 0 {
|
||||
return;
|
||||
}
|
||||
let width = width as u16;
|
||||
let width = available_width.clamp(24, 52) as u16;
|
||||
|
||||
let offset_x = full_area
|
||||
.x
|
||||
@@ -1389,8 +1429,10 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
|
||||
|
||||
for toast in toasts {
|
||||
let (label, badge_style, border_style) = toast_palette(toast.level, theme);
|
||||
let accent_color = toast_level_color(toast.level, theme);
|
||||
let icon = toast_icon(toast.level);
|
||||
let badge_text = format!(" {} ", label);
|
||||
let indent_width = UnicodeWidthStr::width(badge_text.as_str()) + 1;
|
||||
let indent_width = UnicodeWidthStr::width(badge_text.as_str()) + 2;
|
||||
let indent = " ".repeat(indent_width);
|
||||
|
||||
let content_width = width.saturating_sub(4).max(1) as usize;
|
||||
@@ -1404,22 +1446,48 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
|
||||
.collect()
|
||||
};
|
||||
|
||||
let text_style = Style::default().fg(theme.text);
|
||||
let mut paragraph_lines = Vec::with_capacity(lines.len());
|
||||
let mut paragraph_lines = Vec::new();
|
||||
if let Some((first, rest)) = lines.split_first() {
|
||||
paragraph_lines.push(Line::from(vec![
|
||||
Span::styled(badge_text.clone(), badge_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(first.clone(), text_style),
|
||||
Span::styled(
|
||||
format!(" {icon} "),
|
||||
Style::default()
|
||||
.fg(accent_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(first.clone(), Style::default().fg(theme.text)),
|
||||
]));
|
||||
for line in rest {
|
||||
paragraph_lines.push(Line::from(vec![
|
||||
Span::raw(indent.clone()),
|
||||
Span::styled(line.clone(), text_style),
|
||||
Span::styled(line.clone(), Style::default().fg(theme.text)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hint) = toast.shortcut().filter(|hint| !hint.is_empty()) {
|
||||
paragraph_lines.push(Line::from(vec![
|
||||
Span::raw(indent.clone()),
|
||||
Span::styled(
|
||||
hint.to_string(),
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let bar_width = content_width.clamp(6, 24);
|
||||
let remaining_fraction = toast.remaining_fraction(now) as f64;
|
||||
let bar = unicode_progress_bar(bar_width, remaining_fraction);
|
||||
let remaining_secs = toast.remaining_duration(now).as_secs().min(99);
|
||||
paragraph_lines.push(Line::from(vec![
|
||||
Span::raw(indent.clone()),
|
||||
Span::styled(bar, Style::default().fg(accent_color)),
|
||||
Span::raw(format!(" {:>2}s", remaining_secs)),
|
||||
]));
|
||||
|
||||
let height = (paragraph_lines.len() as u16).saturating_add(2);
|
||||
if offset_y.saturating_add(height) > frame_bottom {
|
||||
break;
|
||||
@@ -1434,7 +1502,7 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
|
||||
let paragraph = Paragraph::new(paragraph_lines)
|
||||
.block(block)
|
||||
.alignment(Alignment::Left)
|
||||
.wrap(Wrap { trim: false });
|
||||
.wrap(Wrap { trim: true });
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
offset_y = offset_y.saturating_add(height + 1);
|
||||
@@ -3197,11 +3265,11 @@ where
|
||||
if !seen { 1 } else { total.max(1) }
|
||||
}
|
||||
|
||||
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
let theme = app.theme();
|
||||
|
||||
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||||
let theme = app.theme().clone();
|
||||
let layer_settings = app.layer_settings().clone();
|
||||
let reduced = app.is_reduced_chrome();
|
||||
let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings());
|
||||
let palette = GlassPalette::for_theme_with_mode(&theme, reduced, &layer_settings);
|
||||
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
@@ -3223,12 +3291,35 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(32),
|
||||
Constraint::Min(24),
|
||||
Constraint::Length(48),
|
||||
Constraint::Length(30),
|
||||
Constraint::Min(36),
|
||||
Constraint::Length(42),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
if columns.len() < 3 {
|
||||
render_status_center(frame, inner, app, &palette, &theme);
|
||||
return;
|
||||
}
|
||||
|
||||
render_status_center(frame, columns[1], app, &palette, &theme);
|
||||
|
||||
let app_ref: &ChatApp = app;
|
||||
render_status_left(frame, columns[0], app_ref, &palette, &theme);
|
||||
render_status_right(frame, columns[2], app_ref, &palette, &theme);
|
||||
}
|
||||
|
||||
fn render_status_left(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
app: &ChatApp,
|
||||
palette: &GlassPalette,
|
||||
theme: &Theme,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (mode_label, mode_color) = match app.mode() {
|
||||
InputMode::Normal => ("NORMAL", theme.mode_normal),
|
||||
InputMode::Editing => ("INSERT", theme.mode_editing),
|
||||
@@ -3268,14 +3359,10 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
FocusedPanel::Code => ("CODE", "Ctrl+3"),
|
||||
};
|
||||
|
||||
let mut left_spans = vec![
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", mode_label), mode_badge_style),
|
||||
Span::styled(
|
||||
" │ ",
|
||||
Style::default()
|
||||
.fg(theme.unfocused_panel_border)
|
||||
.add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!(" {} ", op_label),
|
||||
Style::default()
|
||||
@@ -3283,36 +3370,153 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
.fg(op_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!(" │ {} · {}", focus_label, focus_hint),
|
||||
format!("Focus {focus_label} · {focus_hint}"),
|
||||
Style::default()
|
||||
.fg(theme.pane_header_active)
|
||||
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
|
||||
),
|
||||
];
|
||||
]));
|
||||
|
||||
if app.is_agent_running() {
|
||||
left_spans.push(Span::styled(
|
||||
" 🤖 RUN",
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Agent running · Esc stops",
|
||||
Style::default()
|
||||
.fg(theme.agent_badge_running_fg)
|
||||
.bg(theme.agent_badge_running_bg)
|
||||
.fg(theme.agent_badge_running_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
)]));
|
||||
} else if app.is_agent_mode() {
|
||||
left_spans.push(Span::styled(
|
||||
" 🤖 ARM",
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Agent armed · Alt+A toggle",
|
||||
Style::default()
|
||||
.fg(theme.agent_badge_idle_fg)
|
||||
.bg(theme.agent_badge_idle_bg)
|
||||
.fg(theme.agent_badge_idle_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
)]));
|
||||
}
|
||||
|
||||
let left_paragraph = Paragraph::new(Line::from(left_spans))
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label));
|
||||
frame.render_widget(left_paragraph, columns[0]);
|
||||
if app.has_new_message_alert() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"New replies waiting · End to catch up",
|
||||
Style::default()
|
||||
.fg(theme.agent_action)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"F1",
|
||||
Style::default()
|
||||
.fg(theme.selection_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Help ", Style::default().fg(theme.placeholder)),
|
||||
Span::styled(
|
||||
"?",
|
||||
Style::default()
|
||||
.fg(theme.selection_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Guidance ", Style::default().fg(theme.placeholder)),
|
||||
Span::styled(
|
||||
"F12",
|
||||
Style::default()
|
||||
.fg(theme.selection_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Debug log", Style::default().fg(theme.placeholder)),
|
||||
]));
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_status_center(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
app: &mut ChatApp,
|
||||
palette: &GlassPalette,
|
||||
theme: &Theme,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (level, primary, detail, auxiliary) = compute_status_message(app);
|
||||
let (badge_style, _) = status_level_colors(level, theme);
|
||||
let icon = status_level_icon(level);
|
||||
|
||||
let mut constraints = vec![Constraint::Length(2)];
|
||||
if detail.is_some() {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
if auxiliary.is_some() {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(3));
|
||||
constraints.push(Constraint::Min(0));
|
||||
|
||||
let regions = Layout::vertical(constraints).split(area);
|
||||
let mut index = 0;
|
||||
|
||||
let primary_line = Line::from(vec![
|
||||
Span::styled(format!(" {} ", icon), badge_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
primary,
|
||||
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(primary_line)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
||||
.wrap(Wrap { trim: true }),
|
||||
regions[index],
|
||||
);
|
||||
index += 1;
|
||||
|
||||
if let Some(detail_text) = detail {
|
||||
frame.render_widget(
|
||||
Paragraph::new(detail_text)
|
||||
.style(Style::default().bg(palette.highlight).fg(theme.placeholder))
|
||||
.wrap(Wrap { trim: true }),
|
||||
regions[index],
|
||||
);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if let Some(aux_text) = auxiliary {
|
||||
frame.render_widget(
|
||||
Paragraph::new(aux_text)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
||||
.wrap(Wrap { trim: true }),
|
||||
regions[index],
|
||||
);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if index < regions.len() {
|
||||
render_status_progress(frame, regions[index], app, palette, theme);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_status_right(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
app: &ChatApp,
|
||||
palette: &GlassPalette,
|
||||
theme: &Theme,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let file_tree = app.file_tree();
|
||||
let repo_label = if let Some(branch) = file_tree.git_branch() {
|
||||
@@ -3334,60 +3538,338 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||
};
|
||||
|
||||
let position_label = status_cursor_position(app);
|
||||
let encoding_label = "UTF-8 LF";
|
||||
let language_label = language_label_for_path(current_path.as_deref());
|
||||
|
||||
let mut mid_parts = vec![repo_label];
|
||||
if let Some(path) = current_path.as_ref() {
|
||||
mid_parts.push(path.clone());
|
||||
}
|
||||
mid_parts.push(position_label);
|
||||
mid_parts.push(encoding_label.to_string());
|
||||
mid_parts.push(language_label.to_string());
|
||||
|
||||
let mid_paragraph = Paragraph::new(mid_parts.join(" · "))
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label));
|
||||
frame.render_widget(mid_paragraph, columns[1]);
|
||||
let encoding_label = "UTF-8";
|
||||
|
||||
let provider = app.current_provider();
|
||||
let provider_display = truncate_with_ellipsis(provider, 16);
|
||||
let model_label = app.active_model_label();
|
||||
let model_display = truncate_with_ellipsis(&model_label, 24);
|
||||
let mut right_spans = vec![Span::styled(
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
repo_label,
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
|
||||
if let Some(path) = current_path.as_ref() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
truncate_with_ellipsis(path, area.width.saturating_sub(4) as usize),
|
||||
Style::default().fg(theme.placeholder),
|
||||
)]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
"{} · {} · {}",
|
||||
position_label, language_label, encoding_label
|
||||
),
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM),
|
||||
)]));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
let mut provider_spans = vec![Span::styled(
|
||||
format!("{} ▸ {}", provider_display, model_display),
|
||||
Style::default()
|
||||
.fg(palette.label)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
|
||||
if app.is_loading() || app.is_streaming() {
|
||||
if app.is_streaming() {
|
||||
let spinner = app.get_loading_indicator();
|
||||
let spinner = if spinner.is_empty() { "…" } else { spinner };
|
||||
right_spans.push(Span::styled(
|
||||
provider_spans.push(Span::styled(
|
||||
format!(" · {} streaming", spinner),
|
||||
Style::default().fg(progress_band_color(ProgressBand::Normal, theme)),
|
||||
));
|
||||
right_spans.push(Span::styled(
|
||||
" · p:Pause r:Resume s:Stop",
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
Style::default().fg(toast_level_color(ToastLevel::Info, theme)),
|
||||
));
|
||||
}
|
||||
|
||||
right_spans.push(Span::styled(
|
||||
provider_spans.push(Span::styled(
|
||||
" · LSP:✓",
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM),
|
||||
));
|
||||
lines.push(Line::from(provider_spans));
|
||||
|
||||
let right_line = spans_within_width(right_spans, columns[2].width);
|
||||
let right_paragraph = Paragraph::new(right_line)
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label));
|
||||
frame.render_widget(right_paragraph, columns[2]);
|
||||
if app.is_loading() && !app.is_streaming() {
|
||||
let spinner = app.get_loading_indicator();
|
||||
if !spinner.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!("Loading {spinner} · Esc cancels"),
|
||||
Style::default().fg(theme.info),
|
||||
)]));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Toast history",
|
||||
Style::default()
|
||||
.fg(theme.pane_header_active)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]));
|
||||
|
||||
let history: Vec<_> = app.toast_history().take(3).cloned().collect();
|
||||
let message_width = area.width.saturating_sub(10).max(12) as usize;
|
||||
|
||||
if history.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"No recent toasts",
|
||||
Style::default().fg(theme.placeholder),
|
||||
)]));
|
||||
} else {
|
||||
for entry in history {
|
||||
let color = toast_level_color(entry.level, theme);
|
||||
let icon = toast_icon(entry.level);
|
||||
let age = format_elapsed_short(entry.recorded().elapsed());
|
||||
let message = truncate_to_width(&entry.message, message_width);
|
||||
let mut spans = vec![
|
||||
Span::styled(format!("{icon} "), Style::default().fg(color)),
|
||||
Span::styled(message, Style::default().fg(theme.text)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
age,
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM),
|
||||
),
|
||||
];
|
||||
if let Some(hint) = entry.shortcut_hint.clone() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
hint,
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_status_message(app: &ChatApp) -> (StatusLevel, String, Option<String>, Option<String>) {
|
||||
let status_text = app.status.trim();
|
||||
let system_text = app.system_status().trim();
|
||||
|
||||
if let Some(error) = app.error.as_ref().filter(|err| !err.trim().is_empty()) {
|
||||
let detail = if status_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(status_text.to_string())
|
||||
};
|
||||
let aux = if system_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(system_text.to_string())
|
||||
};
|
||||
return (StatusLevel::Error, error.trim().to_string(), detail, aux);
|
||||
}
|
||||
|
||||
let mut level = StatusLevel::Info;
|
||||
let primary = if status_text.is_empty() {
|
||||
default_status_message(app)
|
||||
} else {
|
||||
let text = status_text.to_string();
|
||||
let lower = text.to_lowercase();
|
||||
if lower.contains("success") || lower.contains("saved") || lower.contains("ready") {
|
||||
level = StatusLevel::Success;
|
||||
} else if lower.contains("warn") || lower.contains("slow") || lower.contains("limit") {
|
||||
level = StatusLevel::Warning;
|
||||
}
|
||||
text
|
||||
};
|
||||
|
||||
let detail = if system_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(system_text.to_string())
|
||||
};
|
||||
|
||||
(level, primary, detail, None)
|
||||
}
|
||||
|
||||
fn default_status_message(app: &ChatApp) -> String {
|
||||
let mode = app.mode().to_string();
|
||||
format!("Ready · {} mode", mode)
|
||||
}
|
||||
|
||||
fn render_status_progress(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
app: &mut ChatApp,
|
||||
palette: &GlassPalette,
|
||||
theme: &Theme,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let bar_width = area.width.saturating_sub(18).max(8) as usize;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
if let Some(descriptor) = app
|
||||
.context_usage_with_fallback()
|
||||
.and_then(context_usage_descriptor)
|
||||
{
|
||||
let ratio = app.animated_gauge_ratio(GaugeKey::Context, descriptor.ratio);
|
||||
let bar = unicode_progress_bar(bar_width, ratio);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Context ",
|
||||
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(bar, Style::default().fg(theme.info)),
|
||||
Span::raw(format!(
|
||||
" {} ({})",
|
||||
descriptor.percent_label, descriptor.detail
|
||||
)),
|
||||
]));
|
||||
} else {
|
||||
app.reset_gauge(GaugeKey::Context);
|
||||
}
|
||||
|
||||
if let Some(snapshot) = app.usage_snapshot().cloned() {
|
||||
if let Some(descriptor) = usage_gauge_descriptor(&snapshot, UsageWindow::Hour) {
|
||||
let ratio = app.animated_gauge_ratio(GaugeKey::UsageHour, descriptor.ratio);
|
||||
let bar = unicode_progress_bar(bar_width, ratio);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Cloud hr ",
|
||||
Style::default()
|
||||
.fg(theme.agent_action)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(bar, Style::default().fg(theme.agent_action)),
|
||||
Span::raw(format!(" {}", descriptor.detail)),
|
||||
]));
|
||||
}
|
||||
if let Some(descriptor) = usage_gauge_descriptor(&snapshot, UsageWindow::Week) {
|
||||
let ratio = app.animated_gauge_ratio(GaugeKey::UsageWeek, descriptor.ratio);
|
||||
let bar = unicode_progress_bar(bar_width, ratio);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Cloud wk ",
|
||||
Style::default()
|
||||
.fg(theme.agent_badge_idle_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(bar, Style::default().fg(theme.agent_badge_idle_bg)),
|
||||
Span::raw(format!(" {}", descriptor.detail)),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
app.reset_gauge(GaugeKey::UsageHour);
|
||||
app.reset_gauge(GaugeKey::UsageWeek);
|
||||
}
|
||||
|
||||
if app.is_streaming() {
|
||||
let spinner = app.get_loading_indicator();
|
||||
let spinner = if spinner.is_empty() { "…" } else { spinner };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Streaming {spinner} "),
|
||||
Style::default().fg(theme.info),
|
||||
),
|
||||
Span::styled(
|
||||
"p Pause · r Resume · s Stop",
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
} else if app.is_loading() {
|
||||
let spinner = app.get_loading_indicator();
|
||||
if !spinner.is_empty() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Loading {spinner} "),
|
||||
Style::default().fg(theme.info),
|
||||
),
|
||||
Span::styled(
|
||||
"Esc cancels",
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Usage metrics pending · run :limits",
|
||||
Style::default().fg(theme.placeholder),
|
||||
)]));
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn unicode_progress_bar(width: usize, ratio: f64) -> String {
|
||||
if width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let width = width.clamp(1, 40);
|
||||
let clamped = ratio.clamp(0.0, 1.0);
|
||||
let filled = (clamped * width as f64).round() as usize;
|
||||
let mut bar = String::with_capacity(width);
|
||||
for index in 0..width {
|
||||
if index < filled {
|
||||
bar.push('█');
|
||||
} else {
|
||||
bar.push('░');
|
||||
}
|
||||
}
|
||||
bar
|
||||
}
|
||||
|
||||
fn format_elapsed_short(duration: Duration) -> String {
|
||||
let secs = duration.as_secs();
|
||||
if secs == 0 {
|
||||
"<1s".to_string()
|
||||
} else if secs < 60 {
|
||||
format!("{}s", secs)
|
||||
} else if secs < 3600 {
|
||||
format!("{}m", secs / 60)
|
||||
} else {
|
||||
format!("{}h", secs / 3600)
|
||||
}
|
||||
}
|
||||
|
||||
fn toast_level_color(level: ToastLevel, theme: &Theme) -> Color {
|
||||
match level {
|
||||
ToastLevel::Info => theme.info,
|
||||
ToastLevel::Success => theme.agent_badge_idle_bg,
|
||||
ToastLevel::Warning => theme.agent_action,
|
||||
ToastLevel::Error => theme.error,
|
||||
}
|
||||
}
|
||||
|
||||
fn toast_icon(level: ToastLevel) -> &'static str {
|
||||
match level {
|
||||
ToastLevel::Info => "ⓘ",
|
||||
ToastLevel::Success => "✔",
|
||||
ToastLevel::Warning => "⚠",
|
||||
ToastLevel::Error => "✖",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_token_short(value: u64) -> String {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: crates/owlen-tui/tests/chat_snapshots.rs
|
||||
assertion_line: 156
|
||||
expression: snapshot
|
||||
---
|
||||
" "
|
||||
@@ -34,7 +33,7 @@ expression: snapshot
|
||||
" System/Status "
|
||||
" "
|
||||
" "
|
||||
" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
|
||||
" "
|
||||
" "
|
||||
" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui "
|
||||
" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
|
||||
" "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: crates/owlen-tui/tests/chat_snapshots.rs
|
||||
assertion_line: 162
|
||||
expression: snapshot
|
||||
---
|
||||
" "
|
||||
@@ -33,8 +32,8 @@ expression: snapshot
|
||||
" System/Status "
|
||||
" "
|
||||
" "
|
||||
" NORMAL │ CHAT │ INPUT · Ctrl owlen-tui · 1:1 · UTF-8 LF · Plain Text ollama_local ▸ stub-model · LSP:✓ "
|
||||
" "
|
||||
" "
|
||||
" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui "
|
||||
" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: crates/owlen-tui/tests/chat_snapshots.rs
|
||||
assertion_line: 150
|
||||
expression: snapshot
|
||||
---
|
||||
" "
|
||||
@@ -35,6 +34,6 @@ expression: snapshot
|
||||
" System/Status "
|
||||
" "
|
||||
" "
|
||||
" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model "
|
||||
" "
|
||||
" "
|
||||
" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tu "
|
||||
" Ctrl+5 Icons: Nerd (auto) i "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: crates/owlen-tui/tests/chat_snapshots.rs
|
||||
assertion_line: 232
|
||||
expression: snapshot
|
||||
---
|
||||
" "
|
||||
@@ -34,7 +33,7 @@ expression: snapshot
|
||||
" System/Status "
|
||||
" "
|
||||
" "
|
||||
" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
|
||||
" "
|
||||
" "
|
||||
" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui "
|
||||
" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
|
||||
" "
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: crates/owlen-tui/tests/chat_snapshots.rs
|
||||
assertion_line: 216
|
||||
expression: snapshot
|
||||
---
|
||||
" "
|
||||
@@ -8,10 +7,10 @@ expression: snapshot
|
||||
" Context metrics not available Cloud usage pending "
|
||||
" "
|
||||
" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus "
|
||||
" ┌──────────────────────────────────────────────┐ "
|
||||
" │ Found multiple articles│ WARN Cloud usage is at 82% of the hourly │ "
|
||||
" └──────────────────────────└──────────────────────────────────────────────┘ "
|
||||
" ┌ 🔧 Tool [Result: call-search-1] ──────────────────────────────────────┐ "
|
||||
" ┌──────────────────────────────────────────────────┐ "
|
||||
" │ Found multiple arti│WARN ⚠ Cloud usage is at 82% of the hourly quota.│ "
|
||||
" └──────────────────────│████████████████████████ 2s │ "
|
||||
" ┌ 🔧 Tool [Result: cal└──────────────────────────────────────────────────┘ "
|
||||
" │ Rust 1.85 released with generics cleanups and faster async compila │ "
|
||||
" │ tion. │ "
|
||||
" └────────────────────────────────────────────────────────────────────────┘ "
|
||||
@@ -24,6 +23,6 @@ expression: snapshot
|
||||
" System/Status "
|
||||
" "
|
||||
" "
|
||||
" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model "
|
||||
" "
|
||||
" "
|
||||
" NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tu "
|
||||
" Ctrl+5 Icons: Nerd (auto) i "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · "
|
||||
|
||||
@@ -22,7 +22,7 @@ expression: snapshot
|
||||
" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close "
|
||||
" "
|
||||
" "
|
||||
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
|
||||
" "
|
||||
" "
|
||||
" HELP CHAT Focus INPUT · ⓘ Owlen cheat sheet owlen-tui "
|
||||
" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
|
||||
" "
|
||||
|
||||
@@ -22,7 +22,7 @@ expression: snapshot
|
||||
" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close "
|
||||
" "
|
||||
" "
|
||||
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
|
||||
" "
|
||||
" "
|
||||
" HELP CHAT Focus INPUT · ⓘ Owlen cheat sheet owlen-tui "
|
||||
" Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
|
||||
" "
|
||||
|
||||
@@ -23,6 +23,6 @@ expression: snapshot
|
||||
" "
|
||||
" Normal F1/? | "
|
||||
" "
|
||||
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model "
|
||||
" "
|
||||
" "
|
||||
" HELP CHAT Focus INPUT · ⓘ Welcome to Owlen! Press F1 for owlen-tu "
|
||||
" Ctrl+5 Normal ▸ h/j/k/l • Insert ▸ i,a • i "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · "
|
||||
|
||||
@@ -22,7 +22,7 @@ expression: snapshot
|
||||
" System/Sta Enter/→ Next Shift+Tab/← Back Esc Skip "
|
||||
" "
|
||||
" "
|
||||
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
|
||||
" "
|
||||
" "
|
||||
" HELP CHAT Focus INPUT · ⓘ Owlen onboarding · Step 2 of 3 owlen-tui "
|
||||
" Ctrl+5 Normal ▸ h/j/k/l • Insert ▸ i,a • 1:1 · Plain Text · UTF-8 "
|
||||
" F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
|
||||
" "
|
||||
|
||||
Reference in New Issue
Block a user