fix(tui): make running-turn input shortcuts unambiguous

Give each composer chord one stable role: Enter sends or queues, Ctrl+Enter steers, newline chords always add a line, Ctrl+G/Ctrl+S only stash, and Tab never submits text. Empty Enter now promotes the oldest queued follow-up explicitly.

Keep modified Enter chords out of paste-burst suppression, align hints/help/locales/docs, and cover the contract with unit and PTY runtime regressions.
This commit is contained in:
Hunter B
2026-07-26 13:53:19 -07:00
parent 9df3218fa2
commit a77d2e5cdc
23 changed files with 390 additions and 268 deletions
+5 -4
View File
@@ -269,7 +269,7 @@
"CmdSkillDescription": "Activate a skill, or install/update/uninstall/trust a community skill",
"CmdSkillsDescription": "List local skills, filter by prefix, or browse the curated remote registry",
"CmdDebtDescription": "Inspect or export the debt ledger",
"CmdStashDescription": "Park or restore a composer draft (Ctrl+S sends a queued follow-up first)",
"CmdStashDescription": "Park or restore a composer draft",
"CmdStatusDescription": "Show runtime session status",
"CmdStatuslineDescription": "Configure which items appear in the footer",
"CmdFleetDescription": "Inspect and set up Fleet workers and orchestration state",
@@ -331,10 +331,11 @@
"KbSelectAllDraft": "Select the whole draft",
"KbClearDraft": "Clear the current draft",
"KbRestoreClearedDraft": "Restore the cleared draft",
"KbStashDraft": "Send queued follow-up first; otherwise stash current draft (`/stash pop` to restore)",
"KbStashDraft": "Stash the current draft (`/stash pop` to restore)",
"KbSearchHistory": "Search prompt history and recover local drafts",
"KbInsertNewline": "Insert a newline in the composer",
"KbSendDraft": "Send the current draft",
"KbSendDraft": "Send when idle; queue while busy; empty composer sends the next queued follow-up now",
"KbSteerCurrentTurn": "Steer the current turn (sends normally while idle)",
"KbCloseMenu": "Close menu, cancel request, discard draft, or clear input",
"KbCancelOrExit": "Cancel request, or exit when idle",
"KbShellControls": "Move the running shell wait to /jobs",
@@ -351,7 +352,7 @@
"KbExternalEditor": "Open composer draft in external editor",
"KbLiveTranscript": "Open live transcript overlay (Ctrl+Shift+T; sticky-tail auto-scroll)",
"KbBacktrackMessage": "Backtrack to a previous user message (Left/Right step, Enter to rewind)",
"KbCompleteCycleModes": "Complete /command, queue running-turn follow-up, cycle modes (Plan → Act → Operate)",
"KbCompleteCycleModes": "Complete /command or cycle modes when the composer is empty (Plan → Act → Operate)",
"KbCycleThinking": "Cycle reasoning effort for the active provider (Ctrl+T)",
"KbCyclePermissions": "Cycle permission posture: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Trigger hotbar slots",
+5 -4
View File
@@ -269,7 +269,7 @@
"CmdSkillDescription": "Activar una skill, o instalar/actualizar/desinstalar/confiar en una skill de la comunidad",
"CmdSkillsDescription": "Listar skills locales, filtrar por prefijo o explorar el registro remoto curado",
"CmdDebtDescription": "Inspeccionar o exportar el libro de deuda técnica",
"CmdStashDescription": "Estacionar o restaurar el borrador del compositor (Ctrl+S envía primero un seguimiento en cola)",
"CmdStashDescription": "Estacionar o restaurar el borrador del compositor",
"CmdStatusDescription": "Mostrar el estado de la sesión en ejecución",
"CmdStatuslineDescription": "Configurar qué elementos aparecen en el pie de página",
"CmdFleetDescription": "Inspeccionar y configurar workers de Fleet y orquestación",
@@ -331,10 +331,11 @@
"KbSelectAllDraft": "Seleccionar todo el borrador",
"KbClearDraft": "Limpiar borrador actual",
"KbRestoreClearedDraft": "Restaurar el borrador borrado",
"KbStashDraft": "Estacionar borrador actual (`/stash pop` restaura)",
"KbStashDraft": "Estacionar el borrador actual (`/stash pop` restaura)",
"KbSearchHistory": "Buscar historial de prompts y recuperar borradores locales",
"KbInsertNewline": "Insertar nueva línea en el compositor",
"KbSendDraft": "Enviar borrador actual",
"KbSendDraft": "Enviar si está inactivo; encolar si está ocupado; con el compositor vacío, enviar ahora el siguiente seguimiento en cola",
"KbSteerCurrentTurn": "Guiar el turno actual (envía normalmente si está inactivo)",
"KbCloseMenu": "Cerrar menú, cancelar solicitud, descartar borrador o limpiar entrada",
"KbCancelOrExit": "Cancelar solicitud o salir cuando está inactivo",
"KbShellControls": "Mover la espera del shell a /jobs",
@@ -351,7 +352,7 @@
"KbExternalEditor": "Abrir borrador en el editor externo",
"KbLiveTranscript": "Abrir superposición de transcripción en vivo (auto-scroll)",
"KbBacktrackMessage": "Retroceder al mensaje anterior del usuario (izquierda/derecha, Enter para rebobinar)",
"KbCompleteCycleModes": "Completar /command, encolar follow-up, ciclar modos; Shift+Tab cicla esfuerzo de razonamiento",
"KbCompleteCycleModes": "Completar /command o ciclar modos cuando el compositor está vacío (Plan → Act → Operate)",
"KbCycleThinking": "Alternar el esfuerzo de razonamiento del proveedor activo (Ctrl+T)",
"KbCyclePermissions": "Ciclar postura de permiso: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Activar ranuras de la hotbar",
+4 -3
View File
@@ -269,7 +269,7 @@
"CmdSkillDescription": "スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼",
"CmdSkillsDescription": "ローカルスキルを一覧表示し、接頭辞で絞り込むか、精選レジストリを参照する",
"CmdDebtDescription": "負債台帳を確認またはエクスポート",
"CmdStashDescription": "コンポーザーの下書きを退避/復元する(Ctrl+S は待機中の後続送信を優先)",
"CmdStashDescription": "コンポーザーの下書きを退避/復元する",
"CmdStatusDescription": "実行中のセッション状態を表示",
"CmdStatuslineDescription": "フッターに表示する項目を設定",
"CmdFleetDescription": "Fleet ワーカーとオーケストレーション状態を確認・設定",
@@ -334,7 +334,8 @@
"KbStashDraft": "現在の下書きをスタッシュ(`/stash pop`で復元)",
"KbSearchHistory": "プロンプト履歴を検索してローカル下書きを復元",
"KbInsertNewline": "コンポーザーに改行を挿入",
"KbSendDraft": "現在の下書きを送信",
"KbSendDraft": "待機中は送信、実行中はキュー。入力欄が空なら次のキューを今すぐ送信",
"KbSteerCurrentTurn": "現在のターンをステア(待機中は通常送信)",
"KbCloseMenu": "メニューを閉じる、リクエストをキャンセル、下書きを破棄、または入力をクリア",
"KbCancelOrExit": "リクエストをキャンセル、またはアイドル時に終了",
"KbShellControls": "実行中のシェル待機を /jobs へ移動",
@@ -351,7 +352,7 @@
"KbExternalEditor": "作成中の下書きを外部エディターで開く",
"KbLiveTranscript": "ライブ会話履歴オーバーレイを開く(自動追尾スクロール)",
"KbBacktrackMessage": "前のユーザーメッセージに戻る(左右でステップ、Enter で巻き戻し)",
"KbCompleteCycleModes": "/command を補完、実行中ターンのフォローアップをキュー、モードを切り替え;Shift+Tab で推論強度を切り替え",
"KbCompleteCycleModes": "/command を補完、または入力欄が空のときモードを切り替え(Plan → Act → Operate",
"KbCycleThinking": "現在のプロバイダーの推論強度を順に切り替え (Ctrl+T)",
"KbCyclePermissions": "権限を順に切り替え: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "ホットバースロットを起動",
+5 -4
View File
@@ -269,7 +269,7 @@
"CmdSkillDescription": "스킬을 활성화하거나 커뮤니티 스킬을 설치/업데이트/제거/신뢰합니다",
"CmdSkillsDescription": "로컬 스킬을 나열하고, 접두사로 필터링하거나 큐레이션된 원격 레지스트리를 조회합니다",
"CmdDebtDescription": "부채 원장을 확인하거나 내보냅니다",
"CmdStashDescription": "입력창 초안을 보관하거나 복원합니다 (Ctrl+S는 대기 중인 후속 메시지를 먼저 전송)",
"CmdStashDescription": "입력창 초안을 보관하거나 복원합니다",
"CmdStatusDescription": "런타임 세션 상태를 표시합니다",
"CmdStatuslineDescription": "하단 표시줄에 표시할 항목을 설정합니다",
"CmdFleetDescription": "플릿 워커와 오케스트레이션 상태를 확인하고 설정합니다",
@@ -331,10 +331,11 @@
"KbSelectAllDraft": "초안 전체 선택",
"KbClearDraft": "현재 초안 지우기",
"KbRestoreClearedDraft": "지운 초안 복원",
"KbStashDraft": "대기 중인 후속 메시지를 먼저 전송하고, 없으면 현재 초안을 보관합니다 (`/stash pop`으로 복원)",
"KbStashDraft": "현재 초안을 보관합니다 (`/stash pop`으로 복원)",
"KbSearchHistory": "프롬프트 기록을 검색하고 로컬 초안을 복구",
"KbInsertNewline": "입력창에 줄바꿈 삽입",
"KbSendDraft": "현재 초안 전송",
"KbSendDraft": "대기 중이면 전송, 실행 중이면 대기열에 추가; 입력창이 비어 있으면 다음 후속 메시지를 즉시 전송",
"KbSteerCurrentTurn": "현재 턴 조정 (대기 중에는 일반 전송)",
"KbCloseMenu": "메뉴 닫기, 요청 취소, 초안 버리기, 또는 입력 지우기",
"KbCancelOrExit": "요청 취소, 유휴 상태면 종료",
"KbShellControls": "실행 중인 셸 대기를 /jobs로 이동",
@@ -351,7 +352,7 @@
"KbExternalEditor": "작성 중인 초안을 외부 편집기에서 열기",
"KbLiveTranscript": "실시간 대화 기록 오버레이 열기 (Ctrl+Shift+T; 하단 고정 자동 스크롤)",
"KbBacktrackMessage": "이전 사용자 메시지로 되돌아가기 (Left/Right로 이동, Enter로 되감기)",
"KbCompleteCycleModes": "/명령 완성, 실행 중인 턴에 후속 메시지 대기, 모드 순환 (계획 → 실행 → 운영)",
"KbCompleteCycleModes": "/명령 완성 또는 입력창이 비어 있을 때 모드 순환 (계획 → 실행 → 운영)",
"KbCycleThinking": "현재 프로바이더의 추론 강도 순환 전환 (Ctrl+T)",
"KbCyclePermissions": "권한 모드 순환: 확인 → 자동 검토 → 전체 권한 (Shift+Tab)",
"KbJumpPlanAgentYolo": "핫바 슬롯 실행",
+5 -4
View File
@@ -269,7 +269,7 @@
"CmdSkillDescription": "Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade",
"CmdSkillsDescription": "Listar skills locais, filtrar por prefixo ou navegar o registro remoto curado",
"CmdDebtDescription": "Inspecionar ou exportar o livro de dívida técnica",
"CmdStashDescription": "Estacionar ou restaurar o rascunho do compositor (Ctrl+S envia primeiro um follow-up enfileirado)",
"CmdStashDescription": "Estacionar ou restaurar o rascunho do compositor",
"CmdStatusDescription": "Exibir o status da sessão em execução",
"CmdStatuslineDescription": "Configurar quais itens aparecem no rodapé",
"CmdFleetDescription": "Inspecionar e configurar workers do Fleet e orquestração",
@@ -331,10 +331,11 @@
"KbSelectAllDraft": "Selecionar todo o rascunho",
"KbClearDraft": "Limpar rascunho atual",
"KbRestoreClearedDraft": "Restaurar o rascunho apagado",
"KbStashDraft": "Estacionar rascunho atual (`/stash pop` restaura)",
"KbStashDraft": "Estacionar o rascunho atual (`/stash pop` restaura)",
"KbSearchHistory": "Buscar histórico de prompts e recuperar rascunhos locais",
"KbInsertNewline": "Inserir nova linha no compositor",
"KbSendDraft": "Enviar rascunho atual",
"KbSendDraft": "Enviar quando ocioso; enfileirar quando ocupado; com o compositor vazio, enviar agora o próximo follow-up",
"KbSteerCurrentTurn": "Direcionar o turno atual (envia normalmente quando ocioso)",
"KbCloseMenu": "Fechar menu, cancelar requisição, descartar rascunho ou limpar entrada",
"KbCancelOrExit": "Cancelar requisição ou sair quando ocioso",
"KbShellControls": "Mover a espera do shell para /jobs",
@@ -351,7 +352,7 @@
"KbExternalEditor": "Abrir rascunho no editor externo",
"KbLiveTranscript": "Abrir sobreposição de transcrição ao vivo (auto-scroll)",
"KbBacktrackMessage": "Retroceder para mensagem anterior do usuário (esquerda/direita, Enter para rebobinar)",
"KbCompleteCycleModes": "Completar /command, enfileirar follow-up, ciclar modos; Shift+Tab cicla esforço de raciocínio",
"KbCompleteCycleModes": "Completar /command ou ciclar modos quando o compositor estiver vazio (Plan → Act → Operate)",
"KbCycleThinking": "Alternar o esforço de raciocínio do provedor ativo (Ctrl+T)",
"KbCyclePermissions": "Ciclar postura de permissão: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Acionar slots da hotbar",
+4 -3
View File
@@ -269,7 +269,7 @@
"CmdSkillDescription": "Kích hoạt một kỹ năng, hoặc cài đặt/cập nhật/gỡ bỏ/tin cậy một kỹ năng cộng đồng",
"CmdSkillsDescription": "Liệt kê kỹ năng cục bộ, lọc theo tiền tố, hoặc duyệt kho đăng ký từ xa",
"CmdDebtDescription": "Kiểm tra hoặc xuất sổ nợ kỹ thuật",
"CmdStashDescription": "Tạm cất hoặc khôi phục bản nháp (Ctrl+S gửi trước follow-up đang xếp hàng)",
"CmdStashDescription": "Tạm cất hoặc khôi phục bản nháp",
"CmdStatusDescription": "Hiển thị trạng thái thời gian chạy của phiên",
"CmdStatuslineDescription": "Cấu hình các mục hiển thị ở thanh trạng thái dưới cùng",
"CmdFleetDescription": "Xem và thiết lập worker Fleet cùng trạng thái điều phối",
@@ -334,7 +334,8 @@
"KbStashDraft": "Tạm cất bản nháp hiện tại (dùng `/stash pop` để khôi phục)",
"KbSearchHistory": "Tìm kiếm lịch sử câu lệnh và khôi phục các bản nháp cục bộ",
"KbInsertNewline": "Chèn một dòng mới trong khung soạn thảo",
"KbSendDraft": "Gửi bản nháp hiện tại",
"KbSendDraft": "Gửi khi rảnh; xếp hàng khi bận; khung soạn thảo trống sẽ gửi ngay follow-up tiếp theo trong hàng đợi",
"KbSteerCurrentTurn": "Điều hướng lượt hiện tại (gửi bình thường khi rảnh)",
"KbCloseMenu": "Đóng menu, hủy yêu cầu, hủy bản nháp hoặc xóa sạch đầu vào",
"KbCancelOrExit": "Hủy yêu cầu, hoặc thoát khi rảnh",
"KbShellControls": "Chuyển phiên chờ shell sang /jobs",
@@ -351,7 +352,7 @@
"KbExternalEditor": "Mở bản nháp trong trình soạn thảo bên ngoài",
"KbLiveTranscript": "Mở lớp phủ bản ghi trực tiếp (tự động cuộn theo đuôi)",
"KbBacktrackMessage": "Quay lại tin nhắn trước đó của người dùng (nhấn Trái/Phải để chuyển bước, Enter để lùi lại)",
"KbCompleteCycleModes": "Hoàn thành /command, xếp hàng theo dõi lượt đang chạy, chuyển đổi chế độ; Shift+Tab để chuyển đổi mức độ suy luận",
"KbCompleteCycleModes": "Hoàn thành /command hoặc chuyển chế độ khi khung soạn thảo trống (Plan → Act → Operate)",
"KbCycleThinking": "Luân phiên mức suy luận của nhà cung cấp đang hoạt động (Ctrl+T)",
"KbCyclePermissions": "Xoay vòng quyền: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "Kích hoạt các ô hotbar",
+4 -3
View File
@@ -269,7 +269,7 @@
"CmdSkillDescription": "激活技能,或安装/更新/卸载/信任社区技能",
"CmdSkillsDescription": "列出本地技能、按前缀筛选,或浏览精选远程注册表",
"CmdDebtDescription": "检查或导出债务账本",
"CmdStashDescription": "暂存或恢复输入草稿(Ctrl+S 会优先发送排队的后续消息)",
"CmdStashDescription": "暂存或恢复输入草稿",
"CmdStatusDescription": "显示当前运行状态",
"CmdStatuslineDescription": "配置底栏要显示哪些条目",
"CmdFleetDescription": "查看与配置 Fleet 工作节点及编排状态",
@@ -334,7 +334,8 @@
"KbStashDraft": "暂存当前草稿(用 `/stash pop` 恢复)",
"KbSearchHistory": "搜索提示历史并恢复本地草稿",
"KbInsertNewline": "在输入框中插入换行",
"KbSendDraft": "发送当前草稿",
"KbSendDraft": "空闲时发送,忙碌时排队;输入框为空时立即发送下一条排队消息",
"KbSteerCurrentTurn": "调整当前轮次(空闲时正常发送)",
"KbCloseMenu": "关闭菜单、取消请求、丢弃草稿或清空输入",
"KbCancelOrExit": "取消请求,或空闲时退出",
"KbShellControls": "将正在等待的 shell 移到 /jobs",
@@ -351,7 +352,7 @@
"KbExternalEditor": "在外部编辑器中打开撰写中的草稿",
"KbLiveTranscript": "打开实时对话覆盖层(自动滚动尾随)",
"KbBacktrackMessage": "回退到之前的用户消息(左右键步进,Enter 回退)",
"KbCompleteCycleModes": "补全 /command、排队运行轮次跟进、切换模式;Shift+Tab 切换推理强度",
"KbCompleteCycleModes": "补全 /command,或在输入框为空时切换模式(Plan → Act → Operate",
"KbCycleThinking": "轮换当前提供商的推理强度 (Ctrl+T)",
"KbCyclePermissions": "循环权限: Ask → Auto-Review → Full Access (Shift+Tab)",
"KbJumpPlanAgentYolo": "触发快捷栏槽位",
+2
View File
@@ -425,6 +425,7 @@ pub enum MessageId {
KbSearchHistory,
KbInsertNewline,
KbSendDraft,
KbSteerCurrentTurn,
KbCloseMenu,
KbCancelOrExit,
KbShellControls,
@@ -1617,6 +1618,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::KbSearchHistory,
MessageId::KbInsertNewline,
MessageId::KbSendDraft,
MessageId::KbSteerCurrentTurn,
MessageId::KbCloseMenu,
MessageId::KbCancelOrExit,
MessageId::KbShellControls,
+12 -17
View File
@@ -1004,7 +1004,7 @@ pub struct App {
pub dispatch_in_flight: bool,
/// Timestamp of the most recent Enter while the engine was busy.
/// Retained for session layout compatibility; bare-Enter double-tap
/// steering was removed (use Shift+Enter / Ctrl+Enter instead).
/// steering was removed (use Ctrl+Enter instead).
#[allow(dead_code)]
pub last_enter_instant: Option<Instant>,
/// Whether the once-per-turn provider-wait incident (#3095) has already
@@ -1546,8 +1546,8 @@ pub struct App {
/// Draft queued message being edited
pub queued_draft: Option<QueuedMessage>,
/// Legacy pending-steer bucket retained for session compatibility. New
/// in-flight input uses Enter for same-turn steering and Tab for queued
/// follow-ups; Esc only cancels the active turn.
/// in-flight input uses Ctrl+Enter for same-turn steering and Enter for
/// queued follow-ups; Esc only cancels the active turn.
pub pending_steers: VecDeque<QueuedMessage>,
/// Engine-rejected steers (e.g. a tool was already running and couldn't be
/// cancelled cleanly). Surfaced in the pending-input preview so the user
@@ -3583,7 +3583,8 @@ impl App {
}
/// Park a legacy pending steer. New keyboard handling routes running-turn
/// drafts through Enter (same-turn steer) or Tab (next-turn follow-up).
/// drafts through Ctrl+Enter (same-turn steer) or Enter (next-turn
/// follow-up).
#[allow(dead_code)]
pub fn push_pending_steer(&mut self, message: QueuedMessage) {
self.pending_steers.push_back(message);
@@ -3604,15 +3605,13 @@ impl App {
/// Decide how to route a fresh composer submit.
///
/// v0.8.68: streaming output queues. Busy-but-waiting turns steer so
/// Enter can amend the active turn before output starts. Explicit Shift/Ctrl+Enter
/// Enter within 500 ms triggers Steer while streaming; Ctrl+Enter forces
/// Steer in all busy states.
/// Running turns always queue bare-Enter submissions. Ctrl+Enter is the
/// single explicit gesture for amending the active turn, regardless of
/// whether the provider has emitted its first token yet.
///
/// Truth table:
/// offline=F, busy=F → Immediate
/// offline=F, busy=T, streaming=FSteer
/// offline=F, busy=T, streaming=T → Queue (Shift/Ctrl+Enter steers)
/// offline=F, busy=T, streaming=*Queue (Ctrl+Enter steers)
/// offline=T, busy=* → Queue
#[must_use]
pub fn decide_submit_disposition(&self) -> SubmitDisposition {
@@ -3627,19 +3626,15 @@ impl App {
if !self.is_loading {
return SubmitDisposition::Immediate;
}
if self.streaming_message_index.is_none() {
return SubmitDisposition::Steer;
}
// Streaming: queue the message. Steer is an explicit gesture
// (Shift+Enter / Ctrl+Enter), not a bare double-Enter race.
// Busy: queue the message. Steer is an explicit Ctrl+Enter gesture,
// not a timing-sensitive change in bare Enter behavior.
SubmitDisposition::Queue
}
/// Resolve what bare Enter should do right now.
///
/// When the engine is busy, Enter queues. When idle, Enter submits
/// immediately. Steering is only available via explicit Shift+Enter or
/// Ctrl+Enter — a second bare Enter after queueing must not interrupt.
/// immediately. Steering is only available via explicit Ctrl+Enter.
#[must_use]
pub fn enter_with_double_tap(&mut self) -> Option<SubmitDisposition> {
// Name kept for call-site stability; the double-tap window is gone.
+7 -6
View File
@@ -3736,13 +3736,13 @@ fn submit_disposition_immediate_when_idle_and_online() {
#[test]
fn submit_disposition_queue_when_busy_and_online_not_streaming() {
// Busy but not streaming means the model is still waiting, so Enter can
// amend the active turn immediately.
// Bare Enter has one stable busy-state meaning even before the provider
// emits its first token: queue a follow-up for the next turn.
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.offline_mode = false;
// streaming_message_index is None (default) → waiting phase
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Steer);
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
}
#[test]
@@ -3776,7 +3776,7 @@ fn submit_disposition_offline_busy_queues() {
#[test]
fn bare_enter_while_streaming_stays_queue_not_steer() {
let mut app = App::new(test_options(false), &Config::default());
// Busy + streaming: every bare Enter queues. Steer is Shift/Ctrl+Enter only.
// Busy + streaming: every bare Enter queues. Steer is Ctrl+Enter only.
app.is_loading = true;
app.streaming_message_index = Some(0);
@@ -3787,7 +3787,7 @@ fn bare_enter_while_streaming_stays_queue_not_steer() {
}
#[test]
fn empty_composer_second_enter_does_not_steal_queued_for_steer() {
fn submit_disposition_does_not_mutate_the_queue() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.streaming_message_index = Some(0);
@@ -3795,7 +3795,8 @@ fn empty_composer_second_enter_does_not_steal_queued_for_steer() {
app.queue_message(QueuedMessage::new("older queued".to_string(), None));
app.queue_message(QueuedMessage::new("just typed follow-up".to_string(), None));
assert!(app.input.is_empty());
// Bare Enter no longer pops the queue for steer; messages stay queued.
// The event loop owns empty-Enter queue promotion. Merely asking for the
// typed-submit disposition must not mutate queue state.
assert_eq!(app.enter_with_double_tap(), Some(SubmitDisposition::Queue));
assert_eq!(app.queued_message_count(), 2);
}
+1 -11
View File
@@ -174,17 +174,7 @@ pub(crate) fn is_composer_newline_key(key: KeyEvent) -> bool {
}
pub(crate) fn is_forced_submit_key(key: KeyEvent) -> bool {
match key.code {
KeyCode::Enter => key.modifiers.contains(KeyModifiers::CONTROL),
// Several terminals encode Ctrl+Enter / Cmd+Enter as Ctrl+J. Keep
// Ctrl+J available as a newline while idle, but let the event loop use
// this helper to force a live steer when a turn is already running.
KeyCode::Char('j') | KeyCode::Char('J') => {
key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
}
_ => false,
}
matches!(key.code, KeyCode::Enter) && key.modifiers.contains(KeyModifiers::CONTROL)
}
pub(crate) fn handle_history_search_key(app: &mut App, key: KeyEvent) {
+44
View File
@@ -179,6 +179,11 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[
description_id: crate::localization::MessageId::KbSendDraft,
section: KeybindingSection::Submission,
},
KeybindingEntry {
chord: "Ctrl+Enter / Cmd+Enter",
description_id: crate::localization::MessageId::KbSteerCurrentTurn,
section: KeybindingSection::Submission,
},
KeybindingEntry {
chord: "Esc",
description_id: crate::localization::MessageId::KbCloseMenu,
@@ -383,6 +388,45 @@ mod tests {
);
}
#[test]
fn composer_catalog_assigns_one_stable_role_to_each_chord() {
let chord_for = |id| {
KEYBINDINGS
.iter()
.find(|entry| entry.description_id == id)
.expect("composer binding should be documented")
.chord
};
assert_eq!(
chord_for(crate::localization::MessageId::KbInsertNewline),
"Ctrl+J / Alt+Enter / Shift+Enter"
);
assert_eq!(
chord_for(crate::localization::MessageId::KbSteerCurrentTurn),
"Ctrl+Enter / Cmd+Enter"
);
assert_eq!(
chord_for(crate::localization::MessageId::KbStashDraft),
"Ctrl+G / Ctrl+S"
);
assert_eq!(
chord_for(crate::localization::MessageId::KbSendDraft),
"Enter"
);
let tab_copy = crate::localization::tr(
crate::localization::Locale::En,
crate::localization::MessageId::KbCompleteCycleModes,
);
assert!(!tab_copy.to_ascii_lowercase().contains("queue"));
let stash_copy = crate::localization::tr(
crate::localization::Locale::En,
crate::localization::MessageId::KbStashDraft,
);
assert!(!stash_copy.to_ascii_lowercase().contains("send"));
}
#[test]
fn clipboard_help_distinguishes_terminal_text_graphical_image_and_in_app_copy() {
let terminal_paste = KEYBINDINGS
+46 -89
View File
@@ -5649,7 +5649,13 @@ async fn run_event_loop(
let has_ctrl_alt_or_super = super::widgets::key_hint::has_ctrl_or_alt(key.modifiers)
|| key.modifiers.contains(KeyModifiers::SUPER);
let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super;
let is_enter = matches!(key.code, KeyCode::Enter);
// Only bare Enter participates in trailing-newline paste-burst
// protection. Modified Enter chords are deliberate composer
// actions: flush any buffered text, then route the chord normally
// so Shift/Alt+Enter newline and Ctrl+Enter steer are never eaten
// after fast typing or an unbracketed paste.
let is_plain_enter =
matches!(key.code, KeyCode::Enter) && key.modifiers == KeyModifiers::NONE;
// Tool details: Alt+V / Option+V only. Bare `v` always types `v`
// in every focus state (TUI-DOG-002).
@@ -5659,13 +5665,15 @@ async fn run_event_loop(
}
if !is_plain_char
&& !is_enter
&& !is_plain_enter
&& let Some(pending) = app.flush_paste_burst_before_modified_input_if_enabled()
{
app.insert_str(&pending);
}
if (is_plain_char || is_enter) && super::paste::handle_paste_burst_key(app, &key, now) {
if (is_plain_char || is_plain_enter)
&& super::paste::handle_paste_burst_key(app, &key, now)
{
continue;
}
@@ -6141,9 +6149,6 @@ async fn run_event_loop(
if crate::tui::file_mention::try_autocomplete_file_mention(app) {
continue;
}
if app.is_loading && queue_current_draft_for_next_turn(app) {
continue;
}
if app.input.is_empty()
&& let Some(suggestion) = app.prompt_suggestion.take()
{
@@ -6152,6 +6157,13 @@ async fn run_event_loop(
app.needs_redraw = true;
continue;
}
// Tab is completion when the composer has content and a
// mode switch only when it is empty. Sending or queueing
// input is reserved for Enter so Tab never changes roles
// based on whether a turn happens to be running.
if !app.input.is_empty() {
continue;
}
let prior_model = app.model.clone();
let prior_mode = app.mode;
app.cycle_mode();
@@ -6212,48 +6224,8 @@ async fn run_event_loop(
// Help chords (Alt+?, F1, Ctrl+/) are handled above via
// shell_key_routing::is_help_shortcut so printable layout
// characters stay text.
// Shift+Enter steers a running turn. When idle, the
// normal composer-newline branch below still handles it
// as a multiline input gesture.
KeyCode::Enter
if app.is_loading
&& key.modifiers.contains(KeyModifiers::SHIFT)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
if let Some(input) = app.submit_input() {
if handle_bang_shell_input(app, &engine_handle, &input).await? {
continue;
}
if looks_like_slash_command_input(&input) {
if execute_command_input(
terminal,
app,
&mut engine_handle,
&task_manager,
config,
&mut web_config_session,
&input,
)
.await?
{
return Ok(());
}
} else {
let queued = if let Some(mut draft) = app.queued_draft.take() {
draft.display = input;
draft
} else {
build_queued_message(app, input)
};
attempt_steer_with_queue_fallback(app, &engine_handle, queued).await;
}
}
}
// Input handling
_ if is_composer_newline_key(key)
&& !(app.is_loading && is_forced_submit_key(key)) =>
{
_ if is_composer_newline_key(key) => {
app.insert_char('\n');
}
KeyCode::Enter
@@ -6266,12 +6238,9 @@ async fn run_event_loop(
continue;
}
// #382: Ctrl+Enter forces a steer into the current turn.
// Some terminals report Ctrl/Cmd+Enter as Ctrl+J; while a
// turn is running, accept that encoding here instead of
// inserting a newline.
_ if is_forced_submit_key(key)
&& (matches!(key.code, KeyCode::Enter) || app.is_loading) =>
{
// Ctrl+J remains a newline everywhere; it never changes
// meaning based on engine state.
_ if is_forced_submit_key(key) => {
if let Some(input) = app.submit_input() {
if handle_bang_shell_input(app, &engine_handle, &input).await? {
continue;
@@ -6315,6 +6284,18 @@ async fn run_event_loop(
}
}
}
// An empty Enter promotes the oldest queued follow-up into
// the active turn. This is the only context-sensitive Enter
// shortcut: typed Enter always submits (idle) or queues
// (busy), while Ctrl+Enter always means explicit steer.
KeyCode::Enter
if app.is_loading
&& app.input.is_empty()
&& app.queued_draft.is_none()
&& !app.queued_messages.is_empty() =>
{
let _ = send_next_queued_message_now(app, config, &engine_handle).await?;
}
KeyCode::Enter => {
// #573: when the user typed a slash-command prefix that
// the popup is matching (e.g. `/mo` → `/model`), Enter
@@ -6644,9 +6625,6 @@ async fn run_event_loop(
| KeyCode::Char('G')
if key.modifiers == KeyModifiers::CONTROL =>
{
if send_shortcut_queued_message_now(app, config, &engine_handle).await? {
continue;
}
// #440: park the current draft to the persistent stash and
// clear the composer. Ctrl+G is the terminal-safe alias for
// hosts such as Cursor/VS Code that reserve Ctrl+S for Save.
@@ -6655,7 +6633,13 @@ async fn run_event_loop(
// confirmation (no-op feels broken otherwise).
if !app.input.is_empty() {
crate::composer_stash::push_stash(&app.input);
app.clear_input_recoverable();
if app.queued_draft.is_some() {
// Stash the edited text while preserving the
// original queued follow-up in its queue slot.
let _ = app.cancel_queued_draft_edit();
} else {
app.clear_input_recoverable();
}
app.push_status_toast(
"Draft stashed — `/stash pop` to restore",
StatusToastLevel::Info,
@@ -6750,7 +6734,7 @@ async fn run_event_loop(
_ => {}
}
if !is_plain_char && !is_enter {
if !is_plain_char && !is_plain_enter {
app.paste_burst.deactivate_keep_window();
}
}
@@ -8368,33 +8352,7 @@ async fn submit_initial_input_if_ready(
Ok(())
}
fn queue_current_draft_for_next_turn(app: &mut App) -> bool {
let Some(input) = app.submit_input() else {
return false;
};
let queued = if let Some(mut draft) = app.queued_draft.take() {
draft.display = input;
draft
} else {
build_queued_message(app, input)
};
enqueue_offline_message(app, queued);
let toast = format!(
"{} queued follow-up(s) — sends after current output; ↑ edit last, /queue send <n>",
app.queued_message_count()
);
app.status_message = Some(toast.clone());
app.push_status_toast(toast, StatusToastLevel::Info, Some(3_000));
true
}
fn take_shortcut_queued_message(app: &mut App) -> Option<(QueuedMessage, Option<usize>)> {
if let Some(mut draft) = app.queued_draft.take() {
if let Some(input) = app.submit_input() {
draft.display = input;
}
return Some((draft, None));
}
fn take_next_queued_message(app: &mut App) -> Option<(QueuedMessage, Option<usize>)> {
if app.input.is_empty() {
return app
.remove_queued_message(0)
@@ -8403,12 +8361,12 @@ fn take_shortcut_queued_message(app: &mut App) -> Option<(QueuedMessage, Option<
None
}
async fn send_shortcut_queued_message_now(
async fn send_next_queued_message_now(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
) -> Result<bool> {
let Some((message, restore_index)) = take_shortcut_queued_message(app) else {
let Some((message, restore_index)) = take_next_queued_message(app) else {
return Ok(false);
};
send_taken_queued_message_now(app, config, engine_handle, message, restore_index).await?;
@@ -12116,8 +12074,7 @@ async fn submit_or_steer_message(
app.push_status_toast(toast, StatusToastLevel::Info, Some(3_000));
Ok(())
}
// Steer: reached via Enter when busy-but-waiting (v0.8.44), or
// via Ctrl+Enter override in any busy state.
// Steer: reached only via Ctrl+Enter in a busy state.
SubmitDisposition::Steer => {
attempt_steer_with_queue_fallback(app, engine_handle, message).await;
Ok(())
+22 -66
View File
@@ -1292,7 +1292,7 @@ fn composer_newline_shortcuts_do_not_steal_ctrl_enter() {
}
#[test]
fn forced_submit_accepts_ctrl_enter_and_ctrl_j_encodings() {
fn forced_submit_accepts_only_ctrl_enter() {
assert!(is_forced_submit_key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::CONTROL,
@@ -1301,11 +1301,11 @@ fn forced_submit_accepts_ctrl_enter_and_ctrl_j_encodings() {
KeyCode::Enter,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
)));
assert!(is_forced_submit_key(KeyEvent::new(
assert!(!is_forced_submit_key(KeyEvent::new(
KeyCode::Char('j'),
KeyModifiers::CONTROL,
)));
assert!(is_forced_submit_key(KeyEvent::new(
assert!(!is_forced_submit_key(KeyEvent::new(
KeyCode::Char('J'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
)));
@@ -10599,7 +10599,7 @@ async fn steer_user_message_records_prompt_for_cancel_restore() {
}
#[tokio::test]
async fn composer_send_shortcut_sends_next_queued_message_into_running_turn() {
async fn empty_enter_sends_next_queued_message_into_running_turn() {
let mut app = create_test_app();
app.is_loading = true;
app.queue_message(crate::tui::app::QueuedMessage::new(
@@ -10610,9 +10610,9 @@ async fn composer_send_shortcut_sends_next_queued_message_into_running_turn() {
let mut engine = crate::core::engine::mock_engine_handle();
assert!(
send_shortcut_queued_message_now(&mut app, &config, &engine.handle)
send_next_queued_message_now(&mut app, &config, &engine.handle)
.await
.expect("composer send shortcut succeeds")
.expect("empty Enter succeeds")
);
assert_eq!(app.queued_message_count(), 0);
@@ -10623,7 +10623,7 @@ async fn composer_send_shortcut_sends_next_queued_message_into_running_turn() {
}
#[tokio::test]
async fn composer_send_shortcut_sends_edited_queued_draft_into_running_turn() {
async fn empty_enter_does_not_send_an_edited_queued_draft() {
let mut app = create_test_app();
app.is_loading = true;
app.queued_draft = Some(crate::tui::app::QueuedMessage::new(
@@ -10636,17 +10636,15 @@ async fn composer_send_shortcut_sends_edited_queued_draft_into_running_turn() {
let mut engine = crate::core::engine::mock_engine_handle();
assert!(
send_shortcut_queued_message_now(&mut app, &config, &engine.handle)
!send_next_queued_message_now(&mut app, &config, &engine.handle)
.await
.expect("composer send shortcut succeeds")
.expect("empty Enter no-op succeeds")
);
assert!(app.queued_draft.is_none());
assert!(app.input.is_empty());
assert!(app.queued_draft.is_some());
assert_eq!(app.input, "edited queued follow-up");
assert_eq!(app.queued_message_count(), 0);
let content = engine.rx_steer.recv().await.expect("steer content");
assert!(content.contains("edited queued follow-up"));
assert!(content.contains("skill body"));
assert!(engine.rx_steer.try_recv().is_err());
}
#[test]
@@ -10797,14 +10795,14 @@ async fn streaming_enter_queue_pushes_visible_toast() {
}
#[tokio::test]
async fn empty_composer_second_enter_leaves_queued_message() {
// Bare Enter while streaming only queues. A second bare Enter must not
// steal the just-queued body for steer — use Shift+Enter / Ctrl+Enter.
async fn empty_enter_promotes_the_oldest_queued_message() {
// Typed Enter while streaming queues. An explicit empty Enter promotes
// the oldest queued follow-up into the active turn.
let mut app = create_test_app();
app.is_loading = true;
app.streaming_message_index = Some(0);
let config = Config::default();
let engine = crate::core::engine::mock_engine_handle();
let mut engine = crate::core::engine::mock_engine_handle();
let queued = build_queued_message(&mut app, "coordinate parallel tasks".to_string());
submit_or_steer_message(&mut app, &config, &engine.handle, queued)
@@ -10813,15 +10811,14 @@ async fn empty_composer_second_enter_leaves_queued_message() {
assert_eq!(app.queued_message_count(), 1);
assert!(app.input.is_empty());
// Second bare Enter with empty composer is a no-op for queue contents.
assert!(app.input.trim().is_empty());
assert_eq!(
app.decide_submit_disposition(),
crate::tui::app::SubmitDisposition::Queue
assert!(
send_next_queued_message_now(&mut app, &config, &engine.handle)
.await
.expect("empty Enter promotes queue")
);
assert_eq!(app.queued_message_count(), 1);
assert_eq!(app.queued_message_count(), 0);
assert_eq!(
app.queued_messages.front().map(|m| m.display.as_str()),
engine.rx_steer.recv().await.as_deref(),
Some("coordinate parallel tasks")
);
}
@@ -16264,47 +16261,6 @@ fn next_escape_action_slash_menu_takes_priority() {
assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);
}
#[test]
fn tab_queues_running_turn_draft_for_next_turn() {
let mut app = create_test_app();
app.is_loading = true;
app.input = "follow up next".to_string();
app.cursor_position = app.input.chars().count();
assert!(queue_current_draft_for_next_turn(&mut app));
assert!(app.input.is_empty());
assert_eq!(app.queued_message_count(), 1);
assert_eq!(
app.queued_messages.front().map(|msg| msg.display.as_str()),
Some("follow up next")
);
assert!(
app.status_message
.as_deref()
.is_some_and(|msg| msg.contains("queued follow-up(s)"))
);
}
#[test]
fn tab_queue_preserves_queued_draft_skill_instruction() {
let mut app = create_test_app();
app.is_loading = true;
app.input = "edited queued follow-up".to_string();
app.cursor_position = app.input.chars().count();
app.queued_draft = Some(QueuedMessage::new(
"original".to_string(),
Some("skill body".to_string()),
));
assert!(queue_current_draft_for_next_turn(&mut app));
let queued = app.queued_messages.front().expect("queued message");
assert_eq!(queued.display, "edited queued follow-up");
assert_eq!(queued.skill_instruction.as_deref(), Some("skill body"));
assert!(app.queued_draft.is_none());
}
#[test]
fn merge_pending_steers_returns_none_when_empty() {
let mut app = create_test_app();
+11 -13
View File
@@ -1159,9 +1159,8 @@ impl Renderable for ComposerWidget<'_> {
} else if !input_text.trim().is_empty() {
// Live disambiguation for #345: when there's content in the
// composer, show what `Enter` will do RIGHT NOW so the user
// never has to guess between Immediate / Steer / QueueFollowUp /
// Queue. The disposition flips with engine state so this hint
// is the only reliable cue before pressing Enter.
// never has to guess between Immediate / QueueFollowUp /
// Queue. Ctrl+Enter is the separate, stable steer gesture.
use crate::tui::app::SubmitDisposition;
let queue_count = self.app.queued_message_count();
let (label, color) = match self.app.decide_submit_disposition() {
@@ -1179,39 +1178,38 @@ impl Renderable for ComposerWidget<'_> {
if self.app.offline_mode {
(Some("↵ offline queue".to_string()), palette::STATUS_WARNING)
} else if self.app.mode == crate::tui::app::AppMode::Operate {
// Ctrl+G sends an already-queued item now; with only
// composer text it stashes the draft (#440). Steer is
// an explicit Shift+Enter / Ctrl+Enter gesture.
// Enter queues while busy; Ctrl+Enter explicitly
// steers. Ctrl+G/Ctrl+S only stash drafts (#440).
let label = if queue_count > 0 {
format!(
"↵ queue task ({} waiting) · ⇧↵ steer · Ctrl+G send queued",
"↵ queue task ({} waiting) · Ctrl+ steer",
queue_count.saturating_add(1)
)
} else {
"↵ queue task · ↵ steer".to_string()
"↵ queue task · Ctrl+↵ steer".to_string()
};
(Some(label), palette::WHALE_INFO)
} else {
let label = if queue_count > 0 {
format!(
"↵ queue ({} waiting) · ⇧↵ steer · Ctrl+G send queued",
"↵ queue ({} waiting) · Ctrl+ steer",
queue_count.saturating_add(1)
)
} else {
"↵ queue · ↵ steer".to_string()
"↵ queue · Ctrl+↵ steer".to_string()
};
(Some(label), palette::TEXT_MUTED)
}
}
// Steer is reached via Shift+Enter or Ctrl+Enter only.
// Steer is reached via Ctrl+Enter only.
SubmitDisposition::Steer => {
(Some("↵ steering".to_string()), palette::WHALE_INFO)
}
SubmitDisposition::QueueFollowUp => (
Some(if self.app.mode == crate::tui::app::AppMode::Operate {
"↵ queued task · ⇧↵ steer · Ctrl+G send queued".to_string()
"↵ queued task · Ctrl+ steer".to_string()
} else {
"↵ queued · ⇧↵ steer · Ctrl+G send queued".to_string()
"↵ queued · Ctrl+ steer".to_string()
}),
palette::TEXT_MUTED,
),
@@ -121,7 +121,7 @@ impl PendingInputPreview {
);
let controls = crate::localization::truncate_to_width(
&format!(
"Ctrl+G send · {} edit · /queue drop 1",
"Enter send now · {} edit · /queue drop 1",
self.edit_binding.label
),
usize::from(width),
@@ -206,7 +206,7 @@ impl PendingInputPreview {
if !self.queued_messages.is_empty() {
lines.push(Line::from(vec![Span::styled(
format!(
" Ctrl+G send now · {} edit last queued",
" Enter send now · {} edit last queued",
self.edit_binding.label
),
dim,
@@ -448,7 +448,7 @@ mod tests {
let rows = render_to_string(&preview, 40);
assert_eq!(rows.len(), 2, "got rows: {rows:?}");
assert!(rows[0].contains("Queued #1: Hello, world!"));
assert!(rows[1].contains("Ctrl+G send"));
assert!(rows[1].contains("Enter send now"));
assert!(rows[1].contains("↑ edit"));
assert!(rows[1].contains("/queue drop 1"));
}
@@ -463,7 +463,7 @@ mod tests {
for (width, height) in [(40, 1), (40, 2), (60, 3)] {
let rows = render_in_area(&preview, width, height);
assert!(
rows.iter().any(|row| row.contains("Ctrl+G send")),
rows.iter().any(|row| row.contains("Enter send now")),
"send control clipped at {width}x{height}: {rows:?}"
);
}
@@ -574,7 +574,7 @@ mod tests {
assert!(rows.iter().any(|r| r.contains("rejected")));
assert!(rows.iter().any(|r| r.contains("queued")));
assert!(rows.iter().any(|r| r.contains("")));
assert!(rows.iter().any(|r| r.contains("Ctrl+G")));
assert!(rows.iter().any(|r| r.contains("Enter send now")));
}
#[test]
@@ -621,7 +621,7 @@ mod tests {
assert_eq!(rows.len(), 2, "got rows: {rows:?}");
assert!(rows[0].contains("Queued #1: alpha"));
assert!(rows[0].contains('…'));
assert!(rows[1].contains("Ctrl+G send"));
assert!(rows[1].contains("Enter send now"));
}
#[test]
@@ -634,7 +634,7 @@ mod tests {
assert_eq!(rows.len(), 2, "got rows: {rows:?}");
assert!(rows[0].contains("Queued #1: line1 line2"));
assert!(rows[0].contains('…'));
assert!(rows[1].contains("Ctrl+G send"));
assert!(rows[1].contains("Enter send now"));
assert!(rows[1].contains("↑ edit"));
}
@@ -648,7 +648,7 @@ mod tests {
let rows = render_to_string(&preview, 36);
assert_eq!(rows.len(), 2, "got rows: {rows:?}");
assert!(rows[0].contains("Queued #1:"));
assert!(rows[1].contains("Ctrl+G send"));
assert!(rows[1].contains("Enter send now"));
}
#[test]
+49
View File
@@ -97,6 +97,55 @@ fn enter_launch_session(h: &mut Harness) -> anyhow::Result<()> {
Ok(())
}
#[test]
fn composer_newline_and_stash_chords_keep_stable_roles() -> anyhow::Result<()> {
let _guard = qa_pty_test_lock();
let (ws, mut h) = boot_minimal()?;
h.send(keys::key::text("shift-line"))?;
h.send(keys::key::shift_enter())?;
h.send(keys::key::text("alt-line"))?;
h.send(keys::key::alt_enter())?;
h.send(keys::key::text("ctrl-j-line"))?;
h.send(keys::key::ctrl_j())?;
h.send(keys::key::text("last-line"))?;
h.wait_for_text("last-line", KEY_TIMEOUT)?;
let frame = h.frame();
let rows = ["shift-line", "alt-line", "ctrl-j-line", "last-line"].map(|line| {
frame
.find_text(line)
.expect("multiline draft stays visible")
.0
});
assert!(
rows.windows(2).all(|pair| pair[0] < pair[1]),
"Shift+Enter, Alt+Enter, and Ctrl+J must each add a line:\n{}",
frame.debug_dump()
);
h.send(keys::key::ctrl_g())?;
h.wait_for_text("Draft stashed", KEY_TIMEOUT)?;
h.wait_for_text(COMPOSER_READY_TEXT, KEY_TIMEOUT)?;
let stash_path = ws.home().join(".codewhale/composer_stash.jsonl");
let first_stash = std::fs::read_to_string(&stash_path)?;
assert!(first_stash.contains("shift-line\\nalt-line\\nctrl-j-line\\nlast-line"));
h.send(keys::key::text("/stash pop"))?;
h.wait_for_text("/stash pop", KEY_TIMEOUT)?;
std::thread::sleep(Duration::from_millis(180));
h.send(keys::key::enter())?;
h.wait_for_text("last-line", KEY_TIMEOUT)?;
h.send(keys::key::ctrl_s())?;
h.wait_for_text(COMPOSER_READY_TEXT, KEY_TIMEOUT)?;
let second_stash = std::fs::read_to_string(&stash_path)?;
assert!(second_stash.contains("shift-line\\nalt-line\\nctrl-j-line\\nlast-line"));
let _ = h.shutdown();
Ok(())
}
fn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyhow::Result<()> {
let dir = root.join(name);
std::fs::create_dir_all(&dir)?;
+101 -16
View File
@@ -2,8 +2,7 @@
//!
//! These scenarios cover the live TUI checks that unit tests cannot prove:
//! six-worker fanout liveness/cancellation, multi-terminal route isolation,
//! and queued steering via the terminal-safe Ctrl+G shortcut. Every provider is a loopback
//! wiremock
//! and the explicit Enter-queue / Ctrl+Enter-steer contract. Every provider is a loopback wiremock
//! server and every process receives a sealed HOME.
#![cfg(unix)]
@@ -285,13 +284,6 @@ fn type_and_submit(harness: &mut Harness, text: &str) -> Result<()> {
Ok(())
}
fn type_and_tab(harness: &mut Harness, text: &str) -> Result<()> {
harness.send(keys::key::text(text))?;
harness.wait_for_text(text, Duration::from_secs(3))?;
harness.send(b"\t")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn underwater_footer_moves_from_working_through_one_shot_completion() -> Result<()> {
let _guard = RELEASE_RUNTIME_QA_LOCK.lock().await;
@@ -782,10 +774,14 @@ impl Respond for SteeringResponder {
fn respond(&self, request: &Request) -> ResponseTemplate {
let body = request.body_json::<Value>().unwrap_or(Value::Null);
let raw = body.to_string();
if raw.contains("queued steering from ctrl-g") {
if raw.contains("queued steering from enter") {
self.steer_requests.fetch_add(1, Ordering::SeqCst);
return sse_response(text_sse(DEEPSEEK_TEST_MODEL, "steering-applied"));
}
if raw.contains("direct steering from ctrl-enter") {
self.steer_requests.fetch_add(1, Ordering::SeqCst);
return sse_response(text_sse(DEEPSEEK_TEST_MODEL, "direct-steering-applied"));
}
if raw.contains("initial slow turn") {
self.initial_requests.fetch_add(1, Ordering::SeqCst);
return sse_response(text_sse(DEEPSEEK_TEST_MODEL, "initial-turn-output"))
@@ -798,7 +794,7 @@ impl Respond for SteeringResponder {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn release_queued_steering_ctrl_g_sends_now_with_clear_status() -> Result<()> {
async fn release_empty_enter_promotes_queued_follow_up() -> Result<()> {
let _guard = RELEASE_RUNTIME_QA_LOCK.lock().await;
let server = MockServer::start().await;
mount_models(&server, &[DEEPSEEK_TEST_MODEL]).await;
@@ -829,21 +825,110 @@ async fn release_queued_steering_ctrl_g_sends_now_with_clear_status() -> Result<
// A dead engine still fails closed because the counter never advances.
wait_for_counter(&mut tui, &initial_requests, 1, INTERACTION_TIMEOUT)?;
type_and_tab(&mut tui, "queued steering from ctrl-g")?;
tui.wait_for_text("Ctrl+G send", Duration::from_secs(5))?;
tui.send(keys::key::text("queued steering from enter"))?;
tui.wait_for_text("queued steering from enter", Duration::from_secs(3))?;
tui.send(b"\t")?;
std::thread::sleep(PASTE_GUARD_SETTLE);
tui.pump();
assert!(
tui.frame().contains("queued steering from ctrl-g"),
tui.frame().contains("queued steering from enter"),
"Tab must leave a busy-turn draft in the composer:\n{}",
tui.debug_dump()
);
tui.send(keys::key::enter())?;
tui.wait_for_text("Enter send now", Duration::from_secs(5))?;
assert!(
tui.frame().contains("queued steering from enter"),
"queued steering preview was not readable:\n{}",
tui.debug_dump()
);
tui.send(keys::key::text("stash this draft, do not steer"))?;
tui.wait_for_text("stash this draft, do not steer", Duration::from_secs(3))?;
tui.send(keys::key::ctrl_g())?;
tui.wait_for_text("Draft stashed", Duration::from_secs(3))?;
assert_eq!(
steer_requests.load(Ordering::SeqCst),
0,
"Ctrl+G must not send a queued follow-up"
);
tui.wait_for_text("Enter send now", Duration::from_secs(3))?;
let steer_started = Instant::now();
tui.send(b"\x07")?;
tui.send(keys::key::enter())?;
wait_for_counter(&mut tui, &steer_requests, 1, INTERACTION_TIMEOUT)?;
tui.wait_for_text("steering-applied", INTERACTION_TIMEOUT)?;
assert!(
steer_started.elapsed() < Duration::from_secs(10),
"Ctrl+G steering was not incorporated promptly"
"empty Enter queue promotion was not incorporated promptly"
);
let _ = tui.shutdown();
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn release_ctrl_enter_steers_running_turn() -> Result<()> {
let _guard = RELEASE_RUNTIME_QA_LOCK.lock().await;
let server = MockServer::start().await;
mount_models(&server, &[DEEPSEEK_TEST_MODEL]).await;
let initial_requests = Arc::new(AtomicUsize::new(0));
let steer_requests = Arc::new(AtomicUsize::new(0));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(SteeringResponder {
initial_requests: Arc::clone(&initial_requests),
steer_requests: Arc::clone(&steer_requests),
})
.mount(&server)
.await;
let ws = make_sealed_workspace()?;
let mut tui = common_tui_builder(&ws)
.env("CODEWHALE_PROVIDER", "deepseek")
.env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
.env("DEEPSEEK_BASE_URL", server.uri())
.env("DEEPSEEK_MODEL", DEEPSEEK_TEST_MODEL)
.spawn()?;
enter_launch_session(&mut tui)?;
type_and_submit(&mut tui, "initial slow turn")?;
wait_for_counter(&mut tui, &initial_requests, 1, INTERACTION_TIMEOUT)?;
tui.send(keys::key::text("busy-shift-line"))?;
tui.send(keys::key::shift_enter())?;
tui.send(keys::key::text("busy-alt-line"))?;
tui.send(keys::key::alt_enter())?;
tui.send(keys::key::text("busy-ctrl-j-line"))?;
tui.send(keys::key::ctrl_j())?;
tui.send(keys::key::text("direct steering from ctrl-enter"))?;
tui.wait_for_text("direct steering from ctrl-enter", Duration::from_secs(3))?;
let frame = tui.frame();
let rows = [
"busy-shift-line",
"busy-alt-line",
"busy-ctrl-j-line",
"direct steering from ctrl-enter",
]
.map(|line| {
frame
.find_text(line)
.expect("busy multiline draft stays visible")
.0
});
assert!(
rows.windows(2).all(|pair| pair[0] < pair[1]),
"newline chords must stay newlines during a running turn:\n{}",
frame.debug_dump()
);
tui.wait_for_text("Ctrl+↵ steer", Duration::from_secs(3))?;
let steer_started = Instant::now();
tui.send(keys::key::ctrl_enter())?;
wait_for_counter(&mut tui, &steer_requests, 1, INTERACTION_TIMEOUT)?;
tui.wait_for_text("direct-steering-applied", INTERACTION_TIMEOUT)?;
assert!(
steer_started.elapsed() < Duration::from_secs(10),
"Ctrl+Enter steering was not incorporated promptly"
);
let _ = tui.shutdown();
+28 -2
View File
@@ -1,8 +1,9 @@
//! Byte-sequence builders for keys and paste.
//!
//! These produce the raw bytes a real terminal would deliver to the child's
//! PTY slave. They match crossterm's input-decoding tables (keyboard
//! enhancement off, mouse capture off, bracketed paste on).
//! PTY slave. They match crossterm's input-decoding tables: legacy sequences
//! by default, CSI-u for the modified Enter chords the TUI opts into, mouse
//! capture off, and bracketed paste on.
/// Plain key press helpers.
pub mod key {
@@ -15,6 +16,31 @@ pub mod key {
b"\r".to_vec()
}
/// Enhanced-keyboard (CSI-u) encodings used by the TUI for modified Enter.
pub fn shift_enter() -> Vec<u8> {
b"\x1b[13;2u".to_vec()
}
pub fn alt_enter() -> Vec<u8> {
b"\x1b[13;3u".to_vec()
}
pub fn ctrl_enter() -> Vec<u8> {
b"\x1b[13;5u".to_vec()
}
pub fn ctrl_j() -> Vec<u8> {
vec![0x0a]
}
pub fn ctrl_g() -> Vec<u8> {
vec![0x07]
}
pub fn ctrl_s() -> Vec<u8> {
vec![0x13]
}
pub fn esc() -> Vec<u8> {
vec![0x1b]
}
+14 -4
View File
@@ -1106,16 +1106,26 @@ Previews are capped before delivery so lifecycle hooks do not receive full
sub-agent prompts, transcripts, or unbounded results. Use the transcript handle
returned by `agent` when full sub-agent details are needed.
### Running-turn input
Composer shortcuts keep the same role throughout a session:
- **Enter** sends when idle and queues a next-turn follow-up while busy. The
behavior does not change before versus after the provider's first token.
- With an empty composer and queued follow-ups visible, **Enter** sends the
oldest queued follow-up into the active turn now.
- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly
steers the active turn. It sends normally when idle.
- **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** always insert a newline.
- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.
### Composer stash (`/stash`, Ctrl+G / Ctrl+S)
Press **Ctrl+G** in the composer to park the current draft to
`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked
drafts with one-line previews and timestamps; `/stash pop`
restores the most recently parked draft (LIFO); `/stash clear`
wipes the file. Capped at 200 entries; multiline drafts
round-trip intact. When a turn is already running and queued follow-ups exist,
the pending-input preview advertises **Ctrl+G send now**; in that state Ctrl+G
sends the next queued follow-up into the active turn instead of stashing.
wipes the file. Capped at 200 entries; multiline drafts round-trip intact.
**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code
reserve Ctrl+S for Save, so Ctrl+G is the portable default.
+5 -5
View File
@@ -14,7 +14,7 @@ Global key chords are not yet user-configurable — tracked for a future release
| `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit |
| `Ctrl-B` | Move a supported foreground shell wait into `/jobs` so the turn can continue; use `/jobs` or `Bash` with `action: "wait"` to inspect it |
| `Ctrl-D` | Quit (only when the composer is empty) |
| `Tab` | When the composer is idle, cycle TUI mode: Plan → Act → Operate → Plan |
| `Tab` | When the composer is empty, cycle TUI mode: Plan → Act → Operate → Plan |
| `Shift+Tab` | When the composer is idle, cycle permission posture: Ask → Auto-Review → Full Access |
| `Ctrl-T` | Cycle reasoning effort for the active provider. DeepSeek-style providers cycle off → high → max → off; OpenAI Codex cycles low → medium → high → xhigh → low. |
| `Ctrl-Shift-T` | Toggle live transcript overlay (sticky-tail auto-scroll) |
@@ -35,9 +35,9 @@ Editing the message you're about to send.
| Chord | Action |
|-----------------------------|---------------------------------------------------------|
| `Enter` | Send the message (or run the slash command) |
| `Alt-Enter` / `Ctrl-J` | Insert a newline without sending (`Ctrl-J` force-steers while a turn is running) |
| `Ctrl-Enter` / `Cmd-Enter` | Force a live steer into the current turn when supported by the terminal |
| `Enter` | Send when idle; queue while busy; with an empty composer, send the next queued follow-up now |
| `Shift-Enter` / `Alt-Enter` / `Ctrl-J` | Insert a newline without sending (idle or busy) |
| `Ctrl-Enter` / `Cmd-Enter` | Steer the current turn; send normally when idle (when supported by the terminal) |
| `Ctrl-U` | Clear the whole draft (recoverable — see `Ctrl-Z`) |
| `Ctrl-Z` | Restore the cleared draft (only while the composer is empty) |
| `Ctrl-W` | Delete previous word |
@@ -56,7 +56,7 @@ Editing the message you're about to send.
| `Ctrl-Y` | Yank (paste) from kill buffer |
| `↑` / `↓` | Cycle composer history (also selects popup/attachment items) |
| `Ctrl-P` / `Ctrl-N` | Cycle composer history (alternative) |
| `Ctrl-S` | Stash current draft; with queued follow-ups during a running turn, send the next queued item now |
| `Ctrl-G` / `Ctrl-S` | Stash current draft (`/stash pop` restores it); never sends or steers |
| `Alt-R` | Search prompt history (Alt-R to exit) |
| `Tab` | Slash-command / `@`-mention completion (popup-aware) |
| `Ctrl-Shift-O` / `F4` | Open the composer draft in `$VISUAL` / `$EDITOR`; F4 works when the terminal cannot distinguish Ctrl-Shift-O from Ctrl-O |
+3 -3
View File
@@ -20,9 +20,9 @@ into a resumable workflow with its own progress view.
## TUI Modes
Press `Tab` to complete composer menus, queue a draft as a next-turn follow-up
while a turn is running, or cycle through the visible modes when the composer is
otherwise idle: **Plan → Act → Operate → Plan**.
Press `Tab` to complete composer menus or cycle through the visible modes when
the composer is empty: **Plan → Act → Operate → Plan**. `Tab` never sends
or queues composer text; use `Enter` to send or queue it.
Press `Shift+Tab` to cycle permission posture (Ask → Auto-Review → Full Access).
Press `Ctrl+T` to cycle reasoning effort.
Run `/mode` to open the mode picker, or switch directly with `/mode act`,
+5 -3
View File
@@ -81,9 +81,11 @@ visual pass; the test leaves no provider traffic or credentials behind.
2. **Multi-terminal route isolation (#3227).** Open multiple terminals on
distinct provider/model routes. Confirm zero cross-terminal contamination and
no provider+model mismatch — each terminal honors its own route.
3. **Queued steering + Ctrl+G (#3203).** Queue a steering message into a busy
turn; confirm Ctrl+G sends the queued/draft message and queued-steering
status reads clearly.
3. **Running-turn input contract (#3203).** During a busy turn, confirm Enter
queues a typed follow-up, the preview advertises **Enter send now**, and an
empty Enter promotes the oldest queued follow-up. Confirm Ctrl+Enter steers
typed text directly, Shift+Enter inserts a newline, and Ctrl+G/Ctrl+S only
stash drafts.
## Reporting format