Make home reversible and clarify composer submission

/home revisits the whale, recent sessions and MCP without resetting the
conversation or draft or replaying the reveal. Escape, clicking Back, or
typing returns to the same conversation. Keep the previous dashboard under
/overview and /stats. Highlighted home rows own Enter over a preserved draft;
MCP remedies open the manager when replacing input would lose user text.
Only painted rows are keyboard navigable, including zero-height panes.

Use a bold return-arrow submit cue and quiet dot when inactive, with ASCII
fallbacks. Capitalize English Done and update all complete locale packs for
home navigation. Refresh two fixed-width settings preview goldens for the
intentional quiet workbar that CI identified as its only macOS test failure.

Validation:
- Initial focused composer/home/frame checks: 53 passed, 0 failed.
- Final home, config preview and localization checks: 53 passed, 0 failed.
- Launch rows/MCP/theme checks: 27 passed, 0 failed, 1 fixture generator ignored.
  Counts overlap; they are not a combined unique total.
- Scoped TUI/localization Clippy with lib/tests passed.
- npm test: 66 + 14 + 471 passed, 0 failed; npm run check:web passed.
- Blocking-call budget passed; scoped rustfmt passed.
- Diff whitespace check passed except intentional fixed-width golden padding.
- Feature-enabled real-PTY harness compiled; optimized-binary PTY checks pending.

No public release or paid provider requests. Independent Linux startup and
tmux color evidence remain unresolved.
This commit is contained in:
CodeWhale Bot committed 2026-09-20 00:30:39 -07:00
1 parent d0adadcd22
commit 1ad01490f7
33 files changed
+405 -98

No files matched your search

+4 -1
View File
@@ -436,7 +436,10 @@
"CmdHfDescription": "Inspecciona la configuració i els conceptes de Hugging Face MCP",
"CmdHelpDescription": "Entén conceptes, comandes i dreceres de teclat",
"CmdProfileDescription": "Canvia a un perfil de configuració amb nom",
"CmdHomeDescription": "Mostra el tauler inicial amb estadístiques i accions ràpides",
"CmdHomeDescription": "Obre l’inici sense sortir de la conversa actual",
"CmdOverviewDescription": "Mostra el tauler inicial amb estadístiques i accions ràpides",
"HomeBackToConversation": "Torna a la conversa",
"HomeNavigationBusy": "Acaba o atura la feina actual abans d’obrir l’inici.",
"CmdHooksDescription": "Llista els hooks de cicle de vida configurats (només lectura)",
"CmdAgentDescription": "Obre una sessió persistent de subagent",
"CmdGoalDescription": "Continuar un sol objectiu al llarg de diversos torns",
+4 -1
View File
@@ -436,7 +436,10 @@
"CmdHfDescription": "Hugging-Face-MCP-Setup und -Konzepte ansehen",
"CmdHelpDescription": "Konzepte, Befehle und Tastenkürzel verstehen",
"CmdProfileDescription": "Zu einem benannten Konfigprofil wechseln",
"CmdHomeDescription": "Home-Dashboard mit Statistiken und Schnellaktionen anzeigen",
"CmdHomeDescription": "Startseite öffnen und die aktuelle Unterhaltung beibehalten",
"CmdOverviewDescription": "Home-Dashboard mit Statistiken und Schnellaktionen anzeigen",
"HomeBackToConversation": "Zurück zur Unterhaltung",
"HomeNavigationBusy": "Beende oder stoppe die aktuelle Arbeit, bevor du die Startseite öffnest.",
"CmdHooksDescription": "Konfigurierte Lifecycle-Hooks auflisten (schreibgeschützt)",
"CmdAgentDescription": "Persistente Sub-Agent-Sitzung öffnen",
"CmdGoalDescription": "Über mehrere Turns hinweg an einem Ziel weiterarbeiten",
+6 -3
View File
@@ -439,7 +439,10 @@
"CmdHfDescription": "Inspect Hugging Face MCP setup and concepts",
"CmdHelpDescription": "Concepts, commands, and keybindings",
"CmdProfileDescription": "Switch to a named config profile",
"CmdHomeDescription": "Show the home dashboard",
"CmdHomeDescription": "Open home without leaving the current conversation",
"CmdOverviewDescription": "Show the home dashboard",
"HomeBackToConversation": "Back to conversation",
"HomeNavigationBusy": "Finish or stop the current work before opening home.",
"CmdHooksDescription": "List configured lifecycle hooks (read-only)",
"CmdAgentDescription": "Open a persistent sub-agent session",
"CmdGoalDescription": "Work toward one objective across turns",
@@ -1201,7 +1204,7 @@
"ToolFamilyVerify": "verify",
"ToolFamilyThink": "think",
"ToolFamilyGeneric": "tool",
"ToolReceiptDone": "done",
"ToolReceiptDone": "Done",
"ToolReceiptLinesSingular": "1 line",
"ToolReceiptLinesPlural": "{count} lines",
"CmdVoiceDescription": "Dictate into the composer",
@@ -1356,7 +1359,7 @@
"PhaseSubagents": "sub-agents underway",
"PhaseVerifying": "verifying",
"PhaseWaitingOnYou": "waiting on you",
"PhaseDone": "done",
"PhaseDone": "Done",
"PhaseFailed": "failed",
"PhaseFinishing": "finishing",
"ChipModeAct": "work",
+4 -1
View File
@@ -439,7 +439,10 @@
"CmdHfDescription": "Inspeccionar configuracion y conceptos de Hugging Face MCP",
"CmdHelpDescription": "Entender conceptos, comandos y atajos",
"CmdProfileDescription": "Cambiar a un perfil de configuración con nombre",
"CmdHomeDescription": "Mostrar el panel inicial con estadísticas y acciones rápidas",
"CmdHomeDescription": "Abrir el inicio sin salir de la conversación actual",
"CmdOverviewDescription": "Mostrar el panel inicial con estadísticas y acciones rápidas",
"HomeBackToConversation": "Volver a la conversación",
"HomeNavigationBusy": "Termina o detén el trabajo actual antes de abrir el inicio.",
"CmdHooksDescription": "Listar hooks de ciclo de vida configurados (solo lectura)",
"CmdAgentDescription": "Abrir una sesión persistente de sub-agente",
"CmdGoalDescription": "Seguir un solo objetivo a lo largo de varios turnos",
+4 -1
View File
@@ -436,7 +436,10 @@
"CmdHfDescription": "Inspecter la configuration et les concepts Hugging Face MCP",
"CmdHelpDescription": "Comprendre les concepts, commandes et raccourcis",
"CmdProfileDescription": "Basculer vers un profil de config nommé",
"CmdHomeDescription": "Afficher le tableau de bord avec stats et actions rapides",
"CmdHomeDescription": "Ouvrir l’accueil sans quitter la conversation actuelle",
"CmdOverviewDescription": "Afficher le tableau de bord avec stats et actions rapides",
"HomeBackToConversation": "Retour à la conversation",
"HomeNavigationBusy": "Terminez ou arrêtez le travail en cours avant d’ouvrir l’accueil.",
"CmdHooksDescription": "Lister les hooks de cycle de vie configurés (lecture seule)",
"CmdAgentDescription": "Ouvrir une session de sous-agent persistante",
"CmdGoalDescription": "Continuer vers un seul objectif sur plusieurs tours",
+4 -1
View File
@@ -436,7 +436,10 @@
"CmdHfDescription": "Hugging Face MCP सेटअप और अवधारणाएँ जाँचें",
"CmdHelpDescription": "अवधारणाएँ, कमांड और कीबाइंडिंग समझें",
"CmdProfileDescription": "नामित कॉन्फ़िग प्रोफ़ाइल पर जाएँ",
"CmdHomeDescription": "आँकड़ों और त्वरित क्रियाओं सहित होम डैशबोर्ड दिखाएँ",
"CmdHomeDescription": "मौजूदा बातचीत छोड़े बिना होम खोलें",
"CmdOverviewDescription": "आँकड़ों और त्वरित क्रियाओं सहित होम डैशबोर्ड दिखाएँ",
"HomeBackToConversation": "बातचीत पर वापस जाएँ",
"HomeNavigationBusy": "होम खोलने से पहले मौजूदा काम पूरा करें या रोकें।",
"CmdHooksDescription": "कॉन्फ़िगर किए गए लाइफ़साइकिल हुक सूचीबद्ध करें (केवल-पठन)",
"CmdAgentDescription": "स्थायी सब-एजेंट सत्र खोलें",
"CmdGoalDescription": "कई टर्न तक एक ही लक्ष्य पर काम जारी रखें",
+4 -1
View File
@@ -436,7 +436,10 @@
"CmdHfDescription": "Periksa penyiapan dan konsep Hugging Face MCP",
"CmdHelpDescription": "Pahami konsep, perintah, dan pintasan keyboard",
"CmdProfileDescription": "Beralih ke profil konfig bernama",
"CmdHomeDescription": "Tampilkan dasbor beranda dengan statistik dan aksi cepat",
"CmdHomeDescription": "Buka beranda tanpa meninggalkan percakapan saat ini",
"CmdOverviewDescription": "Tampilkan dasbor beranda dengan statistik dan aksi cepat",
"HomeBackToConversation": "Kembali ke percakapan",
"HomeNavigationBusy": "Selesaikan atau hentikan pekerjaan saat ini sebelum membuka beranda.",
"CmdHooksDescription": "Tampilkan hook siklus hidup terkonfigurasi (hanya baca)",
"CmdAgentDescription": "Buka sesi sub-agen persisten",
"CmdGoalDescription": "Terus mengerjakan satu tujuan lintas giliran",
+4 -1
View File
@@ -439,7 +439,10 @@
"CmdHfDescription": "Hugging Face MCP の設定と概念を確認",
"CmdHelpDescription": "概念・コマンド・キーバインドを確認",
"CmdProfileDescription": "名前付き設定プロファイルに切り替え",
"CmdHomeDescription": "統計とクイックアクション付きのホームダッシュボードを表示",
"CmdHomeDescription": "現在の会話を保持してホームを開く",
"CmdOverviewDescription": "統計とクイックアクション付きのホームダッシュボードを表示",
"HomeBackToConversation": "会話に戻る",
"HomeNavigationBusy": "ホームを開く前に、現在の作業を完了または停止してください。",
"CmdHooksDescription": "設定済みのライフサイクルフックを一覧表示(読み取り専用)",
"CmdAgentDescription": "永続サブエージェントセッションを開く",
"CmdGoalDescription": "複数ターンにわたって一つの目標を追い続ける",
+4 -1
View File
@@ -439,7 +439,10 @@
"CmdHfDescription": "Hugging Face MCP 설정과 개념을 확인합니다",
"CmdHelpDescription": "개념, 명령어, 단축키를 안내합니다",
"CmdProfileDescription": "이름이 지정된 설정 프로필로 전환합니다",
"CmdHomeDescription": "통계와 빠른 작업이 있는 홈 대시보드를 표시합니다",
"CmdHomeDescription": "현재 대화를 유지한 채 홈 열기",
"CmdOverviewDescription": "통계와 빠른 작업이 있는 홈 대시보드를 표시합니다",
"HomeBackToConversation": "대화로 돌아가기",
"HomeNavigationBusy": "홈을 열기 전에 현재 작업을 완료하거나 중지하세요.",
"CmdHooksDescription": "설정된 라이프사이클 훅 목록을 표시합니다 (읽기 전용)",
"CmdAgentDescription": "지속적인 서브 에이전트 세션을 엽니다",
"CmdGoalDescription": "여러 턴에 걸쳐 하나의 목표를 계속 진행합니다",
+4 -1
View File
@@ -439,7 +439,10 @@
"CmdHfDescription": "Inspecionar configuracao e conceitos do Hugging Face MCP",
"CmdHelpDescription": "Entender conceitos, comandos e atalhos",
"CmdProfileDescription": "Alternar para um perfil de configuracao nomeado",
"CmdHomeDescription": "Exibir o painel inicial com estatísticas e ações rápidas",
"CmdHomeDescription": "Abrir o início sem sair da conversa atual",
"CmdOverviewDescription": "Exibir o painel inicial com estatísticas e ações rápidas",
"HomeBackToConversation": "Voltar à conversa",
"HomeNavigationBusy": "Conclua ou pare o trabalho atual antes de abrir o início.",
"CmdHooksDescription": "Listar hooks de ciclo de vida configurados (somente leitura)",
"CmdAgentDescription": "Abrir uma sessão persistente de sub-agente",
"CmdGoalDescription": "Continuar um único objetivo ao longo de vários turnos",
+4 -1
View File
@@ -436,7 +436,10 @@
"CmdHfDescription": "Просмотр настройки и концепций Hugging Face MCP",
"CmdHelpDescription": "Разобраться в концепциях, командах и сочетаниях клавиш",
"CmdProfileDescription": "Переключиться на именованный профиль конфигурации",
"CmdHomeDescription": "Показать домашнюю панель со статистикой и быстрыми действиями",
"CmdHomeDescription": "Открыть главную, сохранив текущий разговор",
"CmdOverviewDescription": "Показать домашнюю панель со статистикой и быстрыми действиями",
"HomeBackToConversation": "Вернуться к разговору",
"HomeNavigationBusy": "Завершите или остановите текущую работу, прежде чем открыть главную.",
"CmdHooksDescription": "Список настроенных хуков жизненного цикла (только чтение)",
"CmdAgentDescription": "Открыть постоянную сессию субагента",
"CmdGoalDescription": "Продолжать одну цель на протяжении нескольких ходов",
+4 -1
View File
@@ -436,7 +436,10 @@
"CmdHfDescription": "Переглянути налаштування та концепції Hugging Face MCP",
"CmdHelpDescription": "Зрозуміти концепції, команди та комбінації клавіш",
"CmdProfileDescription": "Перейти на іменований профіль конфігурації",
"CmdHomeDescription": "Показати домашню панель зі статистикою та швидкими діями",
"CmdHomeDescription": "Відкрити головну, зберігши поточну розмову",
"CmdOverviewDescription": "Показати домашню панель зі статистикою та швидкими діями",
"HomeBackToConversation": "Повернутися до розмови",
"HomeNavigationBusy": "Завершіть або зупиніть поточну роботу, перш ніж відкрити головну.",
"CmdHooksDescription": "Показати налаштовані хуки життєвого циклу (лише читання)",
"CmdAgentDescription": "Відкрити постійний сеанс субагента",
"CmdGoalDescription": "Продовжувати одну ціль упродовж кількох ходів",
+4 -1
View File
@@ -439,7 +439,10 @@
"CmdHfDescription": "Kiểm tra thiết lập và khái niệm Hugging Face MCP",
"CmdHelpDescription": "Hiểu khái niệm, lệnh và phím tắt",
"CmdProfileDescription": "Chuyển sang profile cấu hình đã đặt tên",
"CmdHomeDescription": "Hiển thị bảng điều khiển trang chủ với số liệu thống kê và hành động nhanh",
"CmdHomeDescription": "Mở trang chủ mà không rời cuộc trò chuyện hiện tại",
"CmdOverviewDescription": "Hiển thị bảng điều khiển trang chủ với số liệu thống kê và hành động nhanh",
"HomeBackToConversation": "Quay lại cuộc trò chuyện",
"HomeNavigationBusy": "Hoàn tất hoặc dừng công việc hiện tại trước khi mở trang chủ.",
"CmdHooksDescription": "Liệt kê các lifecycle hook đã cấu hình (chỉ đọc)",
"CmdAgentDescription": "Mở một phiên sub-agent nền",
"CmdGoalDescription": "Tiếp tục một mục tiêu xuyên suốt nhiều lượt",
+4 -1
View File
@@ -439,7 +439,10 @@
"CmdHfDescription": "检查 Hugging Face MCP 设置和概念",
"CmdHelpDescription": "理解概念、命令与快捷键",
"CmdProfileDescription": "切换到命名配置配置文件",
"CmdHomeDescription": "显示主页面板,含统计与快捷操作",
"CmdHomeDescription": "保留当前对话并打开主页",
"CmdOverviewDescription": "显示主页面板,含统计与快捷操作",
"HomeBackToConversation": "返回对话",
"HomeNavigationBusy": "请先完成或停止当前工作,再打开主页。",
"CmdHooksDescription": "列出已配置的生命周期钩子(只读)",
"CmdAgentDescription": "打开持久子代理会话",
"CmdGoalDescription": "跨多轮持续推进同一个目标",
+4 -1
View File
@@ -333,7 +333,10 @@
"CmdGoalDescription": "跨多輪持續推進同一個目標",
"CmdHelpDescription": "理解概念、命令與快捷鍵",
"CmdHfDescription": "檢查 Hugging Face MCP 設定和概念",
"CmdHomeDescription": "顯示主頁面板,含統計與快捷操作",
"CmdHomeDescription": "保留目前對話並開啟首頁",
"CmdOverviewDescription": "顯示主頁面板,含統計與快捷操作",
"HomeBackToConversation": "返回對話",
"HomeNavigationBusy": "請先完成或停止目前工作,再開啟首頁。",
"CmdHooksDescription": "列出已設定的生命週期鉤子(唯讀)",
"CmdHotbarDescription": "開啟 Hotbar 設定",
"CmdInitDescription": "為項目生成 AGENTS.md",
+6
View File
@@ -510,6 +510,9 @@ pub enum MessageId {
CmdHelpDescription,
CmdProfileDescription,
CmdHomeDescription,
CmdOverviewDescription,
HomeBackToConversation,
HomeNavigationBusy,
CmdHooksDescription,
CmdAgentDescription,
CmdGoalDescription,
@@ -2851,6 +2854,9 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdHelpDescription,
MessageId::CmdProfileDescription,
MessageId::CmdHomeDescription,
MessageId::CmdOverviewDescription,
MessageId::HomeBackToConversation,
MessageId::HomeNavigationBusy,
MessageId::CmdHooksDescription,
MessageId::CmdAgentDescription,
MessageId::CmdInitDescription,
+116 -1
View File
@@ -8,7 +8,7 @@ use super::CommandResult;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "home",
aliases: &["stats", "overview", "zhuye", "shouye"],
aliases: &["zhuye", "shouye"],
usage: "/home",
description_id: MessageId::CmdHomeDescription,
};
@@ -20,7 +20,122 @@ impl RegisterCommand for HomeCmd {
&COMMAND_INFO
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
if app.session_transition_blocked() {
return CommandResult::error(app.tr(MessageId::HomeNavigationBusy).into_owned());
}
app.launch.workspace = app.workspace.clone();
app.launch.restore_card();
app.launch.visible = true;
app.launch.return_to_session = true;
app.needs_redraw = true;
CommandResult::ok()
}
}
/// Preserve the old read-only statistics view under its descriptive aliases.
pub(in crate::commands) struct OverviewCmd;
impl RegisterCommand for OverviewCmd {
fn info() -> &'static CommandInfo {
&CommandInfo {
name: "overview",
aliases: &["stats"],
usage: "/overview",
description_id: MessageId::CmdOverviewDescription,
}
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
super::core::home_dashboard(app)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn home_preserves_session_draft_and_reveal_and_escape_returns() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.launch.dismiss();
app.current_session_id = Some("retained-session".into());
app.api_messages_mut().push(codewhale_models::Message {
role: codewhale_models::Role::User,
content: vec![codewhale_models::ContentBlock::Text {
text: "Retain this conversation".into(),
cache_control: None,
}],
});
app.add_message(crate::tui::history::HistoryCell::System {
content: "Retain this transcript".into(),
});
app.input = "unsent draft".into();
app.cursor_position = app.input.chars().count();
app.launch.mark_reveal_started_at = Some(std::time::Instant::now());
let reveal = app.launch.mark_reveal_started_at;
let messages = app.api_messages.clone();
let history_len = app.history.len();
let result = crate::commands::execute("/home", &mut app);
assert!(!result.is_error, "{:?}", result.message);
assert!(
result.action.is_none(),
"navigation must not sync/reset the Engine"
);
assert!(crate::tui::widgets::should_render_empty_state(&app));
assert!(app.launch.return_to_session);
assert!(!crate::tui::underwater::launch_motion_active(
&app, false, true
));
crate::tui::underwater::refresh_launch_row_hitboxes(
&mut app,
ratatui::layout::Rect::new(0, 0, 80, 20),
);
let rows = crate::tui::underwater::launch_rows_for_app(&app);
assert_eq!(
crate::tui::underwater::launch_row_click_action(&rows[0].id),
crate::tui::underwater::LaunchAction::ReturnToSession,
);
app.launch.menu_selected = Some(0);
assert_eq!(
crate::tui::underwater::handle_launch_composer_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
),
crate::tui::underwater::LaunchComposerKey::MenuRun,
);
assert_eq!(app.input, "unsent draft");
crate::tui::underwater::handle_launch_composer_key(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(!app.launch.visible);
assert!(!app.launch.return_to_session);
assert_eq!(app.current_session_id.as_deref(), Some("retained-session"));
assert_eq!(app.api_messages, messages);
assert_eq!(app.history.len(), history_len);
assert_eq!(app.input, "unsent draft");
assert_eq!(app.launch.mark_reveal_started_at, reveal);
assert!(!crate::tui::widgets::should_render_empty_state(&app));
for command in ["/overview", "/stats"] {
let result = crate::commands::execute(command, &mut app);
assert!(!result.is_error);
assert!(result.message.unwrap().contains("Quick Actions"));
assert!(!app.launch.visible);
}
}
#[test]
fn home_refuses_to_cover_active_work() {
let mut app =
crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
app.launch.dismiss();
app.is_loading = true;
let result = HomeCmd::execute(&mut app, None);
assert!(result.is_error);
assert!(!app.launch.visible);
}
}
@@ -167,6 +167,10 @@ impl CommandGroup for CoreCommands {
home::HomeCmd::info(),
home::HomeCmd::execute,
)),
Box::new(FunctionCommand::new(
home::OverviewCmd::info(),
home::OverviewCmd::execute,
)),
Box::new(FunctionCommand::new(
workspace::WorkspaceCmd::info(),
workspace::WorkspaceCmd::execute,
+14 -1
View File
@@ -588,6 +588,7 @@ pub struct LaunchRecentSession {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchRowId {
NewSession,
ReturnToSession,
Recent(String),
SeeAll,
/// Open the MCP manager from the status summary, including healthy servers.
@@ -610,6 +611,8 @@ pub(crate) const LAUNCH_RECENT_INLINE_LIMIT: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchState {
pub visible: bool,
/// Home temporarily covers the current conversation; it does not own a session.
pub return_to_session: bool,
pub status: Option<String>,
/// Canonical workspace this launch state is scoped to. Recent work is
/// the workspace's own sessions (archived and empty auto-created ones
@@ -727,6 +730,7 @@ impl LaunchState {
let claude_code_detected = has_claude_code && !import_already_reviewed;
Self {
visible,
return_to_session: false,
status: None,
workspace: workspace.to_path_buf(),
recent,
@@ -742,6 +746,15 @@ impl LaunchState {
}
}
/// Leave home without resetting the conversation, draft, or reveal clock.
pub fn dismiss(&mut self) {
self.visible = false;
self.return_to_session = false;
self.row_hitboxes.clear();
self.menu_selected = None;
self.hovered_row = None;
}
/// Re-read the recent-work list from disk (same filter as
/// construction). Called when the card is restored after a picker
/// closes so a session created or renamed behind the picker shows up.
@@ -1808,7 +1821,7 @@ pub struct App {
pub launch: LaunchState,
/// Mouse-selected launch action, consumed by the async UI loop.
pub pending_launch_action: Option<crate::tui::underwater::LaunchAction>,
/// Mouse click on the live composer's `[↑]` send target. The async UI loop
/// Mouse click on the live composer's `[↵]` send target. The async UI loop
/// consumes it through the same submit dispatcher as Enter.
pub pending_composer_submit: Option<ComposerSubmitChord>,
/// Mouse-selected hotbar slot, consumed by the async UI loop.
+19 -26
View File
@@ -138,13 +138,13 @@ mod tests {
// ---------------------------------------------------------------------------
// Tideline composer restyle (spec §2 composer decision, §5a "Composer"):
// rounded border + `[↑]` send hitbox. Translation scaffolding in
// rounded border + `[↵]` send hitbox. Translation scaffolding in
// the topbar mold — a pure, deterministic widget over injected state; the
// composer authority logic (composer_ui.rs) is untouched, and wiring into
// `ui/frame.rs` is the landing slice after #5698 settles.
//
// Cell rules (spec §2): no bezier strokes — `╭─╮│╰╯` border dim at rest and
// Info on focus; the send `↑` is a 3-cell `[↑]` hitbox right-aligned inside
// Info on focus; the send `↵` is a 3-cell `[↵]` hitbox right-aligned inside
// the border. The hand-drawn three-cell crown fluke this cap used to carry
// was deleted by the 2026-08-29 founder decree (terminal marks must be
// generated from the brand master path, never hand-drawn); the corner is a
@@ -156,7 +156,7 @@ use unicode_width::UnicodeWidthStr;
use codewhale_palette::{ChromeInk, UiTheme, chrome_style};
/// Fixed width of the painted `[↑]` submit control.
/// Fixed width of the painted `[↵]` submit control.
pub const TIDELINE_COMPOSER_SUBMIT_WIDTH: u16 = 3;
/// Blank cell between input content and the painted submit control.
@@ -171,34 +171,17 @@ fn put(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) {
buf.set_stringn(x, y, text, width, style);
}
fn symbol(glyph: &str, ascii_safe: bool) -> String {
if !ascii_safe {
return glyph.to_string();
}
if let Some(fallback) = crate::tui::glyphs::ascii_fallback(glyph) {
return fallback.to_string();
}
glyph
.chars()
.map(|ch| {
crate::tui::glyphs::ascii_fallback(&ch.to_string())
.map(str::to_string)
.unwrap_or_else(|| ch.to_string())
})
.collect()
}
/// Shared geometry for the rounded Tideline composer shell.
///
/// Rendering, launch hit-testing, and the live composer must derive their
/// interior and submit rect from this one cell map. Otherwise a visible
/// `[↑]` can drift away from the mouse target at a terminal width boundary.
/// `[↵]` can drift away from the mouse target at a terminal width boundary.
#[derive(Debug, Clone, Copy)]
pub struct TidelineComposerGeometry {
/// Interior input rows, excluding the one-cell rails, the submit control,
/// and its one-cell breathing space.
pub content: Rect,
/// The visible three-cell `[↑]` submit affordance.
/// The visible three-cell `[↵]` submit affordance.
pub submit: Rect,
}
@@ -229,7 +212,7 @@ pub fn tideline_composer_geometry(area: Rect) -> TidelineComposerGeometry {
TidelineComposerGeometry { content, submit }
}
/// Paint or restore the visible `[↑]` affordance above caller-owned content.
/// Paint or restore the visible `[↵]` affordance above caller-owned content.
///
/// The multiline work composer paints this after its input or queued crumb, so that
/// content can never overwrite the one cell target the user is meant to click.
@@ -245,7 +228,13 @@ pub fn render_tideline_composer_submit(
return;
}
let geometry = tideline_composer_geometry(area);
let send = symbol("[↑]", ascii_safe);
let send = if can_submit {
if ascii_safe { "[>]" } else { "[↵]" }
} else if ascii_safe {
"[.]"
} else {
"[·]"
};
let send_ink = if can_submit {
ChromeInk::Info
} else {
@@ -255,7 +244,11 @@ pub fn render_tideline_composer_submit(
buf,
geometry.submit.x,
geometry.submit.y,
&send,
chrome(theme, send_ink),
send,
if can_submit {
chrome(theme, send_ink).bold()
} else {
chrome(theme, send_ink)
},
);
}
@@ -26,7 +26,7 @@
│ │
│ │
system | terminal | shoreline | shoreline-light | underwater | underwater-retro | dark | light | grayscale |…
Preview: ▶▶ ask · agent
Preview: ask agent
type=filter, Up/Down=select, Enter=edit, Esc=close
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -18,7 +18,7 @@
system | terminal | shoreline | shoreline-light | underwater |…
Enter or click again: Enter opens choices · Theme: current terminal · saved
terminal · applies on save
Preview: ▶▶ ask · agent
Preview: ask agent
type=filter, Up/Down=select, Enter=edit, PgUp/PgDn=scroll, Esc=close
──────────────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -440,7 +440,7 @@ pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
}
// Resolve the border- and submit-aware input plane through the same
// persistent prompt geometry used by rendering, cursor placement, and
// viewport bookkeeping. The frame records it after reserving `[↑]`.
// viewport bookkeeping. The frame records it after reserving `[↵]`.
let input_plane = app.viewport.last_composer_content.unwrap_or(area);
let text_area =
crate::tui::widgets::composer_content_geometry(input_plane, app.is_history_search_active())
@@ -2439,7 +2439,7 @@ mod tests {
let area = Rect::new(0, 20, 80, 4);
app.viewport.last_composer_area = Some(area);
// Match the frame's submit-aware input plane: x=74 stays blank,
// then the shared `[↑]` target begins at x=75.
// then the shared `[↵]` target begins at x=75.
app.viewport.last_composer_content = Some(Rect::new(1, 21, 73, 2));
let submit = crate::tui::widgets::active_composer_submit_rect(&app, area)
.expect("enclosed composer submit");
+1 -1
View File
@@ -1425,7 +1425,7 @@ pub(crate) async fn apply_command_result(
// recent rows reach here through `/resume`-shaped dispatch;
// leaving the launch stage visible over the restored
// transcript is what made those rows read as dead (#4).
app.launch.visible = false;
app.launch.dismiss();
app.launch.status = None;
}
AppAction::SyncSession {
+27 -3
View File
@@ -1234,6 +1234,18 @@ async fn dispatch_launch_composer_submit(
config: &mut Config,
chord: ComposerSubmitChord,
) -> Result<bool> {
if app.launch.return_to_session {
app.launch.dismiss();
return dispatch_session_composer_submit(
terminal,
app,
engine_handle,
task_manager,
config,
chord,
)
.await;
}
let action = app.decide_composer_submit(chord);
if app.startup_input_unproven || !app.composer_enter_would_submit() {
// A paste burst, empty composer or startup integrity hold owns this
@@ -1274,7 +1286,7 @@ async fn dispatch_launch_composer_submit(
/// Submit the live-session composer through the same branches Enter uses.
///
/// Mouse `[↑]` sets `pending_composer_submit`; this consumes that chord without
/// Mouse `[↵]` sets `pending_composer_submit`; this consumes that chord without
/// duplicating draft consumption or opening transcript-only Enter shortcuts.
/// Its own gates (`SendQueuedNow`, the paste-burst probe) run here; everything
/// from slash-menu selection onward is the shared `submit_decided_composer_input`
@@ -1288,6 +1300,9 @@ async fn dispatch_session_composer_submit(
config: &mut Config,
chord: ComposerSubmitChord,
) -> Result<bool> {
if app.launch.return_to_session {
app.launch.dismiss();
}
let action = app.decide_composer_submit(chord);
if matches!(action, ComposerSubmitAction::SendQueuedNow) {
let _ = send_next_queued_message_now(app, config, engine_handle).await?;
@@ -1302,7 +1317,7 @@ async fn dispatch_session_composer_submit(
/// Shared tail of a decided composer submit: slash-menu selection, draft
/// consumption, and the memory/`!`/`/`/message branches.
///
/// Keyboard Enter and the mouse `[↑]` dispatcher both end here. Each caller
/// Keyboard Enter and the mouse `[↵]` dispatcher both end here. Each caller
/// keeps its own gates — transcript-only shortcuts and forced-submit chords
/// stay keyboard-only, `SendQueuedNow` and the paste-burst probe stay in the
/// dispatcher — so this tail is the one place either surface can change.
@@ -4659,6 +4674,9 @@ pub(crate) async fn run_event_loop(
}
}
if let Event::Paste(text) = &evt {
if app.launch.return_to_session && app.view_stack.is_empty() {
app.launch.dismiss();
}
handle_bracketed_paste(app, text);
continue;
}
@@ -4824,6 +4842,9 @@ pub(crate) async fn run_event_loop(
if let Some(action) = app.pending_launch_action.take() {
match action {
crate::tui::underwater::LaunchAction::None => {}
crate::tui::underwater::LaunchAction::ReturnToSession => {
app.launch.dismiss()
}
crate::tui::underwater::LaunchAction::NewSession => {
let result = begin_launch_session(app, None);
if apply_command_result(
@@ -5514,6 +5535,9 @@ pub(crate) async fn run_event_loop(
});
match action {
crate::tui::underwater::LaunchAction::None => {}
crate::tui::underwater::LaunchAction::ReturnToSession => {
app.launch.dismiss()
}
crate::tui::underwater::LaunchAction::NewSession => {
let result = begin_launch_session(app, None);
if apply_command_result(
@@ -6447,7 +6471,7 @@ pub(crate) async fn run_event_loop(
);
// Slash-menu selection, draft consumption, and the
// memory/`!`/`/`/message branches are the shared tail the
// mouse `[↑]` dispatcher also runs, so keyboard and pointer
// mouse `[↵]` dispatcher also runs, so keyboard and pointer
// submit behavior cannot drift apart.
if submit_decided_composer_input(
terminal,
+7 -4
View File
@@ -551,7 +551,7 @@ fn register_clickable_chrome_for_hover(app: &App) {
);
}
// The composer's `[↑]` submit control. It registers only when a click
// The composer's `[↵]` submit control. It registers only when a click
// there would actually send: an affordance that lights up and then does
// nothing is the same defect as one that acts without lighting up.
if let Some(composer) = app.viewport.last_composer_area
@@ -1731,7 +1731,7 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
};
app.sidebar_hover_tooltip = None;
if app.agent_focus.is_some() {
if app.agent_focus.is_some() && !app.launch.return_to_session {
// A focused worker's full transcript owns the conversation area;
// the ocean column and every other shell surface stay as they are.
//
@@ -1753,7 +1753,10 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
let buf = f.buffer_mut();
crate::tui::agent_focus::render_focus(app, chat_area, buf);
} else {
if app.launch.visible && app.onboarding == crate::tui::app::OnboardingState::None {
if app.launch.visible
&& !app.launch.return_to_session
&& app.onboarding == crate::tui::app::OnboardingState::None
{
app.launch
.mark_reveal_started_at
.get_or_insert_with(std::time::Instant::now);
@@ -2256,7 +2259,7 @@ mod tests {
);
}
/// The composer's `[↑]` answered clicks and showed nothing under the
/// The composer's `[↵]` answered clicks and showed nothing under the
/// pointer — the last of the clickable-but-dark controls. It lights up
/// only when a click there would actually send.
#[test]
+1 -1
View File
@@ -1539,7 +1539,7 @@ pub(crate) async fn handle_view_events(
content: loaded_message.clone(),
});
app.status_message = Some(loaded_message);
app.launch.visible = false;
app.launch.dismiss();
app.launch.status = None;
}
Err(err) => {
+8 -1
View File
@@ -669,6 +669,13 @@ pub(crate) fn type_launch_mcp_remedy(app: &mut App) {
let Some(command) = crate::tui::underwater::mcp_remedy_command(app) else {
return;
};
// Home can be revisited with an unsent draft. The manager exposes the
// same remedy without replacing user-authored composer content.
if !app.input.is_empty() {
app.launch.dissolve_card(app.ambient_clock_ms);
open_mcp_extensions(app);
return;
}
app.input = command;
app.cursor_position = app.input.chars().count();
app.launch.menu_selected = None;
@@ -691,7 +698,7 @@ pub(crate) fn begin_launch_session(
app.current_session_id = Some(session_id.clone());
app.current_session_metadata = None;
app.session_title = Some(app.tr(MessageId::SessionsNewSessionTitle).into_owned());
app.launch.visible = false;
app.launch.dismiss();
app.launch.status = None;
app.status_message = None;
commands::CommandResult::action(AppAction::SyncSession {
+62 -14
View File
@@ -46,6 +46,7 @@ pub enum LaunchAction {
/// The prominent new-session entry: begin a fresh session in the
/// current workspace.
NewSession,
ReturnToSession,
/// Resume one recent-work row by session id.
ResumeSession(String),
/// The see-all overflow: open the full session picker.
@@ -169,6 +170,19 @@ fn launch_recent_entries(app: &App) -> (Vec<LaunchRecentEntry>, bool) {
(recent, has_more)
}
/// Both painting and input use the same primary action on revisited home.
fn home_card_rows(app: &App, recent: &[LaunchRecentEntry], has_more: bool) -> Vec<LaunchCardRow> {
let mut rows = launch_card_rows(app.ui_locale, recent, has_more);
if app.launch.return_to_session {
rows[0].id = crate::tui::app::LaunchRowId::ReturnToSession;
rows[0].label = format!(
"{} Esc",
tr(app.ui_locale, MessageId::HomeBackToConversation)
);
}
rows
}
/// The card's rows for live `App` state, for keyboard navigation and Enter.
///
/// The painted rows are the authority. Paint sheds the tail of the recent
@@ -179,12 +193,10 @@ fn launch_recent_entries(app: &App) -> (Vec<LaunchRecentEntry>, bool) {
/// list: one ordering for paint, mouse, and keyboard, with no second state.
#[must_use]
pub fn launch_rows_for_app(app: &App) -> Vec<LaunchCardRow> {
let (recent, has_more) = launch_recent_entries(app);
if app.launch.row_hitboxes.is_empty() {
// Nothing painted yet (first frame): nothing is selected either.
return launch_card_rows(app.ui_locale, &recent, has_more);
}
let mut superset = launch_card_rows(app.ui_locale, &recent, true);
let (recent, _) = launch_recent_entries(app);
// An empty pane has no navigable rows, including before its first paint.
// A preserved multiline draft can legitimately leave no room for home.
let mut superset = home_card_rows(app, &recent, true);
// MCP rows join the same ordering only when the boot block painted them.
for id in [
crate::tui::app::LaunchRowId::McpManager,
@@ -245,6 +257,7 @@ pub fn refresh_launch_row_hitboxes(app: &mut App, area: Rect) {
pub fn launch_row_click_action(id: &crate::tui::app::LaunchRowId) -> LaunchAction {
match id {
crate::tui::app::LaunchRowId::NewSession => LaunchAction::NewSession,
crate::tui::app::LaunchRowId::ReturnToSession => LaunchAction::ReturnToSession,
crate::tui::app::LaunchRowId::Recent(session_id) => {
LaunchAction::ResumeSession(session_id.clone())
}
@@ -338,8 +351,8 @@ pub enum LaunchComposerKey {
ComposerAuthority,
/// Move the launch card's row selection (Up/Down while the card is up).
MenuNavigate(i32),
/// Run the card's highlighted row (Enter while the card is up, the
/// composer is empty, and the user has arrowed onto a row).
/// Run the card's highlighted row. Revisited home retains its draft;
/// on startup, only an empty composer yields Enter to the card.
MenuRun,
}
@@ -350,12 +363,27 @@ pub enum LaunchComposerKey {
/// system. Only F1 help stays launch-owned via
/// [`LaunchComposerKey::MenuChord`].
pub fn handle_launch_composer_key(app: &mut App, key: KeyEvent) -> LaunchComposerKey {
if app.launch.return_to_session && key.code == KeyCode::Esc {
app.launch.dismiss();
return LaunchComposerKey::Consumed;
}
let multiline = app.composer_multiline_mode;
let card_up = app.launch.dissolve_started_ms.is_none();
match key.code {
KeyCode::Enter
if crate::tui::composer_ui::composer_submit_chord(key, multiline).is_some() =>
{
// Explicit home navigation takes precedence over a preserved draft.
// Only painted rows may own Enter, just as with mouse activation.
if app.launch.return_to_session
&& card_up
&& app
.launch
.menu_selected
.is_some_and(|index| index < app.launch.row_hitboxes.len())
{
return LaunchComposerKey::MenuRun;
}
// #573 parity with the session composer's Enter arm: when a
// completion popup is matching (e.g. `/mo` → `/model`), Enter
// applies the highlighted entry instead of sending the literal
@@ -411,7 +439,11 @@ pub fn handle_launch_composer_key(app: &mut App, key: KeyEvent) -> LaunchCompose
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
app.launch.dissolve_card(app.ambient_clock_ms);
if app.launch.return_to_session {
app.launch.dismiss();
} else {
app.launch.dissolve_card(app.ambient_clock_ms);
}
}
LaunchComposerKey::ComposerAuthority
}
@@ -1639,7 +1671,7 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
text_width.saturating_sub(mark.map_or(0, |size| usize::from(size.cells().0) + 2));
let spacious = fit.blanks == LAUNCH_SEPARATORS;
let visible: Vec<LaunchRecentEntry> = entries.into_iter().take(fit.shown).collect();
let card_rows = launch_card_rows(locale, &visible, fit.see_all);
let card_rows = home_card_rows(app, &visible, fit.see_all);
// The text column, in order. `None` is a blank row.
let mut text: Vec<Option<Line<'static>>> = Vec::new();
@@ -1693,7 +1725,7 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
.map_or(crate::tui::mark::REVEAL_MS, |started| {
started.elapsed().as_millis()
}),
app.motion_policy().allows_decorative(),
app.motion_policy().allows_decorative() && !app.launch.return_to_session,
),
Style::default().fg(theme.accent_primary),
),
@@ -1775,7 +1807,7 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState {
}
rows.push((row.id.clone(), text.len()));
text.push(Some(Line::from(spans)));
if matches!(row.id, crate::tui::app::LaunchRowId::NewSession) && fit.heading {
if row.prominent && fit.heading {
// With no resumable work the heading says so — but only when the
// workspace genuinely has none. Sessions the card's filter drops
// (empty auto-created shells) still exist in `/resume`, so their
@@ -2162,7 +2194,7 @@ mod launch_card_tests {
#[test]
fn no_keyboard_row_names_a_session_the_pane_is_not_showing() {
let mut app = app_with_recent(&["one", "two", "three", "four", "five"], 5);
for height in 1u16..=14 {
for height in 0u16..=14 {
refresh_launch_row_hitboxes(&mut app, Rect::new(0, 0, 120, height));
let painted: Vec<LaunchRowId> = row_ids(&app);
for row in launch_rows_for_app(&app) {
@@ -2584,6 +2616,21 @@ mod launch_card_tests {
assert_eq!(app.launch.menu_selected, None);
}
#[test]
fn mcp_remedy_preserves_a_draft_and_opens_the_manager() {
let mut app = with_mcp(app_with_recent(&["one"], 9));
app.launch.return_to_session = true;
app.input = "unsent draft".into();
app.cursor_position = 4;
crate::tui::ui::type_launch_mcp_remedy(&mut app);
assert_eq!(app.input, "unsent draft");
assert_eq!(app.cursor_position, 4);
assert_eq!(
app.view_stack.top_kind(),
Some(crate::tui::views::ModalKind::Extensions),
);
}
#[test]
fn mcp_remedy_action_is_a_noop_when_nothing_is_wrong() {
let mut app = app_with_recent(&["one"], 9);
@@ -2727,7 +2774,8 @@ pub fn launch_motion_active(app: &App, obscured: bool, ambient_settled: bool) ->
let dissolve = app.launch.card_dissolve_progress(now, true);
let dissolving = dissolve > 0.0 && dissolve < 1.0;
let water_alive = app.theme_id == codewhale_palette::ThemeId::Underwater && !ambient_settled;
let revealing = !crate::tui::color_compat::ascii_safe_enabled()
let revealing = !app.launch.return_to_session
&& !crate::tui::color_compat::ascii_safe_enabled()
&& app
.launch
.mark_reveal_started_at
+28 -13
View File
@@ -1203,7 +1203,7 @@ pub(crate) fn composer_enclosure_enabled(app: &App) -> bool {
app.composer_border
}
/// Shared `[↑]` submit rect for the live composer, or `None` when the
/// Shared `[↵]` submit rect for the live composer, or `None` when the
/// enclosure cannot host the three-cell affordance.
///
/// The gate is the same `enclosed_composer_panel_fits` predicate the painter
@@ -1256,7 +1256,7 @@ fn enclosed_composer_panel_fits(show_panel: bool, area_width: u16, area_height:
/// Border-aware input plane for the active composer.
///
/// The shared shell's `[↑]` control occupies three cells on the inner row.
/// The shared shell's `[↵]` control occupies three cells on the inner row.
/// Keep the text plane to its left, with one blank cell in between, so input
/// wrapping, cursor placement, and pointer mapping cannot claim painted send
/// cells. The outer block still owns the trailing breathing cell before its
@@ -1935,7 +1935,7 @@ impl Renderable for ComposerWidget<'_> {
.set_style(Style::default().fg(self.app.ui_theme.accent_primary));
}
// Restore the shared `[↑]` after caller-owned input so a long draft
// Restore the shared `[↵]` after caller-owned input so a long draft
// cannot erase the one cell target the mouse handler also uses.
if has_panel {
crate::tui::composer_chrome::render_tideline_composer_submit(
@@ -3529,6 +3529,9 @@ fn apply_selection_to_line(
/// [`crate::tui::ui::render`] and thread the result, so the reservation and
/// the render can never disagree inside a single frame.
pub(crate) fn should_render_empty_state(app: &App) -> bool {
if app.launch.visible && app.launch.return_to_session {
return true;
}
let active_is_empty = app
.active_cell
.as_ref()
@@ -3682,7 +3685,7 @@ fn composer_height(
let has_panel = enclosed_composer_panel_fits(show_panel, area_width, available_height);
// Measure through the same border- and submit-aware plane that rendering,
// cursor placement, the frame viewport, and mouse mapping use. A draft
// that wraps here therefore cannot consume the painted `[↑]` cells later.
// that wraps here therefore cannot consume the painted `[↵]` cells later.
let measurement_area = Rect::new(0, 0, area_width, if has_panel { 3 } else { 1 });
let content_width =
composer_content_geometry(composer_inner_area(measurement_area, has_panel), false)
@@ -6434,7 +6437,7 @@ mod tests {
#[test]
fn composer_height_wraps_to_the_rounded_panel_content_width() {
// At the minimum viable panel width, the side rails, prompt gutter,
// shared `[↑]` control, and its breathing cell leave three text
// shared `[↵]` control, and its breathing cell leave three text
// columns. Measuring against the old width would render extra lines
// without allocating their rows.
let height = composer_height(
@@ -6684,7 +6687,7 @@ mod tests {
);
let inner = widget.inner_area(area);
let quiet_row = cursor_y.saturating_add(1);
// The quiet row hosts exactly one thing: the shared `[↑]` affordance
// The quiet row hosts exactly one thing: the shared `[↵]` affordance
// on its recorded hitbox cells. Every other cell stays blank.
let submit = active_composer_submit_rect(&app, area).expect("enclosed composer submit");
assert!(
@@ -6694,12 +6697,15 @@ mod tests {
submit.y == quiet_row && x >= submit.x && x < submit.x + submit.width;
on_submit || buf[(x, quiet_row)].symbol() == " "
}),
"comfortable composer should keep a quiet content row before the footer, hosting only the shared [↑]: {rendered}"
"comfortable composer should keep a quiet content row before the footer, hosting only the shared [↵]: {rendered}"
);
let painted: String = (submit.x..submit.x + submit.width)
.map(|x| buf[(x, submit.y)].symbol().to_string())
.collect();
assert_eq!(painted, "[↑]", "the quiet row hosts the shared send cells");
assert_eq!(
painted, "[·]",
"the empty composer has an inactive send cue"
);
}
#[test]
@@ -6939,7 +6945,7 @@ mod tests {
for (width, height) in [(40_u16, 12), (60, 16), (80, 24), (100, 32), (120, 32)] {
let rendered = render_composer(&app, width, height);
assert!(
rendered.contains("[↑]"),
rendered.contains("[↵]"),
"missing send affordance at {width}x{height}:\n{rendered}"
);
assert!(
@@ -6963,7 +6969,16 @@ mod tests {
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let submit = active_composer_submit_rect(&app, area).unwrap();
let role = if app.composer_enter_would_submit() {
let ready = app.composer_enter_would_submit();
let painted: String = (submit.x..submit.right())
.map(|x| buf[(x, submit.y)].symbol())
.collect();
assert_eq!(painted, if ready { "[↵]" } else { "[·]" });
assert_eq!(
buf[(submit.x, submit.y)].modifier.contains(Modifier::BOLD),
ready
);
let role = if ready {
codewhale_palette::ChromeInk::Info
} else {
codewhale_palette::ChromeInk::MetadataDim
@@ -6993,7 +7008,7 @@ mod tests {
let painted: String = (submit.x..submit.x + submit.width)
.map(|x| buf[(x, submit.y)].symbol().to_string())
.collect();
assert_eq!(painted, "[↑]", "geometry must cover the painted send cells");
assert_eq!(painted, "[↵]", "geometry must cover the painted send cells");
}
#[test]
@@ -7040,7 +7055,7 @@ mod tests {
let painted: String = (submit.x..submit.right())
.map(|x| buf[(x, submit.y)].symbol().to_string())
.collect();
assert_eq!(painted, "[↑]", "submit stays intact beside the draft");
assert_eq!(painted, "[↵]", "submit stays intact beside the draft");
}
#[test]
@@ -7075,7 +7090,7 @@ mod tests {
app.cursor_position = app.input.chars().count();
let rendered = render_composer(&app, 80, 4);
assert!(
!rendered.contains("[↑]"),
!rendered.contains("[↵]"),
"compact composer must shed the send chrome:\n{rendered}"
);
}
@@ -1,4 +1,4 @@
//! Real-PTY regression for the active composer's painted `[↑]` submit
//! Real-PTY regression for the active composer's painted `[↵]` submit
//! affordance (#5773, TUI-UX-01 active-work half).
//!
//! The session is driven through the real onboarding flow into deterministic
@@ -167,9 +167,9 @@ fn run_pointer_submit_case(rows: u16, cols: u16) {
std::thread::sleep(Duration::from_millis(200));
tui.pump();
let (send_row, send_col) = tui.frame().find_text("[↑]").unwrap_or_else(|| {
let (send_row, send_col) = tui.frame().find_text("[↵]").unwrap_or_else(|| {
panic!(
"{size}: [↑] submit affordance not painted\n{}",
"{size}: [↵] submit affordance not painted\n{}",
tui.diagnostics()
)
});
@@ -185,14 +185,14 @@ fn run_pointer_submit_case(rows: u16, cols: u16) {
tui.diagnostics()
);
// Click the middle cell of the three-cell `[↑]` affordance: SGR down,
// Click the middle cell of the three-cell `[↵]` affordance: SGR down,
// settle, SGR up — the sequence a real terminal sends for one click.
tui.send(keys::mouse::down(send_row, send_col + 1))
.expect("SGR mouse down on [↑]");
.expect("SGR mouse down on [↵]");
tui.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))
.expect("down settles");
tui.send(keys::mouse::up(send_row, send_col + 1))
.expect("SGR mouse up on [↑]");
.expect("SGR mouse up on [↵]");
// Distinguishing assertion: a real submit — the pointer click or keyboard
// Enter alike — consumes the draft into the deterministic offline queue
@@ -231,21 +231,21 @@ fn run_pointer_submit_case(rows: u16, cols: u16) {
// may take a beat to process the second gesture.
if !retried && Instant::now() >= retry_at {
retried = true;
let (retry_row, retry_col) = tui.frame().find_text("[↑]").unwrap_or_else(|| {
let (retry_row, retry_col) = tui.frame().find_text("[↵]").unwrap_or_else(|| {
panic!(
"{size}: [↑] submit affordance not painted on retry\n{}",
"{size}: [↵] submit affordance not painted on retry\n{}",
tui.diagnostics()
)
});
tui.send(keys::mouse::down(retry_row, retry_col + 1))
.expect("SGR mouse down on [↑] retry");
.expect("SGR mouse down on [↵] retry");
std::thread::sleep(Duration::from_millis(150));
tui.send(keys::mouse::up(retry_row, retry_col + 1))
.expect("SGR mouse up on [↑] retry");
.expect("SGR mouse up on [↵] retry");
}
if Instant::now() >= deadline {
panic!(
"{size}: click on [↑] at ({send_row},{}) produced no queue receipt \
"{size}: click on [↵] at ({send_row},{}) produced no queue receipt \
{receipt:?} and no queue growth — pointer submit did not reach the \
keyboard-submit dispatch path (seen={seen:?})\n{}",
send_col + 1,
@@ -447,3 +447,37 @@ fn fleet_roles_open_the_shared_model_picker_and_escape_returns_to_the_same_role(
tui.shutdown();
}
}
#[test]
fn home_returns_to_the_same_conversation_by_escape_click_and_typing() {
for (rows, cols) in SIZES {
let (_workspace, mut tui) = start(rows, cols, false);
click_text(&mut tui, TITLE);
wait(&mut tui, "Resume");
tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
tui.send(keys::key::enter()).unwrap();
wait(&mut tui, SAVED_TEXT);
for return_path in ["escape", "click", "type"] {
tui.paste("/home").unwrap();
tui.send(keys::key::enter()).unwrap();
wait(&mut tui, "Back to conversation");
capture(&mut tui, "home-return");
match return_path {
"escape" => tui.send(keys::key::esc()).unwrap(),
"click" => click_text(&mut tui, "Back to conversation"),
_ => tui.send("draft stays here").unwrap(),
}
wait(&mut tui, SAVED_TEXT);
tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
if return_path == "type" {
wait(&mut tui, "draft stays here");
capture(&mut tui, "home-return-draft");
tui.send(keys::key::ctrl('u')).unwrap();
}
}
tui.paste("/overview").unwrap();
tui.send(keys::key::enter()).unwrap();
wait(&mut tui, "Quick Actions");
tui.shutdown();
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ fn search_text_stays_in_modal_and_out_of_composer() {
.split_once('❯')
.unwrap()
.1
.split("[↑]")
.split("[↵]")
.next()
.unwrap()
.trim_end();