feat(tui): add a live terminal work dock

Show workspace PTY sessions in the work dock with bounded ANSI output,
session identity checks, resize, keyboard/paste input, cancellation and a
separate human shell. Preserve interactive stdin and current-shell state;
reject oversized canonical raw batches before writing and release the send
lock while waiting. Update all 15 locale packs, keybindings, feature registry,
Ratatui consumers, and root/embedded candidate release notes.

Refs #6912. Live PTYs are supported on macOS/Linux; Windows states its current
limitation. The dock shows an output tail rather than a full terminal emulator.

Validation: Terminal/dock selectors 208/208 PASS at source 499468bbf066;
sh/bash/zsh actual PTY journeys passed on unchanged Terminal code. CLI build
and TUI/CLI all-target/all-feature warnings-as-errors Clippy passed at 32af4d9;
the later transition changes only a server test import and embedded release
prose. Full npm gate: package 101, SDK 19, extension 413 passed/7 skipped,
dependency guards 7, web 74 files/772 tests PASS; check:web typecheck and
894-page build PASS, 0 lint errors/1 warning. Corrected release-note/credit
checks 24/24 and changelog sync PASS. Final combined hosted CI remains required
before main. No release or deployment claim.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-10-08 02:57:46 -07:00
1 parent a8cdccc1d6
commit 1c82c935c9
44 files changed
+1818 -53

No files matched your search

+56 -3
View File
@@ -9,12 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.10.2] - 2026-10-08
Codewhale v0.10.2 is a patch release. `/undo` takes back a whole request,
`/diff` works outside a git repository, Plan mode offers its hand-off again,
and three provider failure modes are reported for what they are.
Codewhale v0.10.2 adds a live Terminal work dock and improves recovery,
plugin approval and provider reliability. `/undo` takes back a whole request,
`/diff` works outside a Git repository, and contributor fixes improve token
accounting, localized replies and Windows launcher guidance.
### Added
- A Terminal work dock (`/workbar terminal`) shows the model’s live PTY sessions
in the current workspace, with ANSI-styled output, session selection, resize
and direct keyboard input. `Ctrl+N` starts a separate shell; `Alt+Down` and
`Alt+Up` switch sessions, and Esc returns input to the composer. Input remains
bound to the selected shell’s identity across resets. Available on macOS and
Linux; Windows names its current PTY limitation. The view shows a bounded
output tail rather than emulating full-screen terminal programs (Refs #6912).
- When a Plan turn completes with open To-do steps, the TUI asks "How do you
want to continue?". **Work (Ask)** and **Work (Auto-Review)** switch to Work
with that permission and send "Go ahead with the plan."; **Keep planning**,
@@ -49,8 +58,19 @@ and three provider failure modes are reported for what they are.
for `write_file`, `edit_file` and `apply_patch` in the posture where the
same edit runs without a card inside a repository.
- Clients that start `codewhale serve` can declare a telemetry surface with
`CODEWHALE_TELEMETRY_SURFACE`, including `vscode-extension`. This changes the
session label while preserving collection settings and opt-outs
([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
### Changed
- Goal mode has no step limit when `[goal].max_steps` is omitted or `0`.
Positive values still limit the goal, with values above `100000` clamped to
`100000` ([#6512](https://github.com/codewhale-hq/Codewhale/issues/6512)).
- MCP and provider browser sign-in allow 15 minutes for the callback instead
of five ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)).
- `/undo` takes back your last request in one step, in every access mode:
every file the request changed, plus the request and its reply. It no
longer asks for `/trust on`. Its report names the request and lists each
@@ -109,6 +129,34 @@ and three provider failure modes are reported for what they are.
### Fixed
- Concurrent starts and reconnects for one workspace converge on one Engine
owner. A receipt retired during a reconnect is observed again before
attaching; files with unsafe ownership, permissions or multiple links are
still refused.
- Local plugin updates retain a private recovery backup while replacing the
installed version. Plugin actions marked as requiring approval keep that
requirement in Full Access, and internal installation paths are excluded
from discovery.
- Concurrent Files API creates publish a workspace file exclusively. A
competing creator gets HTTP 409 and cannot overwrite the winning file.
- Cache usage accounts for cache-write tokens as well as cache hits when
deriving cache misses. Missing cache details remain unknown, and explicit
zero counts stay zero
([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)).
- `/profile` usage, switching, success and failure replies follow the UI
language across all 15 complete locale packs
([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)).
- On Windows npm installs, the model's environment identifies `node.exe` as
the launcher and warns that killing it by name also stops npm-launched
Codewhale sessions. It points to stopping servers by PID or port instead
([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)).
- Persistent terminal commands keep interactive stdin available while preserving
current-shell variables, directories and aliases. Human answers no longer
consume the command's completion marker. Oversized raw input batches are
refused before writing in canonical line-input mode, across the Terminal
dock, `terminal/send` and the Runtime API.
- A stream request that receives no response headers in time now adds that
the provider may still be loading the model, and names `codewhale config
set stream.open_timeout_secs 180` (up to 300). `codewhale doctor
@@ -166,6 +214,11 @@ and three provider failure modes are reported for what they are.
### Contributors
- **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)).
- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)).
- **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)).
- **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)).
- **[@BX166](https://github.com/BX166)** — reported three provider failure modes measured on AICraft's own traffic: a cold model that reads as a dead connection, a listed model that fails on every call, and a response cut at the output ceiling that reports `stop` ([#6889](https://github.com/codewhale-hq/Codewhale/issues/6889)).
## [0.10.1] - 2026-10-07
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "S’està iniciant un terminal nou…",
"TerminalDockControls": "{new} nou · {next} sessió següent · {detach} entrada",
"TerminalDockEmpty": "Cap terminal actiu. Inicia’n un amb {new}.",
"TerminalDockUnsupported": "Els terminals PTY són compatibles amb macOS i Linux.",
"TerminalDockLost": "El terminal seleccionat s’ha reiniciat o perdut. Selecciona una sessió.",
"TerminalDockDropped": "Sortida anterior omesa: {count} bytes.",
"TerminalDockError": "Terminal: {error}",
"TerminalDockPasteRefused": "No es pot enganxar: supera 64 KiB o conté caràcters de control no admesos.",
"TerminalDockExited": "ha sortit amb {code}",
"TerminalDockLive": "actiu",
"ExtensionPromptUnavailable": "Les instruccions de les extensions no estan disponibles; consulta /plugin show.",
"ProfileConstitutionUnavailableLocal": "No s'han pogut carregar les preferències del compte de Codewhale; aquest torn fa servir la configuració local. La clau del proveïdor continua funcionant; executa codewhale login per recuperar les preferències del compte.",
"ComposerOversizedSubmitHeld": "No s’ha enviat: el missatge supera els {limit} caràcters i no s’ha pogut desar en un fitxer de text enganxat ({error}). El text complet continua al camp d’entrada: escurça’l o habilita l’escriptura a .codewhale/pastes i torna a prémer Enter.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Neues Terminal wird gestartet…",
"TerminalDockControls": "{new} neu · {next} nächste Sitzung · {detach} Eingabefeld",
"TerminalDockEmpty": "Keine aktiven Terminals. Mit {new} eines starten.",
"TerminalDockUnsupported": "PTY-Terminals werden unter macOS und Linux unterstützt.",
"TerminalDockLost": "Das ausgewählte Terminal wurde zurückgesetzt oder ging verloren. Sitzung neu auswählen.",
"TerminalDockDropped": "Frühere Ausgabe ausgelassen: {count} Bytes.",
"TerminalDockError": "Terminal: {error}",
"TerminalDockPasteRefused": "Einfügen abgelehnt: über 64 KiB oder nicht unterstützte Steuerzeichen.",
"TerminalDockExited": "beendet: {code}",
"TerminalDockLive": "aktiv",
"ExtensionPromptUnavailable": "Anweisungen von Erweiterungen sind nicht verfügbar; prüfe /plugin show.",
"ProfileConstitutionUnavailableLocal": "Die Codewhale-Kontoeinstellungen konnten nicht geladen werden; dieser Durchlauf nutzt deine lokalen Einstellungen. Dein Anbieter-Schlüssel funktioniert weiter; führe codewhale login aus, um die Kontoeinstellungen wiederherzustellen.",
"ComposerOversizedSubmitHeld": "Nicht gesendet: Diese Nachricht enthält mehr als {limit} Zeichen und konnte nicht als Einfügedatei gespeichert werden ({error}). Der vollständige Text steht noch im Eingabefeld: Kürze ihn oder mache .codewhale/pastes beschreibbar und drücke dann erneut Enter.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Starting a fresh terminal…",
"TerminalDockControls": "{new} new · {next} next session · {detach} composer",
"TerminalDockEmpty": "No live terminals. Start one with {new}.",
"TerminalDockUnsupported": "PTY terminals are supported on macOS and Linux.",
"TerminalDockLost": "The selected terminal was reset or lost. Select a session explicitly.",
"TerminalDockDropped": "Earlier output omitted: {count} bytes.",
"TerminalDockError": "Terminal: {error}",
"TerminalDockPasteRefused": "Paste refused: over 64 KiB or unsupported control characters.",
"TerminalDockExited": "exited {code}",
"TerminalDockLive": "live",
"ExtensionPromptUnavailable": "Extension instructions are unavailable; inspect /plugin show.",
"ProfileConstitutionUnavailableLocal": "Codewhale account preferences could not be loaded, so this turn uses your local settings. Your provider key still works; run codewhale login to restore account preferences.",
"ComposerOversizedSubmitHeld": "Not sent: this message is over {limit} characters and could not be saved as a paste file ({error}). The full text is still in the composer: shorten it, or make .codewhale/pastes writable, then press Enter again.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Iniciando una terminal nueva…",
"TerminalDockControls": "{new} nuevo · {next} siguiente sesión · {detach} entrada",
"TerminalDockEmpty": "No hay terminales activos. Inicia uno con {new}.",
"TerminalDockUnsupported": "Las terminales PTY funcionan en macOS y Linux.",
"TerminalDockLost": "La terminal seleccionada se reinició o se perdió. Selecciona una sesión.",
"TerminalDockDropped": "Salida anterior omitida: {count} bytes.",
"TerminalDockError": "Terminal: {error}",
"TerminalDockPasteRefused": "No se pudo pegar: supera 64 KiB o contiene caracteres de control no admitidos.",
"TerminalDockExited": "finalizó: {code}",
"TerminalDockLive": "activo",
"ExtensionPromptUnavailable": "Las instrucciones de las extensiones no están disponibles; revisa /plugin show.",
"ProfileConstitutionUnavailableLocal": "No se pudieron cargar las preferencias de la cuenta de Codewhale; este turno usa tu configuración local. Tu clave del proveedor sigue funcionando; ejecuta codewhale login para restaurar las preferencias de la cuenta.",
"ComposerOversizedSubmitHeld": "No se envió: este mensaje supera los {limit} caracteres y no se pudo guardar en un archivo de texto pegado ({error}). El texto completo sigue en el campo de entrada: acórtalo o habilita la escritura en .codewhale/pastes y vuelve a presionar Enter.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Ouverture d’un nouveau terminal…",
"TerminalDockControls": "{new} nouveau · {next} session suivante · {detach} saisie",
"TerminalDockEmpty": "Aucun terminal actif. Utilisez {new} pour en ouvrir un.",
"TerminalDockUnsupported": "Les terminaux PTY sont pris en charge sur macOS et Linux.",
"TerminalDockLost": "Le terminal sélectionné a été réinitialisé ou perdu. Sélectionnez une session.",
"TerminalDockDropped": "Sortie antérieure omise : {count} octets.",
"TerminalDockError": "Terminal : {error}",
"TerminalDockPasteRefused": "Collage refusé : plus de 64 KiB ou caractères de contrôle non pris en charge.",
"TerminalDockExited": "terminé : {code}",
"TerminalDockLive": "actif",
"ExtensionPromptUnavailable": "Les instructions des extensions sont indisponibles ; consultez /plugin show.",
"ProfileConstitutionUnavailableLocal": "Les préférences du compte Codewhale n'ont pas pu être chargées ; ce tour utilise vos réglages locaux. Votre clé de fournisseur fonctionne toujours ; lancez codewhale login pour rétablir les préférences du compte.",
"ComposerOversizedSubmitHeld": "Non envoyé : ce message dépasse {limit} caractères et n’a pas pu être enregistré dans un fichier de texte collé ({error}). Le texte intégral reste dans la zone de saisie : raccourcissez-le ou autorisez l’écriture dans .codewhale/pastes, puis appuyez à nouveau sur Enter.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "नया टर्मिनल शुरू हो रहा है…",
"TerminalDockControls": "{new} नया · {next} अगला सत्र · {detach} इनपुट बॉक्स",
"TerminalDockEmpty": "कोई सक्रिय टर्मिनल नहीं। {new} से नया शुरू करें।",
"TerminalDockUnsupported": "PTY टर्मिनल macOS और Linux पर समर्थित हैं।",
"TerminalDockLost": "चुना गया टर्मिनल रीसेट हो गया या खो गया। सत्र दोबारा चुनें।",
"TerminalDockDropped": "पहले का आउटपुट छोड़ा गया: {count} बाइट।",
"TerminalDockError": "टर्मिनल: {error}",
"TerminalDockPasteRefused": "पेस्ट अस्वीकृत: 64 KiB से अधिक या असमर्थित नियंत्रण वर्ण।",
"TerminalDockExited": "समाप्त: {code}",
"TerminalDockLive": "सक्रिय",
"ExtensionPromptUnavailable": "एक्सटेंशन के निर्देश उपलब्ध नहीं हैं; /plugin show से जाँचें।",
"ProfileConstitutionUnavailableLocal": "Codewhale खाते की प्राथमिकताएँ लोड नहीं हो सकीं, इसलिए यह टर्न आपकी स्थानीय सेटिंग्स का उपयोग करता है। आपकी प्रदाता कुंजी काम करती रहेगी; खाते की प्राथमिकताएँ वापस लाने के लिए codewhale login चलाएँ।",
"ComposerOversizedSubmitHeld": "नहीं भेजा गया: इस संदेश में {limit} से अधिक वर्ण हैं और इसे पेस्ट फ़ाइल के रूप में सहेजा नहीं जा सका ({error})। पूरा पाठ अभी भी इनपुट बॉक्स में है: इसे छोटा करें या .codewhale/pastes में लिखने की अनुमति दें, फिर Enter दबाएँ।",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Memulai terminal baru…",
"TerminalDockControls": "{new} baru · {next} sesi berikutnya · {detach} kotak masukan",
"TerminalDockEmpty": "Tidak ada terminal aktif. Mulai dengan {new}.",
"TerminalDockUnsupported": "Terminal PTY didukung di macOS dan Linux.",
"TerminalDockLost": "Terminal yang dipilih telah direset atau hilang. Pilih sesi kembali.",
"TerminalDockDropped": "Keluaran sebelumnya dilewati: {count} byte.",
"TerminalDockError": "Terminal: {error}",
"TerminalDockPasteRefused": "Tempel ditolak: melebihi 64 KiB atau berisi karakter kontrol yang tidak didukung.",
"TerminalDockExited": "selesai: {code}",
"TerminalDockLive": "aktif",
"ExtensionPromptUnavailable": "Instruksi ekstensi tidak tersedia; periksa /plugin show.",
"ProfileConstitutionUnavailableLocal": "Preferensi akun Codewhale tidak dapat dimuat, jadi giliran ini memakai pengaturan lokal. Kunci penyedia tetap berfungsi; jalankan codewhale login untuk memulihkan preferensi akun.",
"ComposerOversizedSubmitHeld": "Belum terkirim: pesan ini melebihi {limit} karakter dan tidak dapat disimpan sebagai berkas teks tempelan ({error}). Teks lengkap masih ada di kolom input: persingkat teks atau izinkan penulisan ke .codewhale/pastes, lalu tekan Enter lagi.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "新しい端末を起動中…",
"TerminalDockControls": "{new} 新規 · {next} 次のセッション · {detach} 入力欄へ",
"TerminalDockEmpty": "実行中の端末はありません。{new} で開始できます。",
"TerminalDockUnsupported": "PTY 端末は macOS と Linux に対応しています。",
"TerminalDockLost": "選択した端末はリセットされたか失われました。セッションを選び直してください。",
"TerminalDockDropped": "以前の出力を省略:{count} バイト。",
"TerminalDockError": "端末:{error}",
"TerminalDockPasteRefused": "貼り付け不可:64 KiB を超えるか、未対応の制御文字を含みます。",
"TerminalDockExited": "終了 {code}",
"TerminalDockLive": "稼働中",
"ExtensionPromptUnavailable": "拡張機能の指示を利用できません。/plugin show で確認してください。",
"ProfileConstitutionUnavailableLocal": "Codewhale アカウントの設定を読み込めなかったため、このターンはローカル設定を使います。プロバイダーキーはそのまま使えます。アカウント設定を戻すには codewhale login を実行してください。",
"ComposerOversizedSubmitHeld": "未送信:メッセージが {limit} 文字を超えており、貼り付けファイルに保存できませんでした({error})。全文は入力欄に残っています。短くするか .codewhale/pastes を書き込み可能にしてから、もう一度 Enter を押してください。",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "새 터미널을 시작하는 중…",
"TerminalDockControls": "{new} 새 터미널 · {next} 다음 세션 · {detach} 입력창",
"TerminalDockEmpty": "실행 중인 터미널이 없습니다. {new} 키로 시작하세요.",
"TerminalDockUnsupported": "PTY 터미널은 macOS와 Linux에서 지원됩니다.",
"TerminalDockLost": "선택한 터미널이 초기화되었거나 사라졌습니다. 세션을 다시 선택하세요.",
"TerminalDockDropped": "이전 출력 생략: {count}바이트.",
"TerminalDockError": "터미널: {error}",
"TerminalDockPasteRefused": "붙여넣기 거부: 64 KiB 초과 또는 지원하지 않는 제어 문자 포함.",
"TerminalDockExited": "종료 {code}",
"TerminalDockLive": "실행 중",
"ExtensionPromptUnavailable": "확장 기능의 지침을 사용할 수 없습니다. /plugin show에서 확인하세요.",
"ProfileConstitutionUnavailableLocal": "Codewhale 계정 설정을 불러오지 못해 이번 턴은 로컬 설정을 사용합니다. 공급자 키는 계속 사용할 수 있으며, 계정 설정을 복원하려면 codewhale login을 실행하세요.",
"ComposerOversizedSubmitHeld": "전송하지 못했습니다. 메시지가 {limit}자를 초과하며 붙여넣기 파일로 저장할 수 없습니다({error}). 전체 텍스트는 입력란에 그대로 있습니다. 내용을 줄이거나 .codewhale/pastes에 쓰기 권한을 부여한 뒤 Enter를 다시 누르세요.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Iniciando um novo terminal…",
"TerminalDockControls": "{new} novo · {next} próxima sessão · {detach} entrada",
"TerminalDockEmpty": "Nenhum terminal ativo. Inicie um com {new}.",
"TerminalDockUnsupported": "Terminais PTY são compatíveis com macOS e Linux.",
"TerminalDockLost": "O terminal selecionado foi reiniciado ou perdido. Selecione uma sessão.",
"TerminalDockDropped": "Saída anterior omitida: {count} bytes.",
"TerminalDockError": "Terminal: {error}",
"TerminalDockPasteRefused": "Colagem recusada: mais de 64 KiB ou caracteres de controle incompatíveis.",
"TerminalDockExited": "encerrado: {code}",
"TerminalDockLive": "ativo",
"ExtensionPromptUnavailable": "As instruções das extensões não estão disponíveis; confira /plugin show.",
"ProfileConstitutionUnavailableLocal": "Não foi possível carregar as preferências da conta Codewhale; este turno usa suas configurações locais. Sua chave do provedor continua funcionando; execute codewhale login para restaurar as preferências da conta.",
"ComposerOversizedSubmitHeld": "Não enviado: esta mensagem excede {limit} caracteres e não pôde ser salva em um arquivo de texto colado ({error}). O texto completo continua no campo de entrada: reduza-o ou permita a gravação em .codewhale/pastes e pressione Enter novamente.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Запуск нового терминала…",
"TerminalDockControls": "{new} новый · {next} следующий сеанс · {detach} поле ввода",
"TerminalDockEmpty": "Нет активных терминалов. Создайте один с помощью {new}.",
"TerminalDockUnsupported": "PTY-терминалы поддерживаются в macOS и Linux.",
"TerminalDockLost": "Выбранный терминал сброшен или потерян. Выберите сеанс заново.",
"TerminalDockDropped": "Предыдущий вывод пропущен: {count} байт.",
"TerminalDockError": "Терминал: {error}",
"TerminalDockPasteRefused": "Вставка отклонена: более 64 KiB или неподдерживаемые управляющие символы.",
"TerminalDockExited": "завершён: {code}",
"TerminalDockLive": "активен",
"ExtensionPromptUnavailable": "Инструкции расширений недоступны; проверьте /plugin show.",
"ProfileConstitutionUnavailableLocal": "Не удалось загрузить настройки аккаунта Codewhale, поэтому этот ход использует локальные настройки. Ключ провайдера продолжает работать; выполните codewhale login, чтобы вернуть настройки аккаунта.",
"ComposerOversizedSubmitHeld": "Не отправлено: сообщение длиннее {limit} символов, и его не удалось сохранить в файл вставленного текста ({error}). Полный текст остался в поле ввода: сократите его или разрешите запись в .codewhale/pastes, затем снова нажмите Enter.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Запуск нового термінала…",
"TerminalDockControls": "{new} новий · {next} наступний сеанс · {detach} поле введення",
"TerminalDockEmpty": "Немає активних терміналів. Створіть один за допомогою {new}.",
"TerminalDockUnsupported": "PTY-термінали підтримуються в macOS і Linux.",
"TerminalDockLost": "Вибраний термінал скинуто або втрачено. Виберіть сеанс знову.",
"TerminalDockDropped": "Попередній вивід пропущено: {count} байтів.",
"TerminalDockError": "Термінал: {error}",
"TerminalDockPasteRefused": "Вставлення відхилено: понад 64 KiB або непідтримувані керівні символи.",
"TerminalDockExited": "завершено: {code}",
"TerminalDockLive": "активний",
"ExtensionPromptUnavailable": "Інструкції розширень недоступні; перевірте /plugin show.",
"ProfileConstitutionUnavailableLocal": "Не вдалося завантажити налаштування облікового запису Codewhale, тому цей хід використовує локальні налаштування. Ключ провайдера й далі працює; виконайте codewhale login, щоб повернути налаштування облікового запису.",
"ComposerOversizedSubmitHeld": "Не надіслано: повідомлення перевищує {limit} символів, і його не вдалося зберегти у файл вставленого тексту ({error}). Повний текст залишається в полі введення: скоротіть його або дозвольте запис у .codewhale/pastes, а потім знову натисніть Enter.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "Đang mở thiết bị đầu cuối mới…",
"TerminalDockControls": "{new} mới · {next} phiên tiếp theo · {detach} ô nhập",
"TerminalDockEmpty": "Không có thiết bị đầu cuối đang chạy. Mở bằng {new}.",
"TerminalDockUnsupported": "Thiết bị đầu cuối PTY được hỗ trợ trên macOS và Linux.",
"TerminalDockLost": "Thiết bị đầu cuối đã chọn đã bị đặt lại hoặc mất. Hãy chọn lại phiên.",
"TerminalDockDropped": "Đã bỏ qua đầu ra trước đó: {count} byte.",
"TerminalDockError": "Thiết bị đầu cuối: {error}",
"TerminalDockPasteRefused": "Từ chối dán: vượt 64 KiB hoặc chứa ký tự điều khiển không được hỗ trợ.",
"TerminalDockExited": "đã thoát: {code}",
"TerminalDockLive": "đang chạy",
"ExtensionPromptUnavailable": "Không thể tải hướng dẫn của tiện ích mở rộng; kiểm tra /plugin show.",
"ProfileConstitutionUnavailableLocal": "Không thể tải tùy chọn tài khoản Codewhale nên lượt này dùng cài đặt cục bộ. Khóa nhà cung cấp của bạn vẫn hoạt động; chạy codewhale login để khôi phục tùy chọn tài khoản.",
"ComposerOversizedSubmitHeld": "Chưa gửi: tin nhắn vượt quá {limit} ký tự và không thể lưu thành tệp văn bản đã dán ({error}). Toàn bộ nội dung vẫn còn trong ô nhập: hãy rút ngắn hoặc cho phép ghi vào .codewhale/pastes, rồi nhấn Enter lần nữa.",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "正在启动新终端…",
"TerminalDockControls": "{new} 新建 · {next} 下一会话 · {detach} 返回输入框",
"TerminalDockEmpty": "没有运行中的终端。按 {new} 新建。",
"TerminalDockUnsupported": "PTY 终端支持 macOS 和 Linux。",
"TerminalDockLost": "所选终端已重置或丢失,请重新选择会话。",
"TerminalDockDropped": "已省略较早输出:{count} 字节。",
"TerminalDockError": "终端:{error}",
"TerminalDockPasteRefused": "无法粘贴:超过 64 KiB 或包含不支持的控制字符。",
"TerminalDockExited": "已退出 {code}",
"TerminalDockLive": "运行中",
"ExtensionPromptUnavailable": "扩展指令不可用;请通过 /plugin show 查看。",
"ProfileConstitutionUnavailableLocal": "无法加载 Codewhale 账号偏好,本轮改用本地设置。你的服务商密钥仍可正常使用;运行 codewhale login 可恢复账号偏好。",
"ComposerOversizedSubmitHeld": "未发送:消息超过 {limit} 个字符,无法保存为粘贴文本文件({error})。完整文本仍保留在输入框中:请缩短内容,或允许写入 .codewhale/pastes,然后再次按 Enter。",
+10
View File
@@ -1,4 +1,14 @@
{
"TerminalDockStarting": "正在啟動新終端…",
"TerminalDockControls": "{new} 新增 · {next} 下一個工作階段 · {detach} 返回輸入框",
"TerminalDockEmpty": "沒有執行中的終端。按 {new} 新增。",
"TerminalDockUnsupported": "PTY 終端支援 macOS 和 Linux。",
"TerminalDockLost": "所選終端已重設或遺失,請重新選擇工作階段。",
"TerminalDockDropped": "已省略較早輸出:{count} 位元組。",
"TerminalDockError": "終端:{error}",
"TerminalDockPasteRefused": "無法貼上:超過 64 KiB 或包含不支援的控制字元。",
"TerminalDockExited": "已結束 {code}",
"TerminalDockLive": "執行中",
"ExtensionPromptUnavailable": "擴充功能指令無法使用;請透過 /plugin show 查看。",
"ProfileConstitutionUnavailableLocal": "無法載入 Codewhale 帳號偏好,本輪改用本機設定。你的服務商金鑰仍可正常使用;執行 codewhale login 可恢復帳號偏好。",
"ComposerOversizedSubmitHeld": "未傳送:訊息超過 {limit} 個字元,無法儲存為貼上文字檔案({error})。完整文字仍保留在輸入框中:請縮短內容,或允許寫入 .codewhale/pastes,然後再次按 Enter。",
+20
View File
@@ -2250,6 +2250,16 @@ pub enum MessageId {
SidebarDestructiveArmed,
WorkSurfaceTodoProgress,
WorkSurfaceStopConfirmHint,
TerminalDockStarting,
TerminalDockControls,
TerminalDockEmpty,
TerminalDockUnsupported,
TerminalDockLost,
TerminalDockDropped,
TerminalDockError,
TerminalDockPasteRefused,
TerminalDockExited,
TerminalDockLive,
CoordinationWorkTitle,
CoordinationSummaryDecisions,
CoordinationSummaryContentions,
@@ -4760,6 +4770,16 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::SidebarDestructiveArmed,
MessageId::WorkSurfaceTodoProgress,
MessageId::WorkSurfaceStopConfirmHint,
MessageId::TerminalDockStarting,
MessageId::TerminalDockControls,
MessageId::TerminalDockEmpty,
MessageId::TerminalDockUnsupported,
MessageId::TerminalDockLost,
MessageId::TerminalDockDropped,
MessageId::TerminalDockError,
MessageId::TerminalDockPasteRefused,
MessageId::TerminalDockExited,
MessageId::TerminalDockLive,
MessageId::CoordinationWorkTitle,
MessageId::CoordinationSummaryDecisions,
MessageId::CoordinationSummaryContentions,
+56 -3
View File
@@ -9,12 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.10.2] - 2026-10-08
Codewhale v0.10.2 is a patch release. `/undo` takes back a whole request,
`/diff` works outside a git repository, Plan mode offers its hand-off again,
and three provider failure modes are reported for what they are.
Codewhale v0.10.2 adds a live Terminal work dock and improves recovery,
plugin approval and provider reliability. `/undo` takes back a whole request,
`/diff` works outside a Git repository, and contributor fixes improve token
accounting, localized replies and Windows launcher guidance.
### Added
- A Terminal work dock (`/workbar terminal`) shows the model’s live PTY sessions
in the current workspace, with ANSI-styled output, session selection, resize
and direct keyboard input. `Ctrl+N` starts a separate shell; `Alt+Down` and
`Alt+Up` switch sessions, and Esc returns input to the composer. Input remains
bound to the selected shell’s identity across resets. Available on macOS and
Linux; Windows names its current PTY limitation. The view shows a bounded
output tail rather than emulating full-screen terminal programs (Refs #6912).
- When a Plan turn completes with open To-do steps, the TUI asks "How do you
want to continue?". **Work (Ask)** and **Work (Auto-Review)** switch to Work
with that permission and send "Go ahead with the plan."; **Keep planning**,
@@ -49,8 +58,19 @@ and three provider failure modes are reported for what they are.
for `write_file`, `edit_file` and `apply_patch` in the posture where the
same edit runs without a card inside a repository.
- Clients that start `codewhale serve` can declare a telemetry surface with
`CODEWHALE_TELEMETRY_SURFACE`, including `vscode-extension`. This changes the
session label while preserving collection settings and opt-outs
([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
### Changed
- Goal mode has no step limit when `[goal].max_steps` is omitted or `0`.
Positive values still limit the goal, with values above `100000` clamped to
`100000` ([#6512](https://github.com/codewhale-hq/Codewhale/issues/6512)).
- MCP and provider browser sign-in allow 15 minutes for the callback instead
of five ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)).
- `/undo` takes back your last request in one step, in every access mode:
every file the request changed, plus the request and its reply. It no
longer asks for `/trust on`. Its report names the request and lists each
@@ -109,6 +129,34 @@ and three provider failure modes are reported for what they are.
### Fixed
- Concurrent starts and reconnects for one workspace converge on one Engine
owner. A receipt retired during a reconnect is observed again before
attaching; files with unsafe ownership, permissions or multiple links are
still refused.
- Local plugin updates retain a private recovery backup while replacing the
installed version. Plugin actions marked as requiring approval keep that
requirement in Full Access, and internal installation paths are excluded
from discovery.
- Concurrent Files API creates publish a workspace file exclusively. A
competing creator gets HTTP 409 and cannot overwrite the winning file.
- Cache usage accounts for cache-write tokens as well as cache hits when
deriving cache misses. Missing cache details remain unknown, and explicit
zero counts stay zero
([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)).
- `/profile` usage, switching, success and failure replies follow the UI
language across all 15 complete locale packs
([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)).
- On Windows npm installs, the model's environment identifies `node.exe` as
the launcher and warns that killing it by name also stops npm-launched
Codewhale sessions. It points to stopping servers by PID or port instead
([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)).
- Persistent terminal commands keep interactive stdin available while preserving
current-shell variables, directories and aliases. Human answers no longer
consume the command's completion marker. Oversized raw input batches are
refused before writing in canonical line-input mode, across the Terminal
dock, `terminal/send` and the Runtime API.
- A stream request that receives no response headers in time now adds that
the provider may still be loading the model, and names `codewhale config
set stream.open_timeout_secs 180` (up to 300). `codewhale doctor
@@ -166,6 +214,11 @@ and three provider failure modes are reported for what they are.
### Contributors
- **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)).
- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)).
- **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)).
- **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)).
- **[@BX166](https://github.com/BX166)** — reported three provider failure modes measured on AICraft's own traffic: a cold model that reads as a dead connection, a listed model that fails on every call, and a response cut at the output ceiling that reports `stop` ([#6889](https://github.com/codewhale-hq/Codewhale/issues/6889)).
## [0.10.1] - 2026-10-07
@@ -626,7 +626,7 @@ pub fn screen(app: &mut App, target: ScreenMode, arg: Option<&str>) -> CommandRe
/// claim about a surface that cannot render.
pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
const USAGE: &str =
"Usage: /workbar [bottom|top|left|right|off|tasks|agents|context|pinned] [--save]";
"Usage: /workbar [bottom|top|left|right|off|tasks|agents|terminal|context|pinned] [--save]";
let raw = arg.map(str::trim).unwrap_or("");
let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
@@ -663,6 +663,7 @@ pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
Some(crate::tui::work_surface::RailPanel::Background)
}
"files" | "changes" => Some(crate::tui::work_surface::RailPanel::Files),
"terminal" | "terminals" => Some(crate::tui::work_surface::RailPanel::Terminal),
"notepad" | "notes" => Some(crate::tui::work_surface::RailPanel::Notepad),
"context" | "session" => Some(crate::tui::work_surface::RailPanel::Context),
"git" | "branch" => Some(crate::tui::work_surface::RailPanel::Git),
+7 -1
View File
@@ -293,7 +293,13 @@ pub(super) async fn terminal_input(
)?;
let written = bytes.len();
with_session(session, move |session| {
terminal_session::write_bytes(session, &bytes).map_err(ApiError::internal)
terminal_session::write_raw_input(session, &bytes).map_err(|error| {
if error.kind() == std::io::ErrorKind::InvalidInput {
ApiError::bad_request(error.to_string())
} else {
ApiError::internal(error.to_string())
}
})
})
.await?;
Ok(Json(TerminalWriteResponse { name, written }))
+8 -6
View File
@@ -650,6 +650,7 @@ fn normalize_rail_panel(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"agents" => "agents",
"background" => "background",
"terminal" => "terminal",
"files" => "files",
"notepad" => "notepad",
"context" => "context",
@@ -1549,6 +1550,7 @@ impl Settings {
"tasks"
| "agents"
| "background"
| "terminal"
| "files"
| "notepad"
| "context"
@@ -1558,7 +1560,7 @@ impl Settings {
| "pinned"
) {
anyhow::bail!(
"Failed to update setting: invalid workbar panel '{value}'. Expected: tasks, agents, background, files, notepad, context, git, or price."
"Failed to update setting: invalid workbar panel '{value}'. Expected: tasks, agents, background, terminal, files, notepad, context, git, or price."
);
}
self.rail_panel = normalize_rail_panel(&normalized).to_string();
@@ -3242,11 +3244,12 @@ mod tests {
assert_eq!(settings.rail_panel, "tasks");
// Every panel the dock cycles through must survive `set` and a
// settings.toml round trip — the dock persists all eight.
// settings.toml round trip — including an empty Terminal view.
for panel in [
"tasks",
"agents",
"background",
"terminal",
"files",
"notepad",
"context",
@@ -3269,10 +3272,9 @@ mod tests {
let err = settings
.set("rail_panel", "auto")
.expect_err("auto-collapse was dropped with the legacy sidebar");
assert!(
err.to_string()
.contains("tasks, agents, background, files, notepad, context, git, or price")
);
assert!(err.to_string().contains(
"tasks, agents, background, terminal, files, notepad, context, git, or price"
));
assert_eq!(settings.rail_panel, "tasks");
}
+609 -24
View File
@@ -8,7 +8,7 @@
#[cfg(unix)]
use std::collections::{HashMap, VecDeque};
#[cfg(unix)]
use std::io::Write;
use std::io::{self, Write};
#[cfg(unix)]
use std::path::{Path, PathBuf};
#[cfg(unix)]
@@ -65,6 +65,8 @@ pub(crate) struct TerminalSession {
/// True when the live PTY was started through a real sandbox backend.
/// A later narrowed posture must not reuse an unsandboxed shell.
sandbox_confined: bool,
/// A running process cannot adopt changed roots or network restrictions.
sandbox_policy: crate::sandbox::SandboxPolicy,
}
#[cfg(unix)]
@@ -98,7 +100,7 @@ struct EnvironmentSummary {
#[cfg(unix)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum DurableTerminalState {
pub(crate) enum DurableTerminalState {
Running,
Idle,
Canceled,
@@ -117,6 +119,7 @@ struct CommandState {
struct OutputBuffer {
bytes: VecDeque<u8>,
total: u64,
last_activity: Option<Instant>,
}
/// One absolute-offset slice of a session's output.
@@ -149,6 +152,7 @@ pub(crate) struct OutputChunk {
#[cfg(unix)]
impl OutputBuffer {
fn append(&mut self, data: &[u8]) {
self.last_activity = Some(Instant::now());
self.total = self.total.saturating_add(data.len() as u64);
self.bytes.extend(data);
while self.bytes.len() > BUFFER_LIMIT {
@@ -343,7 +347,7 @@ fn create_session(
})
.map_err(|e| format!("failed to open PTY: {e}"))?;
let (prepared, sandbox_confined) = prepare_pty_shell(&shell, &workspace, policy)?;
let (prepared, sandbox_confined) = prepare_pty_shell(&shell, &workspace, policy.clone())?;
let mut command = portable_pty::CommandBuilder::new(&prepared.command[0]);
for arg in &prepared.command[1..] {
command.arg(arg);
@@ -419,6 +423,7 @@ fn create_session(
durable,
durable_path,
sandbox_confined,
sandbox_policy: policy,
})))
}
@@ -433,14 +438,12 @@ pub(crate) fn get_or_create(
.lock()
.map_err(|_| "terminal session registry lock poisoned".to_string())?;
if let Some(session) = registry.get(&key) {
let confined = session
let session_guard = session
.lock()
.ok()
.map(|guard| guard.sandbox_confined)
.unwrap_or(false);
if policy.should_sandbox() && !confined {
.map_err(|_| "terminal session lock poisoned".to_string())?;
if !can_reuse_under_policy(&session_guard, &policy) {
return Err(
"existing PTY session was started without a sandbox; start a new session name under the current posture or use Full Access"
"existing PTY session does not enforce the current sandbox policy; start a new session name under the current posture or use Full Access"
.to_string(),
);
}
@@ -451,6 +454,17 @@ pub(crate) fn get_or_create(
Ok(session)
}
/// Restricted reuse requires the policy that actually created the process.
/// Exact comparison is deliberately conservative: widening a restricted policy
/// may require a new name too, but never weakens a running process's boundary.
#[cfg(unix)]
fn can_reuse_under_policy(
session: &TerminalSession,
policy: &crate::sandbox::SandboxPolicy,
) -> bool {
!policy.should_sandbox() || (session.sandbox_confined && session.sandbox_policy == *policy)
}
#[cfg(unix)]
fn find(name: &str, workspace: &Path) -> Result<SharedSession, String> {
let live = sessions()
@@ -479,15 +493,33 @@ fn find(name: &str, workspace: &Path) -> Result<SharedSession, String> {
}
#[cfg(unix)]
pub(crate) fn write_bytes(session: &TerminalSession, bytes: &[u8]) -> Result<(), String> {
fn write_bytes(session: &TerminalSession, bytes: &[u8]) -> io::Result<()> {
let mut writer = session
.writer
.lock()
.map_err(|_| "terminal PTY writer lock poisoned".to_string())?;
writer
.write_all(bytes)
.map_err(|e| format!("PTY write failed: {e}"))?;
writer.flush().map_err(|e| format!("PTY flush failed: {e}"))
.map_err(|_| io::Error::other("terminal PTY writer lock poisoned"))?;
writer.write_all(bytes)?;
writer.flush()
}
/// Raw input must not silently overflow the canonical line buffer. This
/// refuses the demonstrated oversized batch, not cumulative line editing
/// or a concurrent terminal-mode change. Internal shell source and completion
/// sentinels use the private writer instead.
#[cfg(unix)]
pub(crate) fn write_raw_input(session: &TerminalSession, bytes: &[u8]) -> io::Result<()> {
if bytes.len() >= 1024
&& session
.master
.get_termios()
.is_none_or(|mode| mode.local_flags.bits() & libc::ICANON != 0)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"terminal input batch is too large for canonical or unknown input mode; no input was sent. Send fewer than 1024 bytes, or use an application in raw mode.",
));
}
write_bytes(session, bytes)
}
#[cfg(unix)]
@@ -584,6 +616,139 @@ pub(crate) fn lookup(name: &str, workspace: &Path) -> Option<SharedSession> {
.and_then(|registry| registry.get(&key).map(Arc::clone))
}
/// A dock projection of the existing registry, never another session owner.
#[cfg(unix)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TerminalSessionInfo {
pub id: String,
pub name: String,
pub state: DurableTerminalState,
pub exit_code: Option<u32>,
pub updated_at: String,
pub last_activity: Option<Instant>,
}
/// Called on the dock's blocking worker. Reads never consume tool output.
/// A selected identity that disappeared is not replaced by a reused name.
#[cfg(unix)]
pub(crate) fn inspect_workspace(
workspace: &Path,
selected: Option<&str>,
) -> Result<(Vec<TerminalSessionInfo>, Option<OutputChunk>), String> {
let workspace = session_key("", workspace).workspace;
let live = sessions()
.lock()
.map_err(|_| "terminal session registry lock poisoned".to_string())?
.iter()
.filter(|(key, _)| key.workspace == workspace)
.map(|(_, session)| Arc::clone(session))
.collect::<Vec<_>>();
let mut entries = Vec::with_capacity(live.len());
for handle in live {
let mut session = handle
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
let exit_code = session_exit_status(&mut session)?.map(|status| status.exit_code());
let state = match completion(&session) {
Some((0, _)) => DurableTerminalState::Idle,
Some((130, _)) => DurableTerminalState::Canceled,
Some(_) => DurableTerminalState::Failed,
None => session.durable.state,
};
entries.push((
TerminalSessionInfo {
id: session.durable.session_id.clone(),
name: session.durable.name.clone(),
state,
exit_code,
updated_at: session.durable.updated_at.clone(),
last_activity: session
.output
.lock()
.map_err(|_| "terminal output lock poisoned".to_string())?
.last_activity,
},
handle.clone(),
));
}
entries.sort_by(|(a, _), (b, _)| {
b.last_activity
.cmp(&a.last_activity)
.then(b.updated_at.cmp(&a.updated_at))
.then(a.name.cmp(&b.name))
});
let selected = match selected {
Some(id) => entries.iter().find(|(info, _)| info.id == id),
None => entries.first(),
};
let output = selected
.map(|(_, handle)| -> Result<OutputChunk, String> {
let session = handle
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
let output = session
.output
.lock()
.map_err(|_| "terminal output lock poisoned".to_string())?;
Ok(output.read_since(output.total.saturating_sub(READ_LIMIT as u64), READ_LIMIT))
})
.transpose()?;
Ok((entries.into_iter().map(|(info, _)| info).collect(), output))
}
/// Human input is bound to the displayed incarnation, including across reset.
/// It follows the same sandbox-reuse guard as the model's terminal/run path.
#[cfg(unix)]
pub(crate) fn write_dock_input(
workspace: &Path,
name: &str,
id: &str,
bytes: &[u8],
policy: &crate::sandbox::SandboxPolicy,
) -> Result<(), String> {
let handle = lookup(name, workspace).ok_or("selected terminal is gone or was reset")?;
let mut session = handle
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
if session.durable.session_id != id {
return Err(
"selected terminal is gone or was reset; select its replacement explicitly".into(),
);
}
if !can_reuse_under_policy(&session, policy) {
return Err(
"selected terminal does not enforce the current sandbox policy; start a fresh terminal"
.into(),
);
}
if session_exit_status(&mut session)?.is_some() {
return Err("selected terminal has exited; start a fresh terminal".into());
}
if bytes == [3] && session.command.is_some() && completion(&session).is_none() {
// SIGINT can discard the grouped command's completion suffix. Reuse
// the confirmed interrupt path so model waiters and later runs see
// the persistent shell become ready again.
drop(session);
return cancel_shared_session(&handle)
.map(|_| ())
.map_err(|error| error.to_string());
}
write_raw_input(&session, bytes).map_err(|error| error.to_string())
}
#[cfg(unix)]
pub(crate) fn resize_dock_session(
session: &TerminalSession,
id: &str,
rows: u16,
cols: u16,
) -> Result<(), String> {
if session.durable.session_id != id {
return Err("selected terminal is gone or was reset".into());
}
resize_session(session, rows, cols)
}
#[cfg(unix)]
fn prune_output(input: &str) -> String {
if input.len() <= OUTPUT_LIMIT {
@@ -631,13 +796,16 @@ fn start_command(session: &mut TerminalSession, command: &str) -> Result<(), Str
let marker = format!("__CODEWHALE_TERM_{}__", Uuid::new_v4().simple());
// The command must run in the CURRENT shell — a subshell would discard
// exactly the state (cd, exports, functions, activated envs) this tool
// exists to preserve (EXEC-001). The sentinel line is typed after the
// command; the tty line discipline holds it until the foreground command
// finishes reading input.
// exists to preserve (EXEC-001). Parse the source and sentinel together,
// but read source lines from a separate fd: interactive commands keep the
// PTY as stdin, and aliases defined on earlier lines expand in every shell.
// The random quoted delimiter prevents source expansion by the outer shell.
let delimiter = format!("{marker}_SOURCE");
let wrapped = format!(
"{command}\n__cw_status=$?; printf '\\n{marker}:%s:%s\\n' \"$__cw_status\" \"$PWD\"\n"
". /dev/fd/9 9<<'{delimiter}'; __cw_status=$?; printf '\\n{marker}:%s:%s\\n' \"$__cw_status\" \"$PWD\"\n{command}\n{delimiter}\n"
);
write_bytes(session, wrapped.as_bytes())?;
write_bytes(session, wrapped.as_bytes())
.map_err(|error| format!("PTY write failed: {error}"))?;
session.command = Some(CommandState { marker });
session.durable.state = DurableTerminalState::Running;
session.durable.updated_at = chrono::Utc::now().to_rfc3339();
@@ -653,7 +821,7 @@ fn write_completion_sentinel(
) -> Result<(), String> {
let sentinel =
format!("__cw_status={status}; printf '\\n{marker}:%s:%s\\n' \"$__cw_status\" \"$PWD\"\n");
write_bytes(session, sentinel.as_bytes())
write_bytes(session, sentinel.as_bytes()).map_err(|error| format!("PTY write failed: {error}"))
}
#[cfg(unix)]
@@ -716,7 +884,8 @@ fn cancel_shared_session(session: &SharedSession) -> Result<(i32, String), ToolE
"terminal session has no running foreground command",
));
}
write_bytes(&session, &[3]).map_err(ToolError::execution_failed)?;
write_bytes(&session, &[3])
.map_err(|error| ToolError::execution_failed(format!("PTY write failed: {error}")))?;
session
.command
.as_ref()
@@ -932,11 +1101,17 @@ impl ToolSpec for TerminalSendTool {
let session = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
let wait = Duration::from_millis(optional_u64(&input, "wait_ms", 250)?.min(60_000));
return tokio::task::spawn_blocking(move || {
{
let session = session.lock().map_err(|_| {
ToolError::execution_failed("terminal session lock poisoned")
})?;
write_raw_input(&session, &text)
.map_err(|error| ToolError::execution_failed(error.to_string()))?;
}
std::thread::sleep(wait);
let mut session = session
.lock()
.map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
write_bytes(&session, &text).map_err(ToolError::execution_failed)?;
std::thread::sleep(wait);
let done = completion(&session);
Ok(session_result(&mut session, done, false))
})
@@ -1081,6 +1256,53 @@ impl ToolSpec for TerminalResetTool {
mod tests {
use super::*;
/// Dock tests own a workspace so parallel tests cannot share their registry
/// entries or durable summaries. Kill and reap every owned shell on drop.
struct DockWorkspace(tempfile::TempDir);
impl DockWorkspace {
fn new() -> Self {
Self(tempfile::tempdir().unwrap())
}
fn path(&self) -> &Path {
self.0.path()
}
fn session(&self, name: &str) -> SharedSession {
get_or_create(
name,
self.path(),
crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap()
}
}
impl Drop for DockWorkspace {
fn drop(&mut self) {
let workspace = session_key("", self.path()).workspace;
let mut owned = Vec::new();
if let Ok(mut registry) = sessions().lock() {
registry.retain(|key, session| {
if key.workspace == workspace {
owned.push(Arc::clone(session));
false
} else {
true
}
});
}
for session in owned {
if let Ok(mut session) = session.lock()
&& session.child.kill().is_ok()
{
let _ = session.child.wait();
}
}
}
}
fn fresh(name: &str) -> SharedSession {
let session = get_or_create(
name,
@@ -1352,6 +1574,369 @@ mod tests {
assert!(find("shared-name", Path::new("/tmp")).is_err());
}
#[test]
fn dock_snapshot_scopes_identities_and_leaves_the_model_cursor_untouched() {
let one = DockWorkspace::new();
let two = DockWorkspace::new();
let first = one.session("shared-name");
let second = two.session("shared-name");
let first_id = first.lock().unwrap().durable.session_id.clone();
let second_id = second.lock().unwrap().durable.session_id.clone();
// Stop the output producer before seeding a deterministic retained tail.
// This verifies the dock projection independently of shell throughput.
{
let mut session = first.lock().unwrap();
session.child.kill().unwrap();
session.child.wait().unwrap();
session.read_cursor = 23;
// The detached PTY reader may still drain its final bytes; give
// this projection fixture an independent buffer after reaping.
session.output = Arc::new(Mutex::new(OutputBuffer::default()));
let mut output = session.output.lock().unwrap();
output.append(&vec![b'x'; BUFFER_LIMIT + 41]);
output.append(b"dock-tail-proof");
}
let (entries, output) = inspect_workspace(one.path(), Some(&first_id)).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, first_id);
assert!(entries[0].last_activity.is_some());
let output = output.unwrap();
assert_eq!(output.bytes.len(), READ_LIMIT);
assert_eq!(output.offset, output.total - READ_LIMIT as u64);
assert!(output.bytes.ends_with(b"dock-tail-proof"));
assert!(output.dropped > 0);
assert_eq!(first.lock().unwrap().read_cursor, 23);
// A foreign UUID never chooses a same-named local shell or falls back.
let (entries, output) = inspect_workspace(one.path(), Some(&second_id)).unwrap();
assert_eq!(entries.len(), 1);
assert!(output.is_none());
let error = write_dock_input(
two.path(),
"shared-name",
&first_id,
b"printf wrong-workspace\n",
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap_err();
assert!(error.contains("reset"), "{error}");
}
#[test]
fn dock_never_attaches_to_a_reset_name_without_selecting_its_new_identity() {
let workspace = DockWorkspace::new();
let old = workspace.session("replaceable");
let old_id = old.lock().unwrap().durable.session_id.clone();
{
let mut old = old.lock().unwrap();
old.child.kill().unwrap();
old.child.wait().unwrap();
}
let replacement = create_session(
"replaceable",
workspace.path(),
crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap();
let new_id = replacement.lock().unwrap().durable.session_id.clone();
sessions().lock().unwrap().insert(
session_key("replaceable", workspace.path()),
Arc::clone(&replacement),
);
let (entries, output) = inspect_workspace(workspace.path(), Some(&old_id)).unwrap();
assert_eq!(entries[0].id, new_id);
assert!(output.is_none());
let error = write_dock_input(
workspace.path(),
"replaceable",
&old_id,
b"touch stale-input-must-not-run\n",
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap_err();
assert!(
error.contains("select its replacement explicitly"),
"{error}"
);
assert!(resize_dock_session(&replacement.lock().unwrap(), &old_id, 40, 80).is_err());
assert!(!workspace.path().join("stale-input-must-not-run").exists());
// Refusal preserves the replacement and its environment instead of
// silently resetting it to recover from the stale selection.
assert_eq!(
lookup("replaceable", workspace.path())
.unwrap()
.lock()
.unwrap()
.durable
.session_id,
new_id
);
}
#[test]
fn dock_and_model_reuse_refuse_changed_restricted_policies() {
use crate::sandbox::SandboxPolicy;
let workspace = DockWorkspace::new();
let shell = workspace.session("posture");
let id = shell.lock().unwrap().durable.session_id.clone();
let narrowed = SandboxPolicy::ReadOnly;
assert!(get_or_create("posture", workspace.path(), narrowed.clone()).is_err());
assert!(write_dock_input(workspace.path(), "posture", &id, b"x", &narrowed).is_err());
// The process creation boundary is tested separately against real
// sandbox backends. Seed its recorded policy here so this regression
// stays portable even on hosts without seatbelt/bwrap.
let original = SandboxPolicy::workspace_with_roots(
vec![workspace.path().join("extra-write-root")],
true,
);
{
let mut shell = shell.lock().unwrap();
shell.sandbox_confined = true;
shell.sandbox_policy = original.clone();
}
let changed = [
SandboxPolicy::ReadOnly,
SandboxPolicy::workspace_with_roots(vec![], true),
SandboxPolicy::workspace_with_roots(
vec![workspace.path().join("extra-write-root")],
false,
),
];
for policy in changed {
assert!(get_or_create("posture", workspace.path(), policy.clone()).is_err());
assert!(write_dock_input(workspace.path(), "posture", &id, b"x", &policy).is_err());
}
assert!(Arc::ptr_eq(
&get_or_create("posture", workspace.path(), original).unwrap(),
&shell
));
assert_eq!(shell.lock().unwrap().durable.session_id, id);
}
#[test]
fn dock_input_refuses_an_exited_shell() {
let workspace = DockWorkspace::new();
let shell = workspace.session("exited");
let id = {
let mut shell = shell.lock().unwrap();
let id = shell.durable.session_id.clone();
shell.child.kill().unwrap();
shell.child.wait().unwrap();
id
};
let error = write_dock_input(
workspace.path(),
"exited",
&id,
b"must-not-run\n",
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap_err();
assert!(error.contains("has exited"), "{error}");
}
#[test]
fn raw_input_refuses_oversized_canonical_batches_without_touching_pending_read() {
let workspace = DockWorkspace::new();
let shell = workspace.session("paste-guard");
let id = {
let mut shell = shell.lock().unwrap();
start_command(
&mut shell,
"stty icanon; printf 'paste-gu''ard-ready\\n'; IFS= read -r answer; printf 'answer=%s\\n' \"$answer\"",
)
.unwrap();
shell.durable.session_id.clone()
};
let deadline = Instant::now() + Duration::from_secs(10);
while !output_snapshot(&shell.lock().unwrap()).contains("paste-guard-ready") {
assert!(Instant::now() < deadline, "shell did not reach its read");
std::thread::sleep(Duration::from_millis(25));
}
let error = write_raw_input(&shell.lock().unwrap(), &vec![b'x'; 64 * 1024]).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(error.to_string().contains("no input was sent"));
// The dock must use the same boundary, including its exact lower edge.
let error = write_dock_input(
workspace.path(),
"paste-guard",
&id,
&[b'x'; 1024],
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap_err();
assert!(error.contains("no input was sent"), "{error}");
write_dock_input(
workspace.path(),
"paste-guard",
&id,
b"human-answer\n",
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap();
let (done, timed_out) = wait_shared_session(&shell, Duration::from_secs(10)).unwrap();
assert!(!timed_out, "refused paste stranded the pending read");
assert_eq!(done.unwrap().0, 0);
let output = output_snapshot(&shell.lock().unwrap());
assert!(output.contains("answer=human-answer"), "{output}");
assert!(
!output.contains("xxxxxxxx"),
"refused input leaked into the PTY"
);
}
#[tokio::test(flavor = "current_thread")]
async fn terminal_send_wait_releases_the_session_for_dock_snapshot_and_input() {
let workspace = DockWorkspace::new();
let shell = workspace.session("send-wait");
let (id, output) = {
let mut shell = shell.lock().unwrap();
start_command(
&mut shell,
"stty icanon echo; printf 'send-wa''it-ready\\n'; IFS= read -r answer; printf 'answer=%s\\n' \"$answer\"",
)
.unwrap();
(shell.durable.session_id.clone(), Arc::clone(&shell.output))
};
let deadline = Instant::now() + Duration::from_secs(10);
while !output.lock().unwrap().text().contains("send-wait-ready") {
assert!(Instant::now() < deadline, "shell did not reach its read");
tokio::time::sleep(Duration::from_millis(25)).await;
}
let context = ToolContext::new(workspace.path());
let send = tokio::spawn(async move {
TerminalSendTool
.execute(
json!({"session":"send-wait", "text":"model-", "wait_ms":3000}),
&context,
)
.await
});
// Observe the PTY reader directly: taking the session mutex here would
// hide the old bug by waiting for terminal/send to finish first.
let deadline = Instant::now() + Duration::from_secs(10);
while !output.lock().unwrap().text().contains("model-") {
assert!(Instant::now() < deadline, "terminal/send did not write");
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(
!send.is_finished(),
"terminal/send already finished its wait"
);
let path = workspace.path().to_path_buf();
let mut dock = tokio::task::spawn_blocking(move || {
let snapshot = inspect_workspace(&path, Some(&id))?;
write_dock_input(
&path,
"send-wait",
&id,
b"human\n",
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)?;
Ok::<_, String>(snapshot)
});
let quick_dock = tokio::time::timeout(Duration::from_secs(1), &mut dock).await;
let responsive = quick_dock.is_ok() && !send.is_finished();
// Join both workers even on the old lock-held path before asserting,
// so a failed regression cannot leave work using the owned fixture.
let (entries, snapshot) = match quick_dock {
Ok(result) => result,
Err(_) => dock.await,
}
.unwrap()
.unwrap();
let result = send.await.unwrap().unwrap();
assert!(responsive, "terminal/send held the session during wait_ms");
assert_eq!(entries[0].state, DurableTerminalState::Running);
assert!(String::from_utf8_lossy(&snapshot.unwrap().bytes).contains("model-"));
assert!(result.content.contains("answer=model-human"), "{result:?}");
assert_eq!(result.metadata.unwrap()["exit_code"], 0);
}
#[test]
fn interactive_command_keeps_its_completion_marker_out_of_stdin() {
let workspace = DockWorkspace::new();
let shell = workspace.session("interactive");
let id = {
let mut shell = shell.lock().unwrap();
start_command(
&mut shell,
"printf 'ready-for-human\\n'; IFS= read -r answer; printf 'answer=%s\\n' \"$answer\"\n# trailing comment",
)
.unwrap();
shell.durable.session_id.clone()
};
let (done, timed_out) = wait_shared_session(&shell, Duration::from_millis(200)).unwrap();
assert!(timed_out && done.is_none());
write_dock_input(
workspace.path(),
"interactive",
&id,
b"human-answer\n",
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap();
let (done, timed_out) = wait_shared_session(&shell, Duration::from_secs(10)).unwrap();
assert!(!timed_out, "interactive command lost its completion marker");
assert_eq!(done.unwrap().0, 0);
assert!(output_snapshot(&shell.lock().unwrap()).contains("answer=human-answer"));
let result = run(
&shell,
"cat <<'CW_EOF'\nheredoc-proof\nCW_EOF\nfalse # preserve status",
Duration::from_secs(10),
);
assert!(result.content.contains("heredoc-proof"));
assert_eq!(result.metadata.unwrap()["exit_code"], 1);
let alias = run(
&shell,
"alias cw_dock_alias='printf alias-proof'\ncw_dock_alias",
Duration::from_secs(10),
);
assert!(alias.content.contains("alias-proof"));
assert_eq!(alias.metadata.unwrap()["exit_code"], 0);
}
#[test]
fn dock_input_can_interrupt_a_command_while_the_model_waits() {
let workspace = DockWorkspace::new();
let shell = workspace.session("waiting");
assert!(
run(&shell, "printf ready", Duration::from_secs(10))
.content
.contains("ready")
);
let id = {
let mut shell = shell.lock().unwrap();
start_command(&mut shell, "printf 'dock-wa''it-ready'; sleep 30").unwrap();
shell.durable.session_id.clone()
};
let deadline = Instant::now() + Duration::from_secs(10);
while !output_snapshot(&shell.lock().unwrap()).contains("dock-wait-ready") {
assert!(Instant::now() < deadline, "shell did not start its command");
std::thread::sleep(Duration::from_millis(25));
}
let waiter_shell = Arc::clone(&shell);
let waiter = std::thread::spawn(move || {
wait_shared_session(&waiter_shell, Duration::from_secs(10)).unwrap()
});
write_dock_input(
workspace.path(),
"waiting",
&id,
&[3],
&crate::sandbox::SandboxPolicy::DangerFullAccess,
)
.unwrap();
let (done, timed_out) = waiter.join().unwrap();
assert!(!timed_out, "dock input was blocked behind the model waiter");
assert_eq!(done.unwrap().0, 130);
assert!(
run(&shell, "printf 'still-alive'", Duration::from_secs(3))
.content
.contains("still-alive")
);
}
#[test]
#[cfg(unix)]
fn durable_summary_marks_prior_process_shell_stale_and_preserves_history() {
+3
View File
@@ -2814,6 +2814,9 @@ impl App {
return Focus::Launch;
}
if self.work_surface.focused {
if self.work_surface.panel == crate::tui::work_surface::RailPanel::Terminal {
return Focus::TerminalPanel;
}
return Focus::Panel;
}
Focus::Composer
+1 -1
View File
@@ -1,4 +1,4 @@
Tasks 3 Fleet 1 Context Git Cost ×
Tasks 3 Fleet 1 Terminal Context Git Cost ×
To-do · 0/3 · 3 left
1 · ▸ work item 0 in progress
2 · ○ work item 1 pending
+63 -4
View File
@@ -40,6 +40,8 @@ pub enum Focus {
Launch,
/// A focused rail or workflow panel inside a live session.
Panel,
/// The live PTY dock owns terminal input, including control keys.
TerminalPanel,
/// The session composer: the default owner.
Composer,
}
@@ -51,6 +53,7 @@ pub enum Focus {
/// is exclusive and sits outside that shell hierarchy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusScope {
TerminalPanel,
PetHabitat,
/// Only the sandbox-elevation decision card.
Elevation,
@@ -73,14 +76,24 @@ impl FocusScope {
#[must_use]
pub fn admits(self, focus: Focus) -> bool {
match self {
Self::TerminalPanel => focus == Focus::TerminalPanel,
Self::PetHabitat => focus == Focus::Modal(ModalKind::PetHabitat),
Self::Elevation => focus == Focus::Modal(ModalKind::Elevation),
Self::RedactionGate => focus == Focus::RedactionGate,
Self::SessionShell => matches!(focus, Focus::Composer | Focus::Panel),
Self::AnyShell => matches!(focus, Focus::Composer | Focus::Panel | Focus::Launch),
Self::SessionShell => {
matches!(focus, Focus::Composer | Focus::Panel | Focus::TerminalPanel)
}
Self::AnyShell => matches!(
focus,
Focus::Composer | Focus::Panel | Focus::TerminalPanel | Focus::Launch
),
Self::AnyShellOrConfig => matches!(
focus,
Focus::Composer | Focus::Panel | Focus::Launch | Focus::Modal(ModalKind::Config)
Focus::Composer
| Focus::Panel
| Focus::TerminalPanel
| Focus::Launch
| Focus::Modal(ModalKind::Config)
),
Self::Everywhere => focus != Focus::RedactionGate,
}
@@ -90,6 +103,10 @@ impl FocusScope {
/// Stable binding ids shared by handlers, footer hints, and help catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellBindingId {
TerminalNew,
TerminalNext,
TerminalPrevious,
TerminalDetach,
ElevationUp,
ElevationDown,
ElevationConfirm,
@@ -140,6 +157,16 @@ impl ShellBinding {
#[must_use]
pub fn matches(&self, key: &KeyEvent) -> bool {
match self.id {
ShellBindingId::TerminalNew => {
key.code == KeyCode::Char('n') && key.modifiers == KeyModifiers::CONTROL
}
ShellBindingId::TerminalNext => {
key.code == KeyCode::Down && key.modifiers == KeyModifiers::ALT
}
ShellBindingId::TerminalPrevious => {
key.code == KeyCode::Up && key.modifiers == KeyModifiers::ALT
}
ShellBindingId::TerminalDetach => key.code == KeyCode::Esc && key.modifiers.is_empty(),
ShellBindingId::ElevationUp => key.code == KeyCode::Up && key.modifiers.is_empty(),
ShellBindingId::ElevationDown => key.code == KeyCode::Down && key.modifiers.is_empty(),
ShellBindingId::ElevationConfirm => {
@@ -192,12 +219,44 @@ impl ShellBinding {
pub fn route(focus: Focus, key: &KeyEvent) -> Option<ShellBindingId> {
SHELL_BINDINGS
.iter()
.find(|binding| binding.focus.admits(focus) && binding.matches(key))
.find(|binding| {
binding.focus.admits(focus)
&& binding.matches(key)
&& !(focus == Focus::TerminalPanel
&& matches!(
binding.id,
ShellBindingId::ModeCycle | ShellBindingId::PermissionCycle
))
})
.map(|binding| binding.id)
}
/// Canonical shell bindings. Handlers and chrome read from here.
pub const SHELL_BINDINGS: &[ShellBinding] = &[
ShellBinding {
id: ShellBindingId::TerminalNew,
catalog_chord: "Ctrl+N",
footer_chord: "Ctrl+N",
focus: FocusScope::TerminalPanel,
},
ShellBinding {
id: ShellBindingId::TerminalNext,
catalog_chord: "Alt+Down",
footer_chord: "Alt+Down",
focus: FocusScope::TerminalPanel,
},
ShellBinding {
id: ShellBindingId::TerminalPrevious,
catalog_chord: "Alt+Up",
footer_chord: "Alt+Up",
focus: FocusScope::TerminalPanel,
},
ShellBinding {
id: ShellBindingId::TerminalDetach,
catalog_chord: "Esc",
footer_chord: "Esc",
focus: FocusScope::TerminalPanel,
},
ShellBinding {
id: ShellBindingId::ElevationUp,
catalog_chord: "Up",
+1
View File
@@ -175,6 +175,7 @@ impl InteractionTargetId {
pub const DOCK_TAB_AGENTS: Self = Self("dock.tab.agents");
pub const DOCK_TAB_TASKS: Self = Self("dock.tab.tasks");
pub const DOCK_TAB_BACKGROUND: Self = Self("dock.tab.background");
pub const DOCK_TAB_TERMINAL: Self = Self("dock.tab.terminal");
pub const DOCK_TAB_FILES: Self = Self("dock.tab.files");
pub const DOCK_TAB_NOTEPAD: Self = Self("dock.tab.notepad");
pub const DOCK_TAB_CONTEXT: Self = Self("dock.tab.context");
+9
View File
@@ -4863,6 +4863,7 @@ pub(crate) async fn run_event_loop(
let now = Instant::now();
flush_paste_burst_before_composer(app, now);
crate::tui::work_surface::poll_terminal(app);
app.sync_status_message_to_toasts();
// Drain background-LLM cost (compaction summaries, seam
// recompaction, cycle briefings) accumulated since the last
@@ -5033,6 +5034,11 @@ pub(crate) async fn run_event_loop(
let remaining = state.next_tick.saturating_duration_since(now);
poll_timeout = poll_timeout.min(remaining);
}
if app.work_surface.panel == crate::tui::work_surface::RailPanel::Terminal
&& app.work_surface.last_area.is_some()
{
poll_timeout = poll_timeout.min(Duration::from_millis(100));
}
poll_timeout = clamp_event_poll_timeout(poll_timeout);
// #549/#3216: give the engine task a scheduler turn before waiting on
@@ -5123,6 +5129,9 @@ pub(crate) async fn run_event_loop(
}
}
if let Event::Paste(text) = &evt {
if crate::tui::work_surface::handle_terminal_paste(app, text) {
continue;
}
if app.launch.return_to_session && app.view_stack.is_empty() {
app.launch.dismiss();
}
+4
View File
@@ -887,6 +887,10 @@ fn bracketed_paste_returns_dock_focus_to_the_visible_composer() {
/// One representative terminal encoding per shell binding.
fn shell_binding_probe(id: ShellBindingId) -> KeyEvent {
match id {
ShellBindingId::TerminalNew => KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
ShellBindingId::TerminalNext => KeyEvent::new(KeyCode::Down, KeyModifiers::ALT),
ShellBindingId::TerminalPrevious => KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
ShellBindingId::TerminalDetach => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
ShellBindingId::ElevationUp => KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
ShellBindingId::ElevationDown => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
ShellBindingId::ElevationConfirm => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
+14 -1
View File
@@ -101,6 +101,9 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowActio
}
return None;
}
if app.focus() == crate::tui::shell_key_routing::Focus::TerminalPanel {
return super::terminal::handle_key(app, key).then_some(None);
}
if app.work_surface.focused {
let tabs = app
.work_surface
@@ -151,7 +154,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowActio
// opened empty view still owns Esc (close) so cycling into "no files
// touched" is never a trap.
let rows = visible_rows_for_panel(app);
if rows.is_empty() && !(app.work_surface.focused && app.work_surface.explicit_view) {
if rows.is_empty()
&& !(app.work_surface.explicit_view
&& (app.work_surface.focused || app.work_surface.panel == RailPanel::Terminal))
{
return None;
}
if !app.work_surface.focused {
@@ -439,6 +445,13 @@ pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> MouseOutcome {
}
}
if super::terminal::handle_mouse(app, mouse) {
return MouseOutcome {
consumed: true,
action: None,
};
}
match mouse.kind {
MouseEventKind::ScrollUp => {
claim_focus(app);
@@ -9,6 +9,9 @@ use crate::tui::views::ModalKind;
use super::model::{RailPanel, WorkRowId};
pub(crate) fn select_dock_panel(app: &mut App, panel: RailPanel) {
if panel == RailPanel::Terminal {
app.launch.dismiss();
}
app.work_surface.panel = panel;
app.work_surface.explicit_view = true;
app.work_surface.dismissed = false;
+5 -2
View File
@@ -12,7 +12,7 @@
//! `work_surface_top_height` (5..=16) or `work_surface_side_width`
//! (26..=80) to `settings.toml`.
//! - **panel** — what it shows. [`RailPanel`]: `Tasks` (default) | `Agents` |
//! `Background` | `Files` | `Notepad` | `Context` | `Git` | `Price`, from
//! `Background` | `Terminal` | `Files` | `Notepad` | `Context` | `Git` | `Price`, from
//! the `rail_panel` setting. The legacy `sidebar_focus` key migrates into
//! it.
//!
@@ -65,6 +65,7 @@ mod input;
mod interaction;
mod model;
mod render;
mod terminal;
mod views;
pub use input::{cycle_view, enter_agents, handle_key, handle_mouse};
@@ -72,6 +73,7 @@ pub(crate) use interaction::{agent_details_closed, release_focus, select_dock_pa
pub use model::{RailPanel, WorkSurfacePlacement, WorkSurfaceState};
pub(crate) use render::collapse_strip;
pub use render::{height, render, split_chat};
pub(crate) use terminal::{handle_paste as handle_terminal_paste, poll as poll_terminal};
#[cfg(test)]
mod tests {
@@ -3137,7 +3139,8 @@ mod tests {
assert!(first_row.contains("Fleet"), "{first_row:?}");
assert!(!first_row.contains("Tasks 3"), "{first_row:?}");
assert!(!first_row.contains("Fleet 1"), "{first_row:?}");
assert!(first_row.contains("Context"), "{first_row:?}");
assert!(first_row.contains("Terminal"), "{first_row:?}");
assert!(!first_row.contains("Context"), "{first_row:?}");
// Shed from the right: price goes before any work view.
assert!(!first_row.contains("Cost"), "{first_row:?}");
}
+11 -1
View File
@@ -65,6 +65,8 @@ pub enum RailPanel {
Tasks,
/// Background shells and durable tasks. Scheduled work has its own manager.
Background,
/// Live workspace PTYs shared with the model, with explicit input focus.
Terminal,
/// Files edited this session (`+/−`) and files read into context.
Files,
/// Per-workspace plain-text notes (`.codewhale/notes.md`).
@@ -79,10 +81,11 @@ pub enum RailPanel {
impl RailPanel {
/// Cycle order — also the tab order in the dock strip.
pub const ORDER: [RailPanel; 8] = [
pub const ORDER: [RailPanel; 9] = [
Self::Tasks,
Self::Agents,
Self::Background,
Self::Terminal,
Self::Files,
Self::Notepad,
Self::Context,
@@ -128,6 +131,7 @@ impl RailPanel {
"agents" | "subagents" | "sub-agents" => Self::Agents,
"tasks" | "todo" | "todos" => Self::Tasks,
"background" | "shells" | "jobs" => Self::Background,
"terminal" | "terminals" => Self::Terminal,
"files" | "changes" => Self::Files,
"notepad" | "notes" => Self::Notepad,
"context" | "session" => Self::Context,
@@ -143,6 +147,7 @@ impl RailPanel {
Self::Agents => "agents",
Self::Tasks => "tasks",
Self::Background => "background",
Self::Terminal => "terminal",
Self::Files => "files",
Self::Notepad => "notepad",
Self::Context => "context",
@@ -163,6 +168,7 @@ impl RailPanel {
Self::Agents => "AGENTS",
Self::Tasks => "TODO",
Self::Background => "BACKGROUND",
Self::Terminal => "TERMINAL",
Self::Files => "FILES",
Self::Notepad => "NOTEPAD",
Self::Context => "CONTEXT",
@@ -329,6 +335,7 @@ pub(crate) struct SessionInstanceScope {
#[derive(Debug, Clone)]
pub struct WorkSurfaceState {
pub(super) terminal: super::terminal::TerminalDock,
pub placement: WorkSurfacePlacement,
pub(super) effective_placement: WorkSurfacePlacement,
/// Panel selection — orthogonal to placement.
@@ -434,6 +441,7 @@ impl WorkSurfaceState {
placement,
effective_placement: placement,
panel: RailPanel::default(),
terminal: super::terminal::TerminalDock::default(),
explicit_view: false,
file_activity: SettledFileActivity::default(),
top_height: top_height.clamp(TOP_HEIGHT_MIN, TOP_HEIGHT_MAX),
@@ -881,6 +889,7 @@ fn view_has_work(app: &mut App, panel: RailPanel) -> bool {
// tasks are work. Scheduled automation configuration belongs in /automation.
RailPanel::Background => background_has_live_work(app),
RailPanel::Files
| RailPanel::Terminal
| RailPanel::Notepad
| RailPanel::Context
| RailPanel::Git
@@ -1151,6 +1160,7 @@ pub(super) fn visible_rows_for(app: &mut App, panel: RailPanel) -> Vec<WorkRow>
RailPanel::Agents => agents_view_rows(app),
RailPanel::Tasks => project_visible(app),
RailPanel::Background => background_view_rows(app),
RailPanel::Terminal => Vec::new(),
RailPanel::Files => super::views::files_rows(app),
RailPanel::Notepad => super::views::notepad_rows(app),
RailPanel::Context => super::views::context_rows(app),
@@ -48,7 +48,9 @@ fn shared_native_body_matches_frozen_whole_buffer_actions_and_hover_receipts() {
] {
for panel in RailPanel::ORDER
.into_iter()
.filter(|panel| *panel != RailPanel::Git)
// These two panels have dedicated renderers; Terminal was added
// after the legacy body was frozen and has no old counterpart.
.filter(|panel| !matches!(panel, RailPanel::Git | RailPanel::Terminal))
{
for width in [12, 40, 59, 71, 72, 80, 120] {
for height in [1, 2, 3, 5, 12] {
@@ -22,6 +22,7 @@ pub(super) fn render_dock_tabs(frame: &mut Frame, area: Rect, app: &mut App) {
RailPanel::Tasks => "Tasks",
RailPanel::Agents => "Fleet",
RailPanel::Background => "Jobs",
RailPanel::Terminal => "Terminal",
RailPanel::Files => "Files",
RailPanel::Notepad => "Notes",
RailPanel::Context => "Context",
@@ -44,7 +45,8 @@ pub(super) fn render_dock_tabs(frame: &mut Frame, area: Rect, app: &mut App) {
// (`input::handle_key`). Otherwise Esc belongs to the composer and stops
// the running turn, which the posture bar already says with the turn
// status; a bare `×` here keeps the two from reading as one shortcut.
let esc_closes = app.work_surface.focused
let esc_closes = app.work_surface.panel != RailPanel::Terminal
&& app.work_surface.focused
&& !super::super::interaction::opened_detail_on_screen(app)
&& (app.work_surface.explicit_view || !visible_rows_for_panel(app).is_empty());
let close = if esc_closes && area.width >= 60 {
@@ -118,6 +118,9 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
collapse_strip(app);
return 0;
}
if app.work_surface.panel == RailPanel::Terminal {
return cap;
}
let list_rows = rows.len().max(usize::from(explicit));
let progress = u16::from(
top_todo_progress(app, &rows).is_some() && !progress_shares_goal_row(width, goal_rows > 0),
+18 -1
View File
@@ -104,6 +104,17 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
super::model::resolve_view(app);
let rows = visible_rows_for_panel(app);
if app.work_surface.panel == RailPanel::Terminal {
Block::default()
.style(Style::default().bg(app.ui_theme.panel_bg))
.render(area, frame.buffer_mut());
render_dock_tabs(frame, area, app);
register_dock_targets(app);
super::terminal::render(frame, body_area, app);
render_divider(frame, area, placement, app);
app.work_surface.last_area = Some(area);
return;
}
let todo_ordinals = if placement.is_strip() {
todo_ordinals(&rows)
} else {
@@ -455,6 +466,7 @@ fn empty_view_hint(panel: RailPanel) -> &'static str {
RailPanel::Agents => "no agents have run this session",
RailPanel::Tasks => "no to-dos yet",
RailPanel::Background => "nothing running in the background",
RailPanel::Terminal => "",
RailPanel::Files => "no files touched this session",
RailPanel::Notepad => "/note add <text> to keep a note",
RailPanel::Context => "context budget unknown",
@@ -580,6 +592,7 @@ fn kit_panel(panel: RailPanel) -> codewhale_ratatui::WorkbarPanel {
RailPanel::Tasks => P::Tasks,
RailPanel::Agents => P::Fleet,
RailPanel::Background => P::Jobs,
RailPanel::Terminal => P::Terminal,
RailPanel::Files => P::Files,
RailPanel::Notepad => P::Notes,
RailPanel::Context => P::Context,
@@ -596,6 +609,7 @@ fn native_target(target: codewhale_ratatui::DockTabTarget) -> DockTabTarget {
P::Tasks => RailPanel::Tasks,
P::Fleet => RailPanel::Agents,
P::Jobs => RailPanel::Background,
P::Terminal => RailPanel::Terminal,
P::Files => RailPanel::Files,
P::Notes => RailPanel::Notepad,
P::Context => RailPanel::Context,
@@ -625,7 +639,8 @@ fn render_dock_tabs(frame: &mut Frame, area: Rect, app: &mut App) {
"×"
};
// Name Esc only while Engine's actual keyboard action closes this dock.
let esc_closes = app.work_surface.focused
let esc_closes = app.work_surface.panel != RailPanel::Terminal
&& app.work_surface.focused
&& !super::interaction::opened_detail_on_screen(app)
&& (app.work_surface.explicit_view || !visible_rows_for_panel(app).is_empty());
let close = if esc_closes && area.width >= 60 {
@@ -702,6 +717,7 @@ fn dock_tab_count(app: &mut App, panel: RailPanel) -> Option<usize> {
),
RailPanel::Files => Some(super::views::files_touched_count(app)),
RailPanel::Notepad => Some(usize::from(super::views::notepad_has_text(app))),
RailPanel::Terminal => None,
RailPanel::Context | RailPanel::Git | RailPanel::Price => None,
}
}
@@ -716,6 +732,7 @@ fn register_dock_targets(app: &mut App) {
RailPanel::Agents => Id::DOCK_TAB_AGENTS,
RailPanel::Tasks => Id::DOCK_TAB_TASKS,
RailPanel::Background => Id::DOCK_TAB_BACKGROUND,
RailPanel::Terminal => Id::DOCK_TAB_TERMINAL,
RailPanel::Files => Id::DOCK_TAB_FILES,
RailPanel::Notepad => Id::DOCK_TAB_NOTEPAD,
RailPanel::Context => Id::DOCK_TAB_CONTEXT,
+715
View File
@@ -0,0 +1,715 @@
//! A view of the model's workspace-scoped PTYs, with ordered human input.
//!
//! Shell creation, input and reads run on one dedicated blocking worker;
//! the UI only exchanges bounded messages. Output is a 64 KiB ANSI-styled
//! tail, not a terminal emulator: cursor addressing and alternate screens
//! are not replayed, and process restart does not resurrect a shell.
use codewhale_localization::MessageId;
use crossterm::event::KeyEvent;
#[cfg(unix)]
use crossterm::event::{KeyCode, KeyModifiers};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Frame, layout::Rect, prelude::Widget, style::Style, text::Line, widgets::Paragraph};
#[cfg(unix)]
use super::model::RailPanel;
use crate::tui::app::App;
use crate::tui::shell_key_routing::ShellBindingId;
#[cfg(unix)]
use crate::tui::shell_key_routing::{binding, display_chord};
#[cfg(unix)]
use crate::tools::terminal_session::{self, TerminalSessionInfo};
#[cfg(unix)]
use std::{
path::PathBuf,
sync::mpsc,
time::{Duration, Instant},
};
#[derive(Debug, Default)]
pub(super) struct TerminalDock {
#[cfg(unix)]
workspace: PathBuf,
#[cfg(unix)]
worker: Option<Worker>,
#[cfg(unix)]
sessions: Vec<TerminalSessionInfo>,
#[cfg(unix)]
selected: Option<String>,
#[cfg(unix)]
output: Vec<u8>,
#[cfg(unix)]
dropped: u64,
#[cfg(unix)]
last_poll: Option<Instant>,
#[cfg(unix)]
refreshing: bool,
#[cfg(unix)]
size: Option<(u16, u16)>,
#[cfg(unix)]
lost: bool,
#[cfg(unix)]
creating: bool,
#[cfg(unix)]
session_tabs: Vec<(String, Rect)>,
#[cfg(unix)]
scroll: usize,
#[cfg(unix)]
error: Option<String>,
}
// A copied presentation must not share an input queue or steal replies.
impl Clone for TerminalDock {
fn clone(&self) -> Self {
Self::default()
}
}
#[cfg(unix)]
#[derive(Debug)]
struct Worker {
requests: mpsc::SyncSender<Request>,
replies: mpsc::Receiver<Reply>,
}
#[cfg(unix)]
#[derive(Debug)]
enum Request {
Refresh(Option<String>),
Create(crate::sandbox::SandboxPolicy),
Input {
name: String,
id: String,
bytes: Vec<u8>,
policy: crate::sandbox::SandboxPolicy,
},
Resize {
name: String,
id: String,
rows: u16,
cols: u16,
},
}
#[cfg(unix)]
#[derive(Debug)]
enum Reply {
Snapshot {
requested: Option<String>,
sessions: Vec<TerminalSessionInfo>,
output: Option<terminal_session::OutputChunk>,
},
Created(String),
InputWritten(String),
Error(String),
}
#[cfg(unix)]
impl Worker {
fn start(workspace: PathBuf) -> Result<Self, String> {
let (requests, incoming) = mpsc::sync_channel(128);
let (outgoing, replies) = mpsc::sync_channel(32);
std::thread::Builder::new()
.name("terminal-dock".into())
.spawn(move || {
while let Ok(request) = incoming.recv() {
let reply = match request {
Request::Refresh(requested) => match terminal_session::inspect_workspace(
&workspace,
requested.as_deref(),
) {
Ok((sessions, output)) => Reply::Snapshot {
requested,
sessions,
output,
},
Err(error) => Reply::Error(error),
},
Request::Create(policy) => {
let name =
format!("user-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
match terminal_session::get_or_create(&name, &workspace, policy)
.and_then(|_| terminal_session::inspect_workspace(&workspace, None))
{
Ok((sessions, _)) => {
match sessions.into_iter().find(|session| session.name == name)
{
Some(session) => Reply::Created(session.id),
None => Reply::Error("created terminal disappeared".into()),
}
}
Err(error) => Reply::Error(error),
}
}
Request::Input {
name,
id,
bytes,
policy,
} => {
match terminal_session::write_dock_input(
&workspace, &name, &id, &bytes, &policy,
) {
Ok(()) => Reply::InputWritten(id),
Err(error) => Reply::Error(error),
}
}
Request::Resize {
name,
id,
rows,
cols,
} => {
let result = terminal_session::lookup(&name, &workspace)
.ok_or_else(|| "selected terminal is gone or was reset".to_string())
.and_then(|handle| {
let session = handle.lock().map_err(|_| {
"terminal session lock poisoned".to_string()
})?;
terminal_session::resize_dock_session(&session, &id, rows, cols)
});
match result {
Ok(()) => continue,
Err(error) => Reply::Error(error),
}
}
};
if outgoing.send(reply).is_err() {
break;
}
}
})
.map_err(|error| error.to_string())?;
Ok(Self { requests, replies })
}
}
#[cfg(unix)]
fn policy(app: &App) -> crate::sandbox::SandboxPolicy {
crate::core::authority::sandbox_policy_for_turn(
app.mode,
app.approval_mode,
app.configured_sandbox_mode.as_deref(),
&app.workspace,
crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network),
)
}
/// Called by the existing event loop, including when another panel is open.
pub(crate) fn poll(app: &mut App) {
#[cfg(unix)]
{
let visible = app.work_surface.panel == RailPanel::Terminal
&& !app.work_surface.dismissed
&& app.work_surface.explicit_view;
let dock = &mut app.work_surface.terminal;
if dock.workspace != app.workspace {
*dock = TerminalDock {
workspace: app.workspace.clone(),
..Default::default()
};
}
tracing::trace!(
target: "codewhale::terminal_dock",
visible,
refreshing = dock.refreshing,
selected = ?dock.selected,
last_poll_age_ms = ?dock.last_poll.map(|last| last.elapsed().as_millis()),
"poll"
);
if visible && dock.worker.is_none() && dock.error.is_none() {
match Worker::start(app.workspace.clone()) {
Ok(worker) => dock.worker = Some(worker),
Err(error) => dock.error = Some(error),
}
}
if let Some(worker) = &dock.worker {
while let Ok(reply) = worker.replies.try_recv() {
match reply {
Reply::Snapshot {
requested,
sessions,
output,
} => {
tracing::trace!(
target: "codewhale::terminal_dock",
requested = ?requested,
selected = ?dock.selected,
accepted = dock.selected == requested,
session_states = ?sessions.iter().map(|session| (&session.id, session.state)).collect::<Vec<_>>(),
output_bytes = output.as_ref().map_or(0, |chunk| chunk.bytes.len()),
"snapshot"
);
dock.refreshing = false;
if dock.selected != requested {
continue;
}
if dock.selected.is_none() {
dock.selected = sessions.first().map(|session| session.id.clone());
}
dock.lost = dock.selected.is_some()
&& !sessions
.iter()
.any(|session| Some(&session.id) == dock.selected.as_ref());
let (bytes, dropped) = output
.map(|chunk| (chunk.bytes, chunk.offset))
.unwrap_or_default();
if dock.sessions != sessions
|| dock.output != bytes
|| dock.dropped != dropped
{
app.needs_redraw = true;
}
dock.sessions = sessions;
dock.output = bytes;
dock.dropped = dropped;
}
Reply::Created(id) => {
dock.creating = false;
dock.selected = Some(id);
dock.output.clear();
dock.size = None;
dock.error = None;
dock.lost = false;
dock.last_poll = None;
app.needs_redraw = true;
}
Reply::InputWritten(id) => {
if dock.selected.as_ref() == Some(&id) && dock.error.take().is_some() {
app.needs_redraw = true;
}
}
Reply::Error(error) => {
dock.creating = false;
dock.refreshing = false;
dock.error = Some(error);
app.needs_redraw = true;
}
}
}
if visible
&& !dock.refreshing
&& dock
.last_poll
.is_none_or(|last| last.elapsed() >= Duration::from_millis(100))
{
match worker
.requests
.try_send(Request::Refresh(dock.selected.clone()))
{
Ok(()) => {
dock.refreshing = true;
dock.last_poll = Some(Instant::now());
}
Err(error) => {
dock.error = Some(error.to_string());
app.needs_redraw = true;
}
}
}
}
}
#[cfg(not(unix))]
let _ = app;
}
#[cfg(unix)]
fn send(app: &mut App, request: Request) -> bool {
let dock = &mut app.work_surface.terminal;
let result = dock
.worker
.as_ref()
.ok_or_else(|| "terminal is not ready".to_string())
.and_then(|worker| {
worker
.requests
.try_send(request)
.map_err(|error| error.to_string())
});
let sent = result.is_ok();
if let Err(error) = result {
dock.error = Some(error);
}
dock.last_poll = None;
app.needs_redraw = true;
sent
}
pub(super) fn handle_key(app: &mut App, key: KeyEvent) -> bool {
use crate::tui::shell_key_routing::route;
let action = route(crate::tui::shell_key_routing::Focus::TerminalPanel, &key);
if action == Some(ShellBindingId::TerminalDetach) {
super::interaction::release_focus(app);
return true;
}
// Shared shell shortcuts keep their meaning; ordinary Tab remains PTY input.
if action.is_some_and(|id| {
!matches!(
id,
ShellBindingId::TerminalNew
| ShellBindingId::TerminalNext
| ShellBindingId::TerminalPrevious
)
}) {
return false;
}
#[cfg(unix)]
{
if action == Some(ShellBindingId::TerminalNew) {
if app.work_surface.terminal.worker.is_none() {
app.work_surface.terminal.error = None;
poll(app);
if app.work_surface.terminal.worker.is_none() {
return true;
}
}
let dock = &mut app.work_surface.terminal;
dock.selected = Some(uuid::Uuid::new_v4().to_string());
dock.output.clear();
dock.creating = true;
dock.lost = false;
dock.scroll = 0;
dock.size = None;
dock.error = None;
let sent = send(app, Request::Create(policy(app)));
app.work_surface.terminal.creating = sent;
return true;
}
if matches!(
action,
Some(ShellBindingId::TerminalNext | ShellBindingId::TerminalPrevious)
) {
let dock = &mut app.work_surface.terminal;
if !dock.sessions.is_empty() {
let current = dock
.sessions
.iter()
.position(|session| Some(&session.id) == dock.selected.as_ref());
let index = match (current, action) {
(Some(index), Some(ShellBindingId::TerminalPrevious)) => {
(index + dock.sessions.len() - 1) % dock.sessions.len()
}
(Some(index), _) => (index + 1) % dock.sessions.len(),
(None, _) => 0,
};
dock.selected = Some(dock.sessions[index].id.clone());
dock.output.clear();
dock.error = None;
dock.size = None;
dock.last_poll = None;
dock.lost = false;
dock.scroll = 0;
app.needs_redraw = true;
}
return true;
}
if matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) {
let dock = &mut app.work_surface.terminal;
dock.scroll = if key.code == KeyCode::PageUp {
dock.scroll.saturating_add(8)
} else {
dock.scroll.saturating_sub(8)
};
app.needs_redraw = true;
return true;
}
if let Some(bytes) = key_bytes(key) {
input(app, bytes);
}
}
true
}
#[cfg(unix)]
fn input(app: &mut App, bytes: Vec<u8>) {
let dock = &app.work_surface.terminal;
if let Some(session) = dock
.sessions
.iter()
.find(|session| Some(&session.id) == dock.selected.as_ref())
{
send(
app,
Request::Input {
name: session.name.clone(),
id: session.id.clone(),
bytes,
policy: policy(app),
},
);
} else {
app.work_surface.terminal.error = Some(app.tr(MessageId::TerminalDockLost).into_owned());
app.needs_redraw = true;
}
}
pub(crate) fn handle_paste(app: &mut App, text: &str) -> bool {
if app.focus() != crate::tui::shell_key_routing::Focus::TerminalPanel
|| app.work_surface.last_area.is_none()
{
return false;
}
#[cfg(unix)]
{
// Paste is deliberate terminal input. Limit it before queuing, and
// never pass terminal control sequences supplied by the clipboard.
if text.len() > 64 * 1024
|| text
.chars()
.any(|c| c.is_control() && !matches!(c, '\r' | '\n' | '\t'))
{
app.work_surface.terminal.error =
Some(app.tr(MessageId::TerminalDockPasteRefused).into_owned());
} else {
input(
app,
text.replace("\r\n", "\n").replace('\r', "\n").into_bytes(),
);
}
app.needs_redraw = true;
}
#[cfg(not(unix))]
let _ = text;
true
}
#[cfg(unix)]
fn key_bytes(key: KeyEvent) -> Option<Vec<u8>> {
let control =
key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT);
let text = match key.code {
KeyCode::Char(c) if control && c.is_ascii() => vec![(c.to_ascii_uppercase() as u8) & 0x1f],
KeyCode::Char(c) => c.to_string().into_bytes(),
KeyCode::Enter => b"\r".to_vec(),
KeyCode::Tab => b"\t".to_vec(),
KeyCode::Backspace => vec![0x7f],
KeyCode::Up => b"\x1b[A".to_vec(),
KeyCode::Down => b"\x1b[B".to_vec(),
KeyCode::Right => b"\x1b[C".to_vec(),
KeyCode::Left => b"\x1b[D".to_vec(),
KeyCode::Home => b"\x1b[H".to_vec(),
KeyCode::End => b"\x1b[F".to_vec(),
KeyCode::Delete => b"\x1b[3~".to_vec(),
_ => return None,
};
if key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::CONTROL) {
Some([vec![0x1b], text].concat())
} else {
Some(text)
}
}
pub(super) fn render(frame: &mut Frame, area: Rect, app: &mut App) {
let style = Style::default()
.fg(app.ui_theme.text_body)
.bg(app.ui_theme.panel_bg);
let muted = Style::default()
.fg(app.ui_theme.text_muted)
.bg(app.ui_theme.panel_bg);
let mut lines = Vec::new();
#[cfg(unix)]
{
use ansi_to_tui::IntoText;
let hints = app
.tr(MessageId::TerminalDockControls)
.replace(
"{new}",
&display_chord(binding(ShellBindingId::TerminalNew).footer_chord),
)
.replace(
"{next}",
&display_chord(binding(ShellBindingId::TerminalNext).footer_chord),
)
.replace(
"{detach}",
&display_chord(binding(ShellBindingId::TerminalDetach).footer_chord),
);
let dock = &app.work_surface.terminal;
let mut session_tabs = Vec::new();
// On compact hosts, remove help chrome before the shell's output.
if area.height >= 4 {
lines.push(Line::styled(hints, muted));
}
if dock.creating {
lines.push(Line::styled(app.tr(MessageId::TerminalDockStarting), muted));
} else if dock.lost {
lines.push(Line::styled(app.tr(MessageId::TerminalDockLost), muted));
} else if dock.sessions.is_empty() {
lines.push(Line::styled(
app.tr(MessageId::TerminalDockEmpty)
.replace("{new}", binding(ShellBindingId::TerminalNew).footer_chord),
muted,
));
} else if area.height >= 2 {
let ordered = dock
.sessions
.iter()
.filter(|session| Some(&session.id) == dock.selected.as_ref())
.chain(
dock.sessions
.iter()
.filter(|session| Some(&session.id) != dock.selected.as_ref()),
);
let mut spans = Vec::new();
let mut column = area.x;
for session in ordered {
let selected = Some(&session.id) == dock.selected.as_ref();
let status = if let Some(code) = session.exit_code {
app.tr(MessageId::TerminalDockExited)
.replace("{code}", &code.to_string())
} else {
match session.state {
terminal_session::DurableTerminalState::Running => {
app.tr(MessageId::SubagentsStatusRunning)
}
terminal_session::DurableTerminalState::Canceled => {
app.tr(MessageId::AutomationRunStatusCanceled)
}
terminal_session::DurableTerminalState::Failed => {
app.tr(MessageId::AutomationRunStatusFailed)
}
_ => app.tr(MessageId::TerminalDockLive),
}
.into_owned()
};
let activity = session
.last_activity
.map(|last| format!(" · {}s", last.elapsed().as_secs()))
.unwrap_or_default();
let text = format!(
"{}{} ({status}{activity}) ",
if selected { "› " } else { "" },
session.name
);
let width = unicode_width::UnicodeWidthStr::width(text.as_str())
.min(usize::from(area.right().saturating_sub(column)))
as u16;
if width == 0 {
break;
}
session_tabs.push((
session.id.clone(),
Rect::new(column, area.y.saturating_add(lines.len() as u16), width, 1),
));
column = column.saturating_add(width);
spans.push(ratatui::text::Span::styled(
text,
if selected && app.work_surface.focused {
style.bg(app.ui_theme.selection_bg)
} else {
style
},
));
}
lines.push(Line::from(spans));
}
if let Some(error) = &dock.error {
lines.push(Line::styled(
app.tr(MessageId::TerminalDockError)
.replace("{error}", error),
muted,
));
}
if dock.dropped > 0 {
lines.push(Line::styled(
app.tr(MessageId::TerminalDockDropped)
.replace("{count}", &dock.dropped.to_string()),
muted,
));
}
let mut safe = String::new();
crate::tui::osc8::strip_ansi_keep_sgr_into(
&String::from_utf8_lossy(&dock.output),
&mut safe,
);
let output = safe.into_text().unwrap_or_default();
let available = usize::from(area.height).saturating_sub(lines.len());
let scroll = dock
.scroll
.min(output.lines.len().saturating_sub(available));
let skip = output
.lines
.len()
.saturating_sub(available)
.saturating_sub(scroll);
lines.extend(output.lines.into_iter().skip(skip).take(available));
if let Some(session) = dock
.sessions
.iter()
.find(|session| Some(&session.id) == dock.selected.as_ref())
{
let size = (available.max(1) as u16, area.width.max(1));
if dock.size != Some(size) {
let request = Request::Resize {
name: session.name.clone(),
id: session.id.clone(),
rows: size.0,
cols: size.1,
};
if send(app, request) {
app.work_surface.terminal.size = Some(size);
}
}
}
app.work_surface.terminal.session_tabs = session_tabs;
app.work_surface.terminal.scroll = scroll;
}
#[cfg(not(unix))]
lines.push(Line::styled(
app.tr(MessageId::TerminalDockUnsupported),
muted,
));
Paragraph::new(lines)
.style(style)
.render(area, frame.buffer_mut());
app.work_surface.visible_rows = usize::from(area.height);
app.work_surface.total_rows = usize::from(area.height);
app.work_surface.hitboxes.clear();
app.work_surface.latest_rows.clear();
}
pub(super) fn handle_mouse(app: &mut App, mouse: MouseEvent) -> bool {
if app.work_surface.panel != super::model::RailPanel::Terminal {
return false;
}
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
super::interaction::claim_focus(app);
#[cfg(unix)]
if let Some(id) = app
.work_surface
.terminal
.session_tabs
.iter()
.find(|(_, area)| area.contains((mouse.column, mouse.row).into()))
.map(|(id, _)| id.clone())
{
let dock = &mut app.work_surface.terminal;
dock.selected = Some(id);
dock.output.clear();
dock.error = None;
dock.size = None;
dock.last_poll = None;
dock.lost = false;
dock.scroll = 0;
}
app.needs_redraw = true;
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
#[cfg(unix)]
{
let dock = &mut app.work_surface.terminal;
dock.scroll = if mouse.kind == MouseEventKind::ScrollUp {
dock.scroll.saturating_add(3)
} else {
dock.scroll.saturating_sub(3)
};
app.needs_redraw = true;
}
}
_ => {}
}
true
}
+1 -1
View File
@@ -14,7 +14,7 @@ use super::model::{RailPanel, WorkRow, WorkRowId, WorkTone};
pub(super) const fn view_always_has_content(panel: RailPanel) -> bool {
matches!(
panel,
RailPanel::Context | RailPanel::Git | RailPanel::Price
RailPanel::Context | RailPanel::Git | RailPanel::Price | RailPanel::Terminal
)
}
+21
View File
@@ -813,3 +813,24 @@ and sub-agents.
Next: [INSTALL.md](INSTALL.md), [CONFIGURATION.md](CONFIGURATION.md),
[PROVIDERS.md](PROVIDERS.md), [MODES.md](MODES.md), and
[TOOL_SURFACE.md](TOOL_SURFACE.md).
## Live Terminal dock
Open `/workbar terminal` to watch the same workspace PTY sessions the model
uses. Click the dock or press `Alt+W` to give it input focus. While focused,
typing, Enter, Tab and Ctrl+C reach the selected shell. Esc returns focus to
the composer; the output remains visible. Click a session or use `Alt+Down` /
`Alt+Up` to switch. `Ctrl+N` starts your own fresh shell without replacing the
model’s session. Mouse-wheel and PageUp/PageDown review the retained output.
The active permission posture and filesystem scope govern fresh shells and
refuse reuse of an unsandboxed shell after the scope narrows.
The dock shows the latest 64 KiB, reports omitted bytes, and never consumes the
model’s output cursor. A reset or lost session requires explicit selection;
input cannot silently follow a reused session name. PTYs are currently supported
on macOS and Linux. This is an ANSI-styled output view: cursor-addressed programs,
alternate screens and replay after process restart are outside its scope. Paste
is direct terminal input and may execute commands containing newlines; control
characters and pastes over 64 KiB are refused. While a process uses canonical
line input, batches of 1 KiB or more are refused before any bytes are sent:
the terminal can discard oversized input. Transfer long content through a file.
+10
View File
@@ -320,3 +320,13 @@ then `Enter` to submit or `Esc` to return to the options without sending.
- **Bare Up/Down arrows scroll transcript when composer empty (v0.8.13).** Previously the `should_scroll_with_arrows` gate was hardcoded to false, meaning bare arrows always navigated composer history even when the composer was empty. Users in virtual terminals (Ghostty, Codex, Kitty-protocol) were especially affected because they couldn't use Cmd+Up / Alt+Up shortcuts.
- **Configurable keymap (#436) and `tui.toml` (#437) remain deferred.** The `TuiPrefs` struct and loader exist in `settings.rs` but are not wired at startup. The named-binding registry that would let `~/.codewhale/tui.toml` override individual entries is still pending.
- **No other broken bindings found.** Every other chord listed above resolves to a live handler in `crates/tui/src/tui/ui.rs` (key-event dispatch) or `crates/tui/src/tui/app.rs` (mode + state transitions).
## Live Terminal dock
In `/workbar terminal`, click the dock or press `Alt+W` to focus it. Typing,
Enter, Tab and Ctrl+C reach the selected shell. Esc returns to the composer.
`Alt+Down` / `Alt+Up` switch sessions; `Ctrl+N` starts a fresh shell.
Mouse-wheel and PageUp/PageDown scroll output.
See the [Terminal dock guide](GUIDE.md#live-terminal-dock) for permission
boundaries, paste behavior and supported platforms.
+8
View File
@@ -612,6 +612,14 @@ status = "stable"
surfaces = { tui = "stable" }
owner = "crates/tui/src/tui/sidebar.rs"
[[feature]]
id = "terminal-dock"
name = "Terminal dock"
summary = "/workbar terminal shows workspace PTYs with live output, human input, session selection and a fresh shell; macOS/Linux."
status = "stable"
surfaces = { tui = "stable" }
owner = "crates/tui/src/tui/work_surface/terminal.rs"
# --- Operations and platforms ---
[[feature]]
+6 -2
View File
@@ -1,4 +1,4 @@
//! Codewhale's work dock: Tasks, Fleet, Jobs, Files, Notes, Context, Git, Cost.
//! Codewhale's work dock: Tasks, Fleet, Jobs, Terminal, Files, Notes, Context, Git, Cost.
//!
//! Extracted from `CodeWhale/crates/tui/src/tui/work_surface/{model,input,
//! render/{mod,layout,rows}}.rs` at `a79ce5c4d5ed1a5f7032185710c27343a900351c`.
@@ -23,6 +23,7 @@ pub enum WorkbarPanel {
Tasks,
Fleet,
Jobs,
Terminal,
Files,
Notes,
Context,
@@ -31,10 +32,11 @@ pub enum WorkbarPanel {
}
impl WorkbarPanel {
pub const ORDER: [Self; 8] = [
pub const ORDER: [Self; 9] = [
Self::Tasks,
Self::Fleet,
Self::Jobs,
Self::Terminal,
Self::Files,
Self::Notes,
Self::Context,
@@ -48,6 +50,7 @@ impl WorkbarPanel {
Self::Tasks => "Tasks",
Self::Fleet => "Fleet",
Self::Jobs => "Jobs",
Self::Terminal => "Terminal",
Self::Files => "Files",
Self::Notes => "Notes",
Self::Context => "Context",
@@ -62,6 +65,7 @@ impl WorkbarPanel {
Self::Tasks => "no to-dos yet",
Self::Fleet => "no agents have run this session",
Self::Jobs => "nothing running in the background",
Self::Terminal => "",
Self::Files => "no files touched this session",
Self::Notes => "/note add <text> to keep a note",
Self::Context => "context budget unknown",
+4
View File
@@ -278,6 +278,10 @@ impl ShowcaseState {
.elapsed_seconds(3),
),
],
WorkbarPanel::Terminal => vec![WorkbarRow::new(
"terminal:host",
"Terminal sessions are supplied by the host",
)],
WorkbarPanel::Jobs => vec![
WorkbarRow::new("shell:layout", "Check the compact session layout")
.mark(mark)
+4
View File
@@ -60,6 +60,10 @@ fn rows(panel: WorkbarPanel) -> Vec<WorkbarRow> {
.elapsed_seconds(183),
),
],
WorkbarPanel::Terminal => vec![WorkbarRow::new(
"terminal:host",
"Terminal sessions are supplied by the host",
)],
WorkbarPanel::Jobs => vec![
WorkbarRow::new("shell:gallery", "Render the component gallery")
.mark("●")