feat(composer): display session title in top-right of composer border

Show the current session's persisted metadata.title in the composer
border's top-right corner alongside the existing vim mode indicator.

- app.rs: add `session_title: Option<String>` field to App
- ui.rs: populate it from metadata.title in apply_loaded_session and
  SessionUpdated handler; add derive_session_title() fallback helper
- widgets/mod.rs: render title (muted) + vim label in a single
  right-aligned title_top span to avoid overlap
This commit is contained in:
kitty
2026-05-12 15:51:07 +08:00
parent 33822424d8
commit e8b4f9911d
3 changed files with 68 additions and 13 deletions
+4
View File
@@ -1078,6 +1078,9 @@ pub struct App {
/// Whether LSP diagnostics are currently enabled. Mirrors the config file
/// `[lsp].enabled` setting. Toggled at runtime via `/lsp on|off`.
pub lsp_enabled: bool,
/// Derived title for the current session shown in the composer border.
/// Updated when `EngineEvent::SessionUpdated` fires or a saved session is loaded.
pub session_title: Option<String>,
}
/// Message queued while the engine is busy.
@@ -1568,6 +1571,7 @@ impl App {
.as_ref()
.and_then(|tui| tui.composer_arrows_scroll)
.unwrap_or(!use_mouse_capture),
session_title: None,
}
}
+36
View File
@@ -1371,7 +1371,18 @@ async fn run_event_loop(
&& let Ok(manager) = SessionManager::default_location()
{
let session = build_session_snapshot(app, &manager);
app.session_title = Some(session.metadata.title.clone());
persistence_actor::persist(PersistRequest::Checkpoint(session));
} else if app.session_title.is_none() {
// First turn on a brand-new session: persist hasn't fired yet so
// read the title from the session file if it already exists,
// otherwise fall back to deriving from messages.
let persisted = app
.current_session_id
.as_deref()
.and_then(|id| SessionManager::default_location().ok()?.load_session(id).ok())
.map(|s| s.metadata.title);
app.session_title = persisted.or_else(|| derive_session_title(&app.api_messages));
}
}
EngineEvent::CompactionStarted { message, .. } => {
@@ -6828,6 +6839,7 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool {
app.session.turn_cache_history.clear();
app.current_session_id = Some(session.metadata.id.clone());
app.session_artifacts = session.artifacts.clone();
app.session_title = Some(session.metadata.title.clone());
app.workspace_context = None;
app.workspace_context_refreshed_at = None;
if let Some(sp) = session.system_prompt.as_ref() {
@@ -6845,6 +6857,30 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool {
recovered
}
/// Derive a short display title from the API message list.
/// Skips the `<turn_meta>` block prepended by the engine and takes the first
/// real user-text block, truncated to 32 characters.
fn derive_session_title(messages: &[Message]) -> Option<String> {
messages.iter().find(|m| m.role == "user").and_then(|m| {
m.content.iter().find_map(|block| match block {
ContentBlock::Text { text, .. } if !text.starts_with("<turn_meta>") => {
let first_line = text.trim().lines().next().unwrap_or("").trim();
if first_line.is_empty() {
return None;
}
let char_count = first_line.chars().count();
let chars: String = first_line.chars().take(32).collect();
if char_count > 32 {
Some(format!("{chars}"))
} else {
Some(chars)
}
}
_ => None,
})
})
}
fn recover_interrupted_user_tail(messages: &[Message]) -> (Vec<Message>, Option<QueuedMessage>) {
let mut recovered = messages.to_vec();
let Some(last) = recovered.last() else {
+28 -13
View File
@@ -608,19 +608,34 @@ impl Renderable for ComposerWidget<'_> {
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(background);
// Vim mode indicator — shown in the top-right corner of the
// composer border when vim editing is active.
if self.app.composer.vim_enabled {
let color = match self.app.composer.vim_mode {
VimMode::Normal => palette::TEXT_MUTED,
VimMode::Insert => palette::DEEPSEEK_SKY,
VimMode::Visual => palette::MODE_PLAN,
};
let label = self.app.composer.vim_mode.label();
block = block.title_top(
Line::from(Span::styled(label, Style::default().fg(color).bold()))
.right_aligned(),
);
// Top-right corner: session title (muted) + vim mode indicator (colored).
// Both share one right-aligned title_top line to avoid overlap.
{
let mut right_spans: Vec<Span> = Vec::new();
if let Some(title) = self.app.session_title.as_deref() {
right_spans.push(Span::styled(
title.to_string(),
Style::default().fg(palette::TEXT_MUTED),
));
}
if self.app.composer.vim_enabled {
let color = match self.app.composer.vim_mode {
VimMode::Normal => palette::TEXT_MUTED,
VimMode::Insert => palette::DEEPSEEK_SKY,
VimMode::Visual => palette::MODE_PLAN,
};
if !right_spans.is_empty() {
right_spans.push(Span::raw(" "));
}
right_spans.push(Span::styled(
self.app.composer.vim_mode.label(),
Style::default().fg(color).bold(),
));
}
if !right_spans.is_empty() {
block = block
.title_top(Line::from(right_spans).right_aligned());
}
}
if let Some(hint_line) = hint_line {
block = block.title_bottom(hint_line);