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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user