mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
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:
1 parent
a8cdccc1d6
commit
1c82c935c9
44 files changed
+1818
-53
No files matched your search
+56
-3
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 दबाएँ।",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 を押してください。",
|
||||
|
||||
@@ -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를 다시 누르세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("●")
|
||||
|
||||
Reference in new issue
Block a user