feat(tui): configurable [title] prefix in the terminal window title

/title [name|off] sets a per-session tab/window title, persisted on the
saved session; the root 'title' config key (or a profile overlay)
supplies the default, editable via /config title <name> [--save]. The
prefix renders as [title] in front of every window-title state
(Codewhale / reasoning... / using tool... / done), so parallel sessions
in separate terminal windows are identifiable at a glance. Independent
of /rename, which keeps naming the session in the composer and picker.

- notifications: TITLE_PREFIX state + set_title_prefix (change-detected)
  + decorate_title applied to every OSC-0 write (activity label, done
  marker, rest title, interaction reset); empty prefix keeps historical
  titles byte-for-byte
- app: window_title (session) / title_default (config), precedence
  window_title > title_default > none; render loop syncs each frame via
  sync_title_activity
- session: SavedSession.window_title persisted (serde default, omitted
  when None); restored on load, cleared on /new, /fork, /clear, and
  carried by build_session_snapshot so autosave cannot drop it
- config: root 'title' key parsed and merged (profile overlays included)
- /config title <name> [--save] runtime set + persistence
- locales: CmdTitleDescription across all 15 complete packs
- config.example.toml + crates/tui/CHANGELOG.md entries
- tests: prefix decoration/change-detection, app precedence, render-loop
  sync, /title set/clear/report/persist, config parse+merge
This commit is contained in:
Shizuku authored and CodeWhale Bot committed 2026-08-15 23:09:45 -07:00
1 parent 9b0cd0c7b0
commit 8b6bcc5e08
34 files changed
+613 -5

No files matched your search

+8
View File
@@ -258,6 +258,14 @@ approval_policy = "on-request" # on-request | untrusted | never
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
# prompt_suggestion = true # opt-in: show ghost-text follow-up question in composer after each turn
# Optional tab/window title shown as `[title] …` in front of the terminal
# window title (the `Codewhale` / `reasoning…` / `using tool…` / `done`
# states). Multi-window setups can give each workspace its own `--config`
# file (or profile) so alt-tabbed sessions are identifiable at a glance.
# The `/title` command overrides this per session; `/config title … --save`
# persists a new default here. Run `/title off` to drop a session override.
# title = "workspace-x"
# Typed permission rules live in a sibling `permissions.toml` file, not in
# config.toml. Each `[[rules]]` entry accepts `tool`, optional `command`
# or `path`, optional absolute `workspace`, optional `command_exact = true`,
+6
View File
@@ -48,6 +48,12 @@ a published OpenAI-compatible host ship here (#5350).
Foreign auto-install policy (Codex `INSTALLED_BY_DEFAULT`) is visibly
ignored; nothing is auto-installed, auto-trusted, or granted vendor
trust.
- `/title [name|off]` sets a per-session tab/window title, shown as
`[title] …` in front of the terminal window title (`Codewhale` /
`reasoning…` / `using tool…` / `done`). The `title` config key supplies
the default (`/config title … --save` persists it); multi-window
workflows can tell parallel sessions apart at a glance. `/title` is
independent of `/rename`, which keeps naming the session in the picker.
- `/rc` attach now includes an observed `owner/name` git remote when the
folder has a GitHub, CNB, or Gitee origin, so CWC can label the paired
+1
View File
@@ -345,6 +345,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale no carrega, migra ni sincronitza codi font local amb el Work allotjat. Utilitza {command} per començar des de la punta de la branca disponible a GitHub o CNB. Els commits no enviats, els fitxers modificats o ignorats, els secrets i l'estat de la sessió es mantenen en local.",
"CmdRemoteEnvBrowserLabel": "Work allotjat de Codewhale",
"CmdRenameDescription": "Reanomena la sessió actual",
"CmdTitleDescription": "Defineix un títol de pestanya/finestra mostrat com a [title] … al títol del terminal",
"CmdRestoreDescription": "Reverteix l'espai de treball a una instantània anterior pre/post-torn. Sense argument, llista les instantànies recents.",
"CmdRetryDescription": "Reintenta l'última petició",
"CmdReviewDescription": "Executa una revisió de codi estructurada sobre un fitxer, diff o PR",
+1
View File
@@ -345,6 +345,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale lädt lokalen Quellcode nicht in gehostetes Work hoch und migriert oder synchronisiert ihn nicht dorthin. Verwende {command}, um von der bei GitHub oder CNB verfügbaren Branch-Spitze zu starten. Nicht gepushte Commits, geänderte oder ignorierte Dateien, Geheimnisse und Sitzungsstatus bleiben lokal.",
"CmdRemoteEnvBrowserLabel": "Gehostetes Work von Codewhale",
"CmdRenameDescription": "Aktuelle Sitzung umbenennen",
"CmdTitleDescription": "Registerkarten-/Fenstertitel festlegen, der als [title] … im Terminaltitel erscheint",
"CmdRestoreDescription": "Workspace auf einen früheren Pre-/Post-Turn-Snapshot zurücksetzen. Ohne Argument werden die letzten Snapshots aufgelistet.",
"CmdRetryDescription": "Letzte Anfrage wiederholen",
"CmdReviewDescription": "Strukturiertes Code-Review für eine Datei, einen Diff oder PR ausführen",
+1
View File
@@ -348,6 +348,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale does not upload, migrate, or sync local source into hosted Work. Use {command} to start from the branch tip available at GitHub or CNB. Unpushed commits, dirty or ignored files, secrets, and session state stay local.",
"CmdRemoteEnvBrowserLabel": "Codewhale hosted Work",
"CmdRenameDescription": "Rename the current session",
"CmdTitleDescription": "Set a tab/window title shown as [title] … in the terminal title",
"CmdRestoreDescription": "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.",
"CmdRetryDescription": "Retry the last request",
"CmdReviewDescription": "Run a structured code review on a file, diff, or PR",
+1
View File
@@ -348,6 +348,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale no carga, migra ni sincroniza el código fuente local con el Work alojado. Usa {command} para iniciar desde la punta de la rama disponible en GitHub o CNB. Los commits sin enviar, los archivos modificados o ignorados, los secretos y el estado de la sesión permanecen locales.",
"CmdRemoteEnvBrowserLabel": "Work alojado de Codewhale",
"CmdRenameDescription": "Renombrar la sesión actual",
"CmdTitleDescription": "Establecer un título de pestaña/ventana mostrado como [title] … en el título del terminal",
"CmdRestoreDescription": "Revertir el workspace a un snapshot pre/post-turno anterior. Sin argumento, lista los snapshots recientes.",
"CmdRetryDescription": "Repetir la última solicitud",
"CmdReviewDescription": "Ejecutar una revisión de código estructurada en un archivo, diff o PR",
+1
View File
@@ -345,6 +345,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale ne téléverse, ne migre et ne synchronise pas le code source local dans le Work hébergé. Utilisez {command} pour démarrer depuis la pointe de la branche disponible sur GitHub ou CNB. Les commits non poussés, les fichiers modifiés ou ignorés, les secrets et l'état de la session restent locaux.",
"CmdRemoteEnvBrowserLabel": "Work hébergé de Codewhale",
"CmdRenameDescription": "Renommer la session en cours",
"CmdTitleDescription": "Définir un titre d'onglet/fenêtre affiché comme [title] … dans le titre du terminal",
"CmdRestoreDescription": "Restaurer le workspace à un snapshot pré/post-tour antérieur. Sans argument, liste les snapshots récents.",
"CmdRetryDescription": "Réessayer la dernière requête",
"CmdReviewDescription": "Lancer une revue de code structurée sur un fichier, un diff ou une PR",
+1
View File
@@ -345,6 +345,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale लोकल सोर्स को होस्टेड Work में अपलोड, माइग्रेट या सिंक नहीं करता। GitHub या CNB पर उपलब्ध ब्रांच टिप से शुरू करने के लिए {command} का उपयोग करें। पुश न किए गए कमिट, बदली हुई या इग्नोर की गई फ़ाइलें, सीक्रेट और सत्र स्थिति लोकल ही रहती हैं।",
"CmdRemoteEnvBrowserLabel": "Codewhale होस्टेड Work",
"CmdRenameDescription": "वर्तमान सत्र का नाम बदलें",
"CmdTitleDescription": "टर्मिनल शीर्षक में [title] … के रूप में दिखने वाला टैब/विंडो शीर्षक सेट करें",
"CmdRestoreDescription": "वर्कस्पेस को पूर्व pre/post-turn स्नैपशॉट पर वापस लाएँ। बिना आर्ग के हालिया स्नैपशॉट सूचीबद्ध करता है।",
"CmdRetryDescription": "अंतिम अनुरोध पुनः प्रयास करें",
"CmdReviewDescription": "फ़ाइल, diff या PR पर संरचित कोड समीक्षा चलाएँ",
+1
View File
@@ -345,6 +345,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale tidak mengunggah, memigrasikan, atau menyinkronkan kode sumber lokal ke Work terhosting. Gunakan {command} untuk memulai dari ujung branch yang tersedia di GitHub atau CNB. Commit yang belum di-push, file yang berubah atau diabaikan, rahasia, dan status sesi tetap lokal.",
"CmdRemoteEnvBrowserLabel": "Work terhosting Codewhale",
"CmdRenameDescription": "Ubah nama sesi saat ini",
"CmdTitleDescription": "Atur judul tab/jendela yang ditampilkan sebagai [title] … pada judul terminal",
"CmdRestoreDescription": "Kembalikan workspace ke snapshot pra/pasca-giliran sebelumnya. Tanpa argumen, menampilkan snapshot terbaru.",
"CmdRetryDescription": "Coba lagi permintaan terakhir",
"CmdReviewDescription": "Jalankan tinjauan kode terstruktur pada file, diff, atau PR",
+1
View File
@@ -348,6 +348,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale はローカルソースをホスト型 Work にアップロード、移行、同期しません。{command} を使用して、GitHub または CNB で利用可能なブランチ先端から開始してください。未プッシュのコミット、変更済みまたは無視対象のファイル、シークレット、セッション状態はローカルに残ります。",
"CmdRemoteEnvBrowserLabel": "Codewhale ホスト型 Work",
"CmdRenameDescription": "現在のセッションの名前を変更",
"CmdTitleDescription": "タブ/ウィンドウのタイトルを設定(端末タイトルに [title] … と表示)",
"CmdRestoreDescription": "ワークスペースを以前のターン前/後スナップショットへロールバック。引数なしで最近のスナップショットを一覧表示。",
"CmdRetryDescription": "直前のリクエストを再試行",
"CmdReviewDescription": "ファイル・diff・PR に対して構造化コードレビューを実行",
+1
View File
@@ -348,6 +348,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale은 로컬 소스를 호스팅 Work에 업로드, 마이그레이션 또는 동기화하지 않습니다. {command}을 사용하여 GitHub 또는 CNB에서 사용할 수 있는 브랜치 끝에서 시작하세요. 푸시하지 않은 커밋, 변경되었거나 무시된 파일, 비밀, 세션 상태는 로컬에 남습니다.",
"CmdRemoteEnvBrowserLabel": "Codewhale 호스팅 Work",
"CmdRenameDescription": "현재 세션 이름을 바꿉니다",
"CmdTitleDescription": "터미널 제목에 [title] … 로 표시되는 탭/창 제목을 설정",
"CmdRestoreDescription": "작업 공간을 이전 턴 전/후 스냅샷으로 되돌립니다. 인자가 없으면 최근 스냅샷 목록을 표시합니다.",
"CmdRetryDescription": "마지막 요청을 재시도합니다",
"CmdReviewDescription": "파일, diff, PR에 대해 구조화된 코드 리뷰를 실행합니다",
+1
View File
@@ -348,6 +348,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "O Codewhale não envia, migra nem sincroniza o código-fonte local com o Work hospedado. Use {command} para iniciar a partir da ponta da branch disponível no GitHub ou CNB. Commits não enviados, arquivos modificados ou ignorados, segredos e o estado da sessão permanecem locais.",
"CmdRemoteEnvBrowserLabel": "Work hospedado do Codewhale",
"CmdRenameDescription": "Renomear a sessão atual",
"CmdTitleDescription": "Definir um título de aba/janela mostrado como [title] … no título do terminal",
"CmdRestoreDescription": "Reverter o workspace a um snapshot pré/pós-turno anterior. Sem argumento, lista os snapshots recentes.",
"CmdRetryDescription": "Repetir a última requisição",
"CmdReviewDescription": "Executar uma revisão de código estruturada em um arquivo, diff ou PR",
+1
View File
@@ -345,6 +345,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale не загружает, не переносит и не синхронизирует локальный исходный код с размещённым Work. Используйте {command}, чтобы начать с вершины ветки, доступной на GitHub или CNB. Неотправленные коммиты, изменённые или игнорируемые файлы, секреты и состояние сеанса остаются локальными.",
"CmdRemoteEnvBrowserLabel": "Размещённый Work Codewhale",
"CmdRenameDescription": "Переименовать текущую сессию",
"CmdTitleDescription": "Установить заголовок вкладки/окна, отображаемый как [title] … в заголовке терминала",
"CmdRestoreDescription": "Откатить рабочую область к прежнему снимку до/после хода. Без аргумента — список недавних снимков.",
"CmdRetryDescription": "Повторить последний запрос",
"CmdReviewDescription": "Запустить структурированное ревью кода для файла, diff или PR",
+1
View File
@@ -345,6 +345,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale не завантажує, не переносить і не синхронізує локальний вихідний код із розміщеним Work. Скористайтеся {command}, щоб почати з вершини гілки, доступної на GitHub або CNB. Ненадіслані коміти, змінені чи ігноровані файли, секрети й стан сеансу залишаються локальними.",
"CmdRemoteEnvBrowserLabel": "Розміщений Work Codewhale",
"CmdRenameDescription": "Перейменувати поточний сеанс",
"CmdTitleDescription": "Встановити заголовок вкладки/вікна, який відображається як [title] … у заголовку термінала",
"CmdRestoreDescription": "Відкотити робочу область до попереднього знімка до/після кроку. Без аргумента — показує останні знімки.",
"CmdRetryDescription": "Повторити останній запит",
"CmdReviewDescription": "Запустити структурований огляд коду для файлу, diff або PR",
+1
View File
@@ -348,6 +348,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale không tải lên, di chuyển hoặc đồng bộ mã nguồn cục bộ vào Work được lưu trữ. Dùng {command} để bắt đầu từ đầu nhánh hiện có trên GitHub hoặc CNB. Các commit chưa đẩy, tệp đã thay đổi hoặc bị bỏ qua, bí mật và trạng thái phiên vẫn ở cục bộ.",
"CmdRemoteEnvBrowserLabel": "Work được Codewhale lưu trữ",
"CmdRenameDescription": "Đổi tên phiên làm việc hiện tại",
"CmdTitleDescription": "Đặt tiêu đề tab/cửa sổ hiển thị dạng [title] … trong tiêu đề terminal",
"CmdRestoreDescription": "Khôi phục không gian làm việc về bản chụp trước/sau lượt. Nếu không có đối số, hiển thị các bản chụp gần đây.",
"CmdRetryDescription": "Thử lại yêu cầu gần nhất",
"CmdReviewDescription": "Chạy một quy trình xem xét mã nguồn có cấu trúc trên tệp, diff hoặc PR",
+1
View File
@@ -348,6 +348,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale 不会将本地源代码上传、迁移或同步到托管 Work。使用 {command} 从 GitHub 或 CNB 上可用的分支尖端开始。未推送的提交、已修改或已忽略的文件、密钥和会话状态仍保留在本地。",
"CmdRemoteEnvBrowserLabel": "Codewhale 托管 Work",
"CmdRenameDescription": "重命名当前会话",
"CmdTitleDescription": "设置标签页标题,终端标题显示为 [title] …",
"CmdRestoreDescription": "将工作区回滚到此前的轮次前/后快照。不带参数时列出最近的快照。",
"CmdRetryDescription": "重试上一次请求",
"CmdReviewDescription": "对文件、diff 或 PR 进行结构化代码审查",
+1
View File
@@ -295,6 +295,7 @@
"CmdRemoteEnvSourceCustodyPolicy": "Codewhale 不會將本機原始碼上傳、移轉或同步到託管 Work。使用 {command} 從 GitHub 或 CNB 上可用的分支尖端開始。尚未推送的提交、已修改或已忽略的檔案、密鑰和工作階段狀態仍保留在本機。",
"CmdRemoteEnvBrowserLabel": "Codewhale 託管 Work",
"CmdRenameDescription": "重新命名目前工作階段",
"CmdTitleDescription": "設定標籤頁/視窗標題,終端機標題顯示為 [title] …",
"CmdRestoreDescription": "將工作區回復到此前的輪次前/後快照。不帶參數時列出最近的快照。",
"CmdRetryDescription": "重試上一次請求",
"CmdReviewDescription": "對檔案、diff 或 PR 進行結構化程式碼審查",
@@ -281,6 +281,11 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
};
Some(config.deepseek_base_url())
}
"title" | "window_title" | "tab_title" => Some(
app.window_title_prefix()
.map(str::to_string)
.unwrap_or_else(|| "(unset)".to_string()),
),
"provider_url" | "provider_base_url" | "endpoint" => {
let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
{
@@ -1758,6 +1763,30 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
"base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
);
}
"title" | "window_title" | "tab_title" => {
let value = value.trim();
if value.is_empty() {
return CommandResult::error(
"title cannot be empty; use /title off to clear a session title",
);
}
if value.chars().count() > 100 {
return CommandResult::error("Title too long (max 100 characters)");
}
let suffix = if persist {
match persist_root_string_key(app.config_path.as_deref(), "title", value) {
Ok(path) => format!(" (saved to {})", path.display()),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
" (session only, add --save to persist)".to_string()
};
app.title_default = Some(value.to_string());
app.needs_redraw = true;
return CommandResult::message(format!(
"title = {value}{suffix} — terminal window titles now read [\"{value}\"] … until /title overrides this session"
));
}
"provider_url" | "provider_base_url" | "endpoint" => {
let value = match resolve_provider_url_value(app.api_provider, value) {
Ok(value) => value,
@@ -161,6 +161,7 @@ pub fn clear(app: &mut App) -> CommandResult {
app.current_session_id = None;
app.current_session_metadata = None;
app.session_title = None;
app.window_title = None;
let locale = app.ui_locale;
let message = tr(locale, MessageId::ClearConversation).to_string();
CommandResult::with_message_and_action(
@@ -20,6 +20,7 @@ mod resume;
mod save;
mod sessions;
mod structcopy;
mod title;
mod tree;
// This group dir intentionally has a `session.rs` child module with the same
// name. The module_inception allow is a permanent structure rationale, not
@@ -39,6 +40,10 @@ impl CommandGroup for SessionCommands {
rename::RenameCmd::info(),
rename::RenameCmd::execute,
)),
Box::new(FunctionCommand::new(
title::TitleCmd::info(),
title::TitleCmd::execute,
)),
Box::new(FunctionCommand::new(
save::SaveCmd::info(),
save::SaveCmd::execute,
@@ -153,6 +153,8 @@ pub fn fork_from_session(app: &mut App, session_id_or_prefix: &str) -> CommandRe
app.current_session_id = Some(forked.metadata.id.clone());
app.current_session_metadata = Some(forked.metadata.clone());
app.session_title = Some(forked.metadata.title.clone());
// A fork starts as its own session: no inherited tab/window title.
app.window_title = None;
let parent_label = crate::session_manager::truncate_id(&source_session.metadata.id).to_string();
let fork_label = crate::session_manager::truncate_id(&forked.metadata.id).to_string();
CommandResult::with_message_and_action(
@@ -273,6 +275,7 @@ pub fn fork(app: &mut App) -> CommandResult {
app.current_session_id = Some(forked.metadata.id.clone());
app.current_session_metadata = Some(forked.metadata.clone());
app.session_title = Some(forked.metadata.title.clone());
app.window_title = None;
let fork_id = forked.metadata.id.clone();
let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string();
let fork_label = crate::session_manager::truncate_id(&fork_id).to_string();
@@ -331,6 +334,7 @@ pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult {
app.current_session_id = Some(new_id.clone());
app.current_session_metadata = None;
app.session_title = Some(crate::session_manager::DEFAULT_SESSION_TITLE.to_string());
app.window_title = None;
app.scroll_to_bottom();
CommandResult::with_message_and_action(
@@ -0,0 +1,275 @@
//! `/title` command — set a distinct tab/window title for the current
//! session, shown as `[title] …` in front of the terminal window title.
//!
//! This is deliberately separate from `/rename`, which changes the session
//! *name* shown in the composer border and session picker. `/title` only
//! affects the terminal window/tab title, so parallel sessions — each in
//! its own terminal window — are identifiable at a glance while they are
//! reasoning, using tools, or waiting.
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::session_manager::{SessionManager, update_session};
use crate::tui::app::App;
use super::CommandResult;
const MAX_TITLE_LEN: usize = 100;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "title",
aliases: &["tabtitle", "window-title"],
usage: "/title [new title|off]",
description_id: MessageId::CmdTitleDescription,
};
pub(in crate::commands) struct TitleCmd;
impl RegisterCommand for TitleCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
set_window_title(app, arg)
}
}
/// Set (or clear) the current session's tab/window title.
///
/// - `/title <new title>` — set the window-title prefix for this session and
/// persist it on the saved session so it survives restarts.
/// - `/title off` — clear the session-level title; the `title` config
/// default (if any) still applies.
/// - `/title` with no argument — report the current effective title.
pub fn set_window_title(app: &mut App, arg: Option<&str>) -> CommandResult {
let arg = arg.map(str::trim).filter(|s| !s.is_empty());
let Some(arg) = arg else {
let current = app.window_title_prefix().unwrap_or("unset");
let source = if app.window_title.is_some() {
" (session)"
} else if app.title_default.is_some() {
" (config default)"
} else {
""
};
return CommandResult::message(format!("Window title: [{current}]{source}"));
};
if arg == "off" || arg == "clear" || arg == "none" {
let manager = match resolve_manager() {
Ok(manager) => manager,
Err(error) => return error,
};
return set_window_title_with_manager(app, None, &manager);
}
if arg.chars().count() > MAX_TITLE_LEN {
return CommandResult::error(format!("Title too long (max {MAX_TITLE_LEN} characters)"));
}
let manager = match resolve_manager() {
Ok(manager) => manager,
Err(error) => return error,
};
set_window_title_with_manager(app, Some(arg.to_string()), &manager)
}
#[allow(clippy::result_large_err)]
fn resolve_manager() -> Result<SessionManager, CommandResult> {
SessionManager::default_location()
.map_err(|e| CommandResult::error(format!("Could not open sessions directory: {e}")))
}
/// Set or clear the session-level window title with an explicit manager.
///
/// Mirrors the `/rename` write path (snapshot Work state and live metadata
/// before saving so the write cannot revert unsaved messages), then updates
/// `app.window_title` in the same step the disk write lands.
pub(crate) fn set_window_title_with_manager(
app: &mut App,
title: Option<String>,
manager: &SessionManager,
) -> CommandResult {
let session_id = match &app.current_session_id {
Some(id) => id.clone(),
None => {
return CommandResult::error(
"No active session. Send a message first to start a session.",
);
}
};
let mut session = match manager.load_session(&session_id) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Could not load session: {e}")),
};
// Sync with current App state to avoid overwriting unsaved messages.
session = update_session(
session,
&app.api_messages,
u64::from(app.session.total_tokens),
app.system_prompt.as_ref(),
);
session.work_state = match app.work_state_snapshot() {
Ok(state) => state,
Err(err) => {
return CommandResult::error(format!(
"Could not snapshot Work state before setting title: {err}"
));
}
};
session.context_references = app.session_context_references.clone();
session.artifacts = app.session_artifacts.clone();
session.last_auto_route = app.auto_route_for_persistence();
session
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
session.metadata.workspace.clone_from(&app.workspace);
session.metadata.mode = Some(app.mode.as_setting().to_string());
app.sync_cost_to_metadata(&mut session.metadata);
session.window_title = title.clone();
match manager.save_session(&session) {
Ok(_) => {
app.window_title = title.clone();
// The render loop syncs the resolved prefix into the terminal
// title; force a frame so the change lands immediately.
app.needs_redraw = true;
if let Err(err) = app.publish_pending_work_state() {
return CommandResult::error(format!(
"Window title saved, but Work views were not published: {err}"
));
}
match title {
Some(title) => CommandResult::message(format!(
"Window title set to \"{title}\" — the terminal tab now reads [\"{title}\"] …"
)),
None => CommandResult::message(
"Window title cleared (the config default still applies if set)",
),
}
}
Err(e) => CommandResult::error(format!("Could not save session: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::session_manager::{SessionManager, create_saved_session_with_mode};
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn make_app(tmpdir: &TempDir) -> App {
App::new(
TuiOptions {
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
..crate::test_support::test_tui_options(tmpdir.path())
},
&Config::default(),
)
}
fn make_session_manager(tmpdir: &TempDir) -> SessionManager {
SessionManager::new(tmpdir.path().join("sessions")).unwrap()
}
#[test]
fn no_active_session_reports_usage_error() {
let tmp = TempDir::new().unwrap();
let mut app = make_app(&tmp);
app.current_session_id = None;
let result = set_window_title(&mut app, Some("task-7"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("No active session"));
}
#[test]
fn set_title_persists_on_the_saved_session() {
let tmp = TempDir::new().unwrap();
let manager = make_session_manager(&tmp);
let mut app = make_app(&tmp);
let mut session = create_saved_session_with_mode(
&[],
"deepseek-v4-pro",
tmp.path(),
0,
None,
Some("agent"),
);
session.metadata.id = "title-test".to_string();
session.metadata.title = "Original Name".to_string();
manager.save_session(&session).unwrap();
app.current_session_id = Some("title-test".to_string());
let result =
set_window_title_with_manager(&mut app, Some("parallel-task".to_string()), &manager);
assert!(!result.is_error, "unexpected error: {:?}", result.message);
assert_eq!(app.window_title.as_deref(), Some("parallel-task"));
// `/rename`-style session name stays untouched.
assert_eq!(app.session_title, None);
let reloaded = manager.load_session("title-test").unwrap();
assert_eq!(reloaded.window_title.as_deref(), Some("parallel-task"));
assert_eq!(reloaded.metadata.title, "Original Name");
}
#[test]
fn clear_title_removes_the_session_level_title() {
let tmp = TempDir::new().unwrap();
let manager = make_session_manager(&tmp);
let mut app = make_app(&tmp);
let mut session = create_saved_session_with_mode(
&[],
"deepseek-v4-pro",
tmp.path(),
0,
None,
Some("agent"),
);
session.metadata.id = "title-clear".to_string();
session.window_title = Some("stale".to_string());
manager.save_session(&session).unwrap();
app.current_session_id = Some("title-clear".to_string());
let result = set_window_title_with_manager(&mut app, None, &manager);
assert!(!result.is_error, "unexpected error: {:?}", result.message);
assert_eq!(app.window_title, None);
assert_eq!(
manager.load_session("title-clear").unwrap().window_title,
None
);
}
#[test]
fn empty_argument_reports_the_effective_title() {
let tmp = TempDir::new().unwrap();
let mut app = make_app(&tmp);
app.title_default = Some("workspace-x".to_string());
let result = set_window_title(&mut app, None);
assert!(!result.is_error);
assert!(result.message.unwrap().contains("[workspace-x]"));
app.window_title = Some("session-specific".to_string());
let result = set_window_title(&mut app, None);
assert!(!result.is_error);
assert!(result.message.unwrap().contains("[session-specific]"));
}
#[test]
fn oversized_title_is_rejected() {
let tmp = TempDir::new().unwrap();
let mut app = make_app(&tmp);
app.current_session_id = Some("any".to_string());
let long = "x".repeat(MAX_TITLE_LEN + 1);
let result = set_window_title(&mut app, Some(&long));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Title too long"));
}
}
+9
View File
@@ -2559,6 +2559,14 @@ pub struct Config {
/// Optional extra HTTP headers sent to model API requests.
#[serde(alias = "httpHeaders")]
pub http_headers: Option<HashMap<String, String>>,
/// Optional user-facing tab/window title shown as `[title] …` in front of
/// the terminal window title (the `Codewhale` / `reasoning…` / `done.`
/// states). This is the default for every session in this config scope;
/// the `/title` command overrides it per session, and `/config title …
/// --save` persists a new default here. Multi-window setups can point each
/// workspace at its own `--config` file (or profile) so alt-tabbed
/// sessions are identifiable at a glance.
pub title: Option<String>,
#[serde(alias = "defaultTextModel")]
pub default_text_model: Option<String>,
#[serde(alias = "authMode")]
@@ -9723,6 +9731,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
}
},
mini_window: override_cfg.mini_window.or(base.mini_window),
title: override_cfg.title.or(base.title),
}
}
+33
View File
@@ -963,6 +963,39 @@ fn workflow_config_defaults_when_omitted_and_overrides_round_trip() {
assert_eq!(merged.workflow_config(), workflow);
}
#[test]
fn window_title_config_parses_and_overlays() {
// `title` is a plain optional root key: absent config → `None`.
let omitted: Config = toml::from_str("").expect("empty config");
assert_eq!(omitted.title, None);
let config: Config = toml::from_str(r#"title = "workspace-x""#).expect("parse title config");
assert_eq!(config.title.as_deref(), Some("workspace-x"));
// Overlay wins over the base when both define a title.
let merged = merge_config(
Config {
title: Some("base-title".to_string()),
..Config::default()
},
Config {
title: Some("override-title".to_string()),
..Config::default()
},
);
assert_eq!(merged.title.as_deref(), Some("override-title"));
// Base title survives an overlay that does not mention it.
let merged = merge_config(
Config {
title: Some("base-title".to_string()),
..Config::default()
},
Config::default(),
);
assert_eq!(merged.title.as_deref(), Some("base-title"));
}
#[test]
fn search_provider_defaults_to_firecrawl() {
assert_eq!(SearchProvider::default(), SearchProvider::Firecrawl);
+2
View File
@@ -487,6 +487,7 @@ pub enum MessageId {
CmdRemoteEnvSourceCustodyPolicy,
CmdRemoteEnvBrowserLabel,
CmdRenameDescription,
CmdTitleDescription,
CmdRestoreDescription,
CmdRetryDescription,
CmdReviewDescription,
@@ -2040,6 +2041,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdRemoteEnvSourceCustodyPolicy,
MessageId::CmdRemoteEnvBrowserLabel,
MessageId::CmdRenameDescription,
MessageId::CmdTitleDescription,
MessageId::CmdRestoreDescription,
MessageId::CmdRetryDescription,
MessageId::CmdReviewDescription,
+1
View File
@@ -232,6 +232,7 @@ fn saved_session_with_blocks(blocks: Vec<crate::models::ContentBlock>) -> SavedS
context_references: Vec::new(),
artifacts: Vec::new(),
work_state: None,
window_title: None,
last_auto_route: None,
}
}
+10
View File
@@ -536,6 +536,12 @@ pub struct SavedSession {
/// To-do and plan state shown in the Work sidebar.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub work_state: Option<SessionWorkState>,
/// User-configured tab/window title for this session (`/title`), shown as
/// `[title] …` in front of the terminal window title. Optional for
/// backward-compatible session loads; absent sessions use the `title`
/// config default instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_title: Option<String>,
/// Most recent accepted/completed Auto decision, when the saved model mode
/// is `auto`. Optional for backward-compatible session loads.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -675,6 +681,7 @@ impl SavedSession {
context_references: Vec::new(),
artifacts: Vec::new(),
work_state: None,
window_title: None,
last_auto_route: None,
})
}
@@ -1765,6 +1772,7 @@ pub fn create_saved_session_with_id_and_mode(
context_references: Vec::new(),
artifacts: Vec::new(),
work_state: None,
window_title: None,
last_auto_route: None,
}
}
@@ -2326,6 +2334,7 @@ mod tests {
context_references: Vec::new(),
artifacts: Vec::new(),
work_state: None,
window_title: None,
last_auto_route: None,
};
manager.save_session(&session).expect("save");
@@ -2365,6 +2374,7 @@ mod tests {
context_references: Vec::new(),
artifacts: Vec::new(),
work_state: None,
window_title: None,
last_auto_route: None,
};
manager.save_session(&session).expect("save empty");
+24
View File
@@ -1976,6 +1976,17 @@ pub struct App {
/// Updated when `EngineEvent::SessionUpdated` fires or a saved session is loaded.
pub session_title: Option<String>,
/// User-configured tab/window title for the current session, shown as
/// `[title] …` in front of the terminal window title. Set with the
/// `/title` command and persisted on the saved session; distinct from
/// [`session_title`](Self::session_title), which is the session *name*
/// shown in the composer border and session picker.
pub window_title: Option<String>,
/// Default tab/window title from the `title` config key (or a profile
/// overlay). Used when the current session has no explicit
/// [`window_title`](Self::window_title).
pub title_default: Option<String>,
/// Post-turn receipt rendered as transient composer chrome.
/// Set when a turn completes; cleared when a new turn starts or after expiry.
pub receipt_text: Option<String>,
@@ -4485,6 +4496,19 @@ impl App {
)
}
/// Resolve the `[title] …` window-title prefix for the terminal title.
///
/// Precedence: the session-level `/title` override wins over the `title`
/// config default; neither configured means no prefix (the historical
/// window titles stay byte-for-byte unchanged).
#[must_use]
pub(crate) fn window_title_prefix(&self) -> Option<&str> {
self.window_title
.as_deref()
.or(self.title_default.as_deref())
.filter(|prefix| !prefix.trim().is_empty())
}
/// Bridge the centralized policy into transcript renderers that still
/// accept the legacy boolean motion contract.
#[must_use]
+2
View File
@@ -989,6 +989,8 @@ impl App {
mention_menu_behavior: settings.mention_menu_behavior.clone(),
workspace_follow_symlinks: settings.workspace_follow_symlinks,
session_title: None,
window_title: None,
title_default: config.title.clone(),
receipt_text: None,
receipt_started_at: None,
tool_evidence: Vec::new(),
+131 -5
View File
@@ -487,6 +487,59 @@ pub fn clear_taskbar_progress() {
set_taskbar_progress(0, None);
}
/// User-configured window-title prefix, rendered as `[prefix] …` in front of
/// every terminal window title. Empty means no prefix — the historical
/// byte-for-byte behavior. Set via the `/title` command (session level) or
/// the `title` config key (default level); the render loop syncs it here
/// through [`set_title_prefix`].
static TITLE_PREFIX: OnceLock<Mutex<String>> = OnceLock::new();
pub(crate) fn title_prefix_slot() -> &'static Mutex<String> {
TITLE_PREFIX.get_or_init(|| Mutex::new(String::new()))
}
/// Serialise tests that touch the process-global title prefix so parallel
/// threads cannot leak a prefix into an unrelated assertion. Also used by
/// `underwater` tests that drive [`set_title_prefix`] through the render
/// loop.
#[cfg(test)]
pub(crate) fn title_prefix_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Set the `[prefix] …` window-title prefix, or clear it with `None`/empty.
///
/// Change detection keeps the per-frame render-loop sync free when the title
/// did not move; on an actual change the running title is redrawn immediately
/// so alt-tabbed sessions pick up the new identity without waiting for the
/// next activity-verb update.
pub fn set_title_prefix(prefix: Option<&str>) {
let prefix = prefix.unwrap_or_default().trim();
let mut slot = title_prefix_slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if slot.as_str() == prefix {
return;
}
slot.clear();
slot.push_str(prefix);
if TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
let base = title_animation_base()
.lock()
.map_or_else(|_| "Codewhale".to_string(), |base| base.clone());
let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
set_terminal_title(&title_activity_label(
&base,
Duration::ZERO,
TERMINAL_FOCUSED.load(Ordering::SeqCst),
motion,
));
}
}
/// Shared flag controlling the title activity marker. Set to `true` by
/// `start_title_animation()`, cleared by `stop_title_animation()`.
static TITLE_ANIMATION_RUNNING: AtomicBool = AtomicBool::new(false);
@@ -562,11 +615,26 @@ fn title_activity_label(base: &str, elapsed: Duration, focused: bool, motion: bo
// Static title when motion is off or the window is focused: one whale +
// state, no competing spinner in the focused app chrome.
if !motion || focused {
return format!("🐳 {body}");
return decorate_title(&format!("🐳 {body}"));
}
let frame = TITLE_WHALE_FRAMES
[(elapsed.as_millis() / TITLE_FRAME_HOLD.as_millis()) as usize % TITLE_WHALE_FRAMES.len()];
format!("{frame} {body}")
decorate_title(&format!("{frame} {body}"))
}
/// Apply the `[prefix] ` decoration to a raw window-title body.
///
/// With no configured prefix this returns the input unchanged, so existing
/// installs keep the exact titles they had before this feature landed.
fn decorate_title(raw: &str) -> String {
let prefix = title_prefix_slot()
.lock()
.map_or_else(|_| String::new(), |prefix| prefix.clone());
if prefix.is_empty() {
raw.to_string()
} else {
format!("[{prefix}] {raw}")
}
}
/// Write OSC 0 (set window title) sequence.
@@ -671,7 +739,7 @@ pub fn stop_title_animation() {
// Always show the completion marker so quiet-sound modes still communicate
// finish state in the window title; interaction clears it.
COMPLETION_MARKER_SHOWN.store(true, Ordering::SeqCst);
set_terminal_title("✓ done");
set_terminal_title(&decorate_title("✓ done"));
play_completion_sound();
}
@@ -683,7 +751,7 @@ pub fn stop_title_animation_quietly() {
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
TITLE_ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
set_terminal_title("Codewhale");
set_terminal_title(&decorate_title("Codewhale"));
}
/// Clear the completion marker from the title when the user interacts.
@@ -692,7 +760,7 @@ pub fn stop_title_animation_quietly() {
/// marker doesn't persist once the user is back at the terminal.
pub fn reset_title_on_interaction() {
if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) {
set_terminal_title("Codewhale");
set_terminal_title(&decorate_title("Codewhale"));
}
}
@@ -1130,6 +1198,7 @@ mod tests {
#[test]
fn title_whale_is_static_when_focused_or_motion_disabled() {
let _guard = prefix_lock();
if let Ok(mut verb) = title_activity_verb().lock() {
"working…".clone_into(&mut *verb);
}
@@ -1157,6 +1226,63 @@ mod tests {
assert_eq!(TITLE_FRAME_HOLD, Duration::from_millis(800));
}
/// Serialise tests that touch the process-global title prefix so parallel
/// threads cannot leak a prefix into an unrelated assertion.
fn prefix_lock() -> std::sync::MutexGuard<'static, ()> {
title_prefix_test_lock()
}
#[test]
fn title_prefix_decorates_activity_label() {
let _guard = prefix_lock();
set_title_prefix(Some("task-7"));
if let Ok(mut verb) = title_activity_verb().lock() {
"reasoning…".clone_into(&mut *verb);
}
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, true, true),
"[task-7] 🐳 reasoning…"
);
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, false, true),
"[task-7] 🐳 reasoning…"
);
set_title_prefix(None);
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, true, true),
"🐳 reasoning…"
);
}
#[test]
fn title_prefix_decorates_rest_and_completion_titles() {
let _guard = prefix_lock();
set_title_prefix(Some("feature/x"));
assert_eq!(decorate_title("Codewhale"), "[feature/x] Codewhale");
assert_eq!(decorate_title("✓ done"), "[feature/x] ✓ done");
set_title_prefix(None);
assert_eq!(decorate_title("Codewhale"), "Codewhale");
assert_eq!(decorate_title("✓ done"), "✓ done");
// Empty/whitespace prefixes behave exactly like `None`.
set_title_prefix(Some(" "));
assert_eq!(decorate_title("Codewhale"), "Codewhale");
set_title_prefix(None);
}
#[test]
fn title_prefix_change_detection_skips_redundant_writes() {
let _guard = prefix_lock();
set_title_prefix(Some("alpha"));
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "alpha");
// Setting the same prefix again must not clear the stored value.
set_title_prefix(Some("alpha"));
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "alpha");
set_title_prefix(Some("beta"));
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "beta");
set_title_prefix(None);
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "");
}
/// Serialise tests that mutate process-global environment or notification
/// sound state while the test harness runs them in parallel threads.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+1
View File
@@ -2540,6 +2540,7 @@ pub(crate) fn apply_loaded_session(
app.current_session_metadata = Some(session.metadata.clone());
app.session_artifacts = session.artifacts.clone();
app.session_title = Some(session.metadata.title.clone());
app.window_title = session.window_title.clone();
app.workspace_context = None;
app.workspace_context_refreshed_at = None;
if let Some(sp) = session.system_prompt.as_ref() {
+1
View File
@@ -434,6 +434,7 @@ pub(crate) fn build_session_snapshot(
session.artifacts = app.session_artifacts.clone();
session.work_state = work_state;
session.last_auto_route = app.auto_route_for_persistence();
session.window_title.clone_from(&app.window_title);
app.current_session_metadata = Some(session.metadata.clone());
// Claim ownership of this session for the process. From here on the
// Runtime API refuses external renames/archives of it with a typed 409
+1
View File
@@ -5415,6 +5415,7 @@ fn saved_session_with_messages(messages: Vec<Message>) -> SavedSession {
context_references: Vec::new(),
artifacts: Vec::new(),
work_state: None,
window_title: None,
last_auto_route: None,
}
}
+55
View File
@@ -515,6 +515,10 @@ pub(crate) fn sync_title_activity(app: &App) {
crate::tui::notifications::set_title_motion_enabled(
app.motion_policy().allows_decorative() && app.status_indicator != "off",
);
// Keep the `[title] …` window-title prefix in step with the session and
// config defaults; change detection inside makes this free when nothing
// moved.
crate::tui::notifications::set_title_prefix(app.window_title_prefix());
if app.is_loading
|| matches!(
ShellPhase::from_app(app),
@@ -1724,6 +1728,57 @@ mod tests {
}
}
#[test]
fn window_title_prefix_prefers_session_over_config_default() {
let mut app = test_app();
// Nothing configured: no prefix at all.
assert_eq!(app.window_title_prefix(), None);
// Config default alone.
app.title_default = Some("workspace-x".to_string());
assert_eq!(app.window_title_prefix(), Some("workspace-x"));
// Session-level `/title` wins over the config default.
app.window_title = Some("task-7".to_string());
assert_eq!(app.window_title_prefix(), Some("task-7"));
// Clearing the session title falls back to the default.
app.window_title = None;
assert_eq!(app.window_title_prefix(), Some("workspace-x"));
// Whitespace-only titles count as unset.
app.window_title = Some(" ".to_string());
assert_eq!(app.window_title_prefix(), None);
app.window_title = None;
}
#[test]
fn sync_title_activity_pushes_the_resolved_prefix() {
// The render-loop sync must reach the notifications layer so the
// terminal title actually carries the prefix.
let _guard = crate::tui::notifications::title_prefix_test_lock();
crate::tui::notifications::set_title_prefix(None);
let mut app = test_app();
app.window_title = Some("sync-check".to_string());
sync_title_activity(&app);
assert_eq!(
crate::tui::notifications::title_prefix_slot()
.lock()
.unwrap()
.as_str(),
"sync-check"
);
app.window_title = None;
sync_title_activity(&app);
assert_eq!(
crate::tui::notifications::title_prefix_slot()
.lock()
.unwrap()
.as_str(),
""
);
}
#[test]
fn launch_row_hitboxes_follow_responsive_render_rows() {
let mut launch = launch();