mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
refactor(split): codewhale-runtime crate and boundary ratchet (RS-0..RS-7) (#6586)
* ci(split): RS-0 runtime -> UI boundary ratchet and a hermetic step that cannot pass empty
First rail of the runtime/TUI crate split.
- scripts/split/module_graph.py: module graph of crates/tui/src and
crates/runtime/src with a comment/string-masking lexer and grouped
`use crate::{...}` expansion. Computes the runtime closure from the eight
seed modules (plus everything already in crates/runtime, plus test-only
support modules its tests use) and counts, per module pair:
prod / test references into UI code (tui, commands, remote_control,
context_report, composer_*, private lib.rs items), "late" references into
modules that move after the core (exec_agent, route_preferences), UI-library
uses (ratatui, crossterm, codewhale_tui, codewhale_palette), and intra-doc
links into UI code.
- scripts/runtime-boundary-baseline.json: prod 84, test 95, late 2, uilib 13,
doc 1 across 101 closure modules. Any rise or new pair fails with file:line;
a drop the baseline did not record also fails (`--update` lowers it and
refuses to raise).
- scripts/check-command-crate-boundaries.py is now table-driven
(BoundaryRule: package, metadata|tree dependency mode, forbidden packages,
source scan) and runs the ratchet. `tree` mode reads `cargo tree -p`, which
resolves features per package; `cargo metadata` unifies them and would see
palette's ratatui feature the TUI enables. The runtime entry lands with the
crate (RS-2).
- ci.yml hermetic safety step: `cargo test ... -- auto_review authority
sandbox` becomes `cargo nextest run --no-tests=fail -E 'test(auto_review) |
test(authority) | test(sandbox)'`, and the job installs nextest. After the
modules move, the old step would have matched 0 tests and passed.
Tests: python3 scripts/test_check_command_crate_boundaries.py -> Ran 19
tests, OK (11 existing + 8 new: tree mode, lexer masking, grouped imports,
late/uilib edges, runtime-crate closure, rise/drop, checked-in baseline).
python3 scripts/check-command-crate-boundaries.py -> PASS.
No Rust changed; no cargo build run for this commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* docs(split): RS-1 record the runtime split sequence and its three departures
docs/design/TUI_DECONSTRUCTION.md gains the runtime split sequence (rails,
crate with live code, cut upward edges one blocker per slice, rename-only
core move, then the client contract) and records where it departs from the
older order in the same file, with why each is safe:
1. config moves inside the runtime's strongly connected component instead
of leaving first (#6034/#6143 become internal runtime work);
2. loop convergence is not a precondition for moving the engine, because
run_subagent and Engine::run_turn move into the same crate;
3. the moves use one root path alias in the TUI lib.rs, deleted at the end,
instead of rewriting every caller in the moving change.
It also states that the engine, turn loop and tools go into
codewhale-runtime (no separate codewhale-engine), why the runtime cannot
live in crates/core (command-contract depends on core), and that the TUI
stays the only terminal-output owner behind the host_terminal port.
docs/ARCHITECTURE.md points at that sequence in its boundary note and
corrects crates/secrets's ownership (it also owns the shared sanitizer and
redaction).
Docs only; no build or test run.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* refactor(split): RS-2 create codewhale-runtime with 19 live leaf modules
`git mv` of the 19 leaf modules that reference nothing outside the batch in
production or test code and touch no UI library (7,149 lines):
session_tree native_memory goal_loop context_budget continual_harness
skill_state tool_history_repair retry_status llm_response_cache sleep_guard
media_originals regex_cache elapsed workspace_discovery hashing fast_hash
prompt_zones safe_label model_context.
- crates/runtime (codewhale-runtime): publishable (version.workspace, no
`publish = false`), `[lints] workspace = true`, the TUI's crate-level
`#![allow(clippy::uninlined_format_args)]` copied, no build.rs (nothing in
the batch needs one). Listed in scripts/release/crates.sh after
config/core/memory/models and before tui. The first crates.io
publication of the new name happens at the next release run; that is a
release-owner decision before this merges.
- TUI: 19 `mod` lines replaced by one private alias block
`use codewhale_runtime::{...};`, so no TUI file's content changes.
examples/zz_perf_probe.rs uses `codewhale_runtime::session_tree` instead of
a `#[path]` include.
- Visibility widened only where the compiler asked (scripts/split/widen.py,
lints capped to warnings during the loop, final pass with lints on):
25 items `pub(crate)` -> `pub` (fast_hash aliases, llm_response_cache
API, regex_cache::compile_user_regex, safe_label API and
safe_error_text, tool_history_repair receipt, workspace_discovery
helpers, hashing), and 12 `expect(dead_code)` / `cfg_attr(not(test),
expect(dead_code))` attributes removed because exported items are no
longer dead. No security-module items in this batch.
- `test-support` feature: retry_status's per-thread test state and readers,
SafeLabel::is_redacted and the WorldState diff renders move from
`cfg(test)` to `cfg(any(test, feature = "test-support"))`, all `pub`; the
TUI enables it from [dev-dependencies] only.
- Exported-only fixes: five rustdoc links from public `safe_error_text` to
private items became code spans; `WorldState::len` gets a local
`allow(clippy::len_without_is_empty)` instead of new API.
- Path sweep (scripts/split/move-modules.py): docs, AGENTS.md,
check-blocking-calls-budget.json key, check-runtime-contract-budget.py
fragment path, dev-test.sh examples; dev-test.sh gains a `runtime` area.
- Boundary checker: codewhale-runtime rule in `tree` mode (cargo tree -p,
per-package features) forbidding codewhale-tui/cli, ratatui*, crossterm,
ansi-to-tui and kit crates, plus a source scan of crates/runtime/src.
Ratchet unchanged: prod 84, test 95, late 2, uilib 13, doc 1.
Verification (local):
- widen.py final pass: cargo check -p codewhale-runtime -p codewhale-tui
--lib --tests --examples with lints on: 0 errors.
- cargo check -p codewhale-runtime --all-targets (standalone features): ok.
- cargo test -p codewhale-runtime --lib: 127 passed; 0 failed.
- cargo clippy -p codewhale-runtime --all-targets --all-features -D warnings: clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-runtime --no-deps: clean.
- cargo tree -p codewhale-runtime -e normal,build: 0 ratatui/crossterm/tui.
- python3 scripts/test_check_command_crate_boundaries.py: 21 tests OK;
check-command-crate-boundaries.py: PASS.
- validate-crate-publish-order.test.sh: passed.
- check-blocking-calls-budget.py: 717 sites, within budget.
Not run locally: TUI test execution (the TUI test binary build hit ENOSPC
on the shared volume; TUI tests are type-checked only), TUI clippy,
check-runtime-contract-budget.py measurement (it builds the TUI tests).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* refactor(palette): RS-3 make ratatui an optional default feature
The headless runtime needs palette's theme-setting and hex normalizers
(settings.rs) and RGB tokens (logging.rs), but palette pulled in ratatui
unconditionally. `ratatui` is now a default feature; without it the crate
is the renderer-free core:
- ids.rs (new): ThemeId with from_name/name/display_name/tagline,
SELECTABLE_THEMES, normalize_theme_name, USER_THEME_PREFIX,
normalize_user_theme_selector, normalize_theme_setting, and a
`(u8, u8, u8)` hex parser `parse_hex_rgb` behind normalize_hex_rgb_color
(same `#rrggbb` output as before).
- rgb.rs (new): the 161 `*_RGB` tuple tokens, split out of tokens.rs with
their comments; tokens.rs keeps the 103 `Color` roles.
- Behind `#[cfg(feature = "ratatui")]`: adapt, contrast, detect, grammar,
osc11, themes (UiTheme, ThemeId::ui_theme, parse_hex_rgb_color,
hex_rgb_string), tokens, user_theme, and the crate tests.
- The public API with default features is unchanged (lib.rs re-exports the
same names), so codewhale-tui needs no edit. The runtime will depend on
`codewhale-palette = { default-features = false }` when settings/logging
move (RS-14).
- Ratchet: codewhale_palette is no longer a UI library for the runtime
closure; uilib 13 -> 10 (settings.rs's 3 palette uses drop out).
Verification (local):
- cargo check -p codewhale-palette --no-default-features --all-targets: ok.
- cargo test -p codewhale-palette: 81 passed; 0 failed.
- cargo clippy -p codewhale-palette --all-targets -D warnings (default and
--no-default-features): clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-palette --no-deps
--no-default-features: clean.
- cargo check -p codewhale-tui --lib --tests: ok.
- python3 scripts/test_check_command_crate_boundaries.py: 21 tests OK.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* refactor(split): RS-4 the host_terminal port, with raw mode as its first use
Runtime code toggled crossterm raw mode directly around interactive
children in three places (tools/shell.rs x2, shell_dispatcher.rs), each with
its own copy of a restore guard. That is the only crossterm use left in the
runtime closure's production code.
- codewhale_runtime::host_terminal: the one port for terminal side effects.
`HostTerminal` trait (suspend_raw_mode / resume_raw_mode), a first-wins
`OnceLock` install, a no-op host when nothing is installed, and
`suspend_raw_mode()` returning a `RawModeSuspension` guard that resumes
only if raw mode was on (issue #1690 semantics). RS-7 adds the
notification-delivery methods to the same trait.
- The three call sites use `crate::host_terminal::suspend_raw_mode()`; the
three hand-written guard structs are deleted.
- tui/ui/terminal.rs: `TuiHostTerminal` implements the port with crossterm;
`install_host_terminal()` is called at the top of `run_with_args`, so
every mode the binary runs (interactive, exec, serve) keeps today's
behavior. Stdio hosts can later be launched without it.
- integration harness: `use codewhale_runtime::host_terminal;` at the
harness root, like `tool_parser`, for its `#[path]` shell_dispatcher.
- Ratchet: uilib 10 -> 1 (tools|crossterm 7 -> 1, the remaining one is a
roster_routes test; shell_dispatcher|crossterm 3 -> 0).
Verification (local):
- cargo test -p codewhale-runtime --lib: 128 passed; 0 failed (adds
host_terminal::tests::no_host_suspension_is_a_no_op).
- cargo clippy -p codewhale-runtime --all-targets --all-features -D warnings: clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-runtime --no-deps: clean.
- cargo check -p codewhale-tui --lib --tests --examples (incl. the
integration harness): ok.
- python3 scripts/split/module_graph.py: PASS after --update.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* refactor(split): RS-5 split the voice capture core out of commands::voice
runtime_api/voice.rs (the `/v1/voice` routes) imported
`crate::commands::voice`, a UI module, for recorder detection, ASR choice
and the headless dictation cycle.
- New runtime-side module `crate::voice` (774 lines) holds everything that
is not a slash command: recorder detection and `is_available`, WAV
encoding, `record_audio`, the send-suffix contract (`SEND_PHRASES`,
`split_send_suffix`), chat-completions transcription (provider, Groq,
local whisper), voice-control, ASR selection, and the headless
`dictate_once` / `DictateMode` / `DictateError` / `DictationOutcome`,
with their tests (vendor pin, WAV header, send suffix, recorder probe).
It uses `config` and `client`, so it moves into codewhale-runtime with
the core (M1).
- `commands::voice` keeps the three slash commands, the recording status
line, `VoiceCaptureOutcome` and `capture_and_transcribe` (it drives the
composer while the user speaks), importing the core from `crate::voice`.
Seven core functions become `pub(crate)` for that loop; nothing else
changes.
- runtime_api/voice.rs imports `crate::voice`.
- Ratchet: prod runtime_api|commands 10 -> 9.
Verification (local): cargo check -p codewhale-tui --lib --tests: ok.
The moved tests live in the TUI test binary, which was not linked locally
(shared volume at ~8 GB free); they are type-checked only.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* refactor(split): RS-6 move the context-window formatter down into utils
fleet/capability_badges.rs (runtime) imported
`tui::model_picker::format_picker_context_window`, a pure formatter.
- It moves to `crate::utils::format_context_window` (same body: `1M`,
`1.05M`, `262K`, `500`); the picker's 10 call sites and the badges' 6
(including its `1M/1.05M/262K/500` assertions) call it there.
- Not merged with `agent_roster::format_tokens` (`1.2k`): that labels usage,
not window size, and merging would change output.
- Ratchet: prod fleet|tui 1 -> 0.
Verification (local): cargo check -p codewhale-tui --lib --tests: ok.
The badge and picker tests are in the TUI test binary, which was not
linked locally (shared volume at ~8 GB free); type-checked only.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* refactor(split): RS-7 notification policy down, delivery behind host_terminal
Runtime code imported `tui::notifications` and friends: the `notify` tool
(tools/notify.rs), native notification preparation
(runtime_api/notification_delivery.rs) and headless exec (exec_agent.rs).
Per the plan's second review, delivery (OSC 9/99/777 escapes, taskbar and
title sequences, stdout writes) stays in the TUI, the one terminal-output
owner; only policy moves down.
- New runtime-side module `crate::notify`: `git mv` of
tui/notification_payload.rs -> notify/payload.rs,
tui/notification_audio.rs -> notify/audio.rs,
tui/sound_policy.rs -> notify/sound_policy.rs, plus the pure policy that
was in tui/notifications.rs: `Method`, `DeliveryOutcome` (+ receipts),
`NotificationGate`, `AttentionCondition` and its grace rule,
`native_attention_allowed`, and `settings_projection` (now takes
`&NotificationsConfig`). It uses `config`, so it moves into
codewhale-runtime with the core.
- `sanitize_stream_chunk` moves to `codewhale_secrets::sanitize` beside
`strip_ansi_into`; the payload and notifications call it there and
tui/ui/frame.rs re-exports it for the event loop, which does not change.
- host_terminal port gains `notify_model(title, body) -> receipt`,
`set_terminal_focused`, and `apply_notification_settings(&NotificationsConfig)`
(no-op / "no terminal host" receipt when nothing is installed). The TUI
host delegates to `tui::notifications::{notify_model,
set_terminal_focused, apply_settings}`; `settings(&Config)` is now a thin
wrapper over `apply_settings`, so its ~40 UI call sites are unchanged.
- tools/notify.rs calls the port; its emission-chain test (method=off
silences the sink) moves to tui::notifications tests, where the chain
lives. exec_agent.rs calls the port.
- notification_delivery.rs uses `crate::notify`; it still calls
`tui::notifications::notify_with_sinks` with null sinks, because that
function mixes the policy with transport selection. Known limitation,
written in notify/mod.rs: splitting transport out of notify_with_sinks is
the remaining step (1 prod runtime_api|tui reference left).
- Ratchet: prod 82 -> 75 (runtime_api|tui 5 -> 1, tools|tui 11 -> 8),
test 95 -> 89 (tools|tui 19 -> 14, config|tui 10 -> 9).
Plan note: RS-7 was scoped to wait for R3/R7 (exec_agent.rs) and #6569
(tui/ui/frame.rs); this branch touches those files and will need a rebase
after they land.
Verification (local):
- cargo check -p codewhale-tui --lib --tests --examples: ok.
- cargo test -p codewhale-runtime --lib: 128 passed; 0 failed.
- cargo test -p codewhale-secrets --lib: 76 passed; 0 failed; 1 ignored.
- cargo clippy -p codewhale-runtime -p codewhale-secrets --all-targets
--all-features -D warnings: clean.
- RUSTDOCFLAGS=-Dwarnings cargo doc -p codewhale-runtime --no-deps: clean.
The notification tests are in the TUI test binary, which was not linked
locally (shared volume at ~8 GB free); type-checked only.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* wip: unfinished review fix-up (interrupted by the session usage limit)
Uncommitted edits from the fix-up stage, saved so nothing is lost. Not
compiled or tested after these edits; the next pass reviews and finishes them.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* ci(split): run the ratchet's baseline-ref step under bash; wip fix-up reviewed
The boundary step uses `[[ =~ ]]` to skip an all-zero push `before` SHA, so
it names `shell: bash` instead of relying on the runner default.
This also closes the review of 7c8319eb1 (the interrupted fix-up): its edits
were compiled and tested here unchanged, and are kept as they are:
- the ratchet compares the committed baseline with the PR base
(`--baseline-ref`), so a hand-raised JSON fails CI;
- `super::` chains that reach the crate root count as references;
- `cargo tree --target all` so cfg(windows) deps are checked too;
- the thread-scoped retry state moves from `test-support` to its own
`test-thread-scoped-state` feature, debug-assertions only;
- RawModeSuspension resumes on the host that suspended it;
- palette `ids` tests run without the ratatui gate.
Ratchet proven to fail (then reverted):
- new `use crate::tui::...` in tools/notify.rs: FAIL, prod tools|tui 8 -> 9
- same edge via `super::super::tui::...`: FAIL, 8 -> 9
- baseline hand-raised to 9 with `--baseline-ref HEAD`: FAIL
("the baseline was raised relative to HEAD")
- `use ratatui::...` in crates/runtime/src: FAIL (source-scan)
Ratchet on a snapshot of this branch merged with origin/main: PASS.
Evidence (local):
- cargo check -p codewhale-runtime --locked: ok
- cargo check -p codewhale-palette --no-default-features --locked: ok
- cargo check -p codewhale-tui --locked: ok, 0 warnings
- cargo test -p codewhale-runtime -p codewhale-palette --lib: 130/0 and 84/0
- cargo test -p codewhale-tui --lib -- host_terminal notif retry_status
tools:🐚 296 passed, 0 failed, 2 ignored
- scripts/test_check_command_crate_boundaries.py: 23/0
- scripts/check-command-crate-boundaries.py --baseline-ref origin/main: PASS
- validate-crate-publish-order (crates.sh, includes codewhale-runtime
before codewhale-tui): ok; its test script: passed
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* test(split): pin the notify tool's no-host receipt; document retry-state feature reach
Review follow-up on the runtime split (both reviews: ship, no blocker or
major findings).
- tools::notify: the_model_sees_success_whatever_the_host_did now asserts
the exact receipt "notification not sent: no terminal host: done". The
old assertion only checked for "done", so a tool that ignored host()
and always said "sent" would still have passed. Also fixes the stale
capabilities comment (delivery goes through the terminal host now).
- runtime::retry_status: documents that Cargo feature unification lets
test-thread-scoped-state reach the debug `codewhale` binary under
`cargo test --workspace`; release and optimized builds never get it.
Behavior note for the series (RS-7): hosts that never install the
terminal host (e.g. in-process `cli dispatch`) now get "notification not
sent: no terminal host" from the notify tool instead of OSC bytes on
stdout.
Evidence:
- cargo test -p codewhale-tui --lib -- tools::notify: 7 passed; 0 failed.
- cargo fmt --all; git diff --check: clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* ci(split): move voice blocking-call budget with the code; list runtime crate in web facts
Lint (blocking-calls ratchet): RS-5 moved the synchronous voice
transcription core from commands/groups/core/voice.rs to src/voice.rs.
The 6 std::fs sites (temp WAV write / sidecar read / cleanup) moved
unchanged, so the budget key follows them: voice.rs gets std_fs 6 and
the now-clean commands/groups/core/voice.rs entry is dropped. Total
site count stays 718 across 202 files; no new debt. --update also
tightened fleet/exact.rs (clean on main, std_fs 2 -> 0) and re-sorted
the runtime/media_originals.rs entry.
Lint & Type Check (web): check:facts failed because the committed
facts.generated.ts predates the new `runtime` crate. Regenerated with
derive-facts.mjs.
Local checks: check-blocking-calls-budget.py within budget (718 sites,
202 files); test_check_blocking_calls_budget.py 11/11 OK;
check-facts OK; check-cloud-facts OK; check:latest-release OK;
check:tokens OK; derive-changelog current (6 releases);
check-versions.sh --range-audit-advisory OK; cargo fmt --check clean;
git diff --check clean. Not run locally: web lint/tsc/build/check:docs
(no web node_modules in this checkout; PR touches no other web files).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* fix(web): read palette RGB tokens from rgb.rs after RS-3
RS-3 moved the *_RGB consts from crates/palette/src/tokens.rs to rgb.rs, so
check:tokens failed with "no palette RGB consts found". The exporter and the
three docs that name the token source now point at rgb.rs. tokens.css
changes only its generated-from comment; all 142 token values are identical.
Checks: export-design-tokens.py --check: up to date (1 file, 142 tokens).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
* test(core): keep engine tests off exec_agent/runtime_chat_relay for the runtime ratchet
Merging origin/main brought two core engine test edges into modules outside
the runtime closure: #6517 asserted the isolated-chat prompt via
crate::runtime_chat_relay::dedicated_chat_system_prompt(None), and the
Auto-Review question fix built the headless catalog via
crate::exec_agent::exec_disallowed_tools(None). The RS-0..RS-7 ratchet counts
test edges as `late` (they block the crate move like prod edges), so
test_checked_in_baseline_holds failed: late core|exec_agent 1 -> 2 and
late core|runtime_chat_relay 0 -> 1 (new pair).
Both intents are kept without the edges: the engine test asserts the shared
ISOLATED_CHAT_SYSTEM_PROMPT (the relay test already pins
dedicated_chat_system_prompt(None) to that constant), and the headless half
passes the explicit deny list (exec_agent tests already pin
exec_disallowed_tools(None) to it). Baseline unchanged.
Local: scripts/test_check_command_crate_boundaries.py 23/23 OK;
check-command-crate-boundaries.py --baseline-ref origin/main PASS
(prod 75, test 89, late 2, uilib 1, doc 1); focused cargo tests 4/4 passed
(question_tool_survives..., isolated_runtime_chat_provider_request...,
isolated_chat_prompt_drops..., headless_exec_withholds...); other Lint-job
python gates green; cargo fmt --all --check and git diff --check clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
---------
Co-authored-by: CodeWhale Bot <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
93 files changed
+4071
-1889
No files matched your search
@@ -370,11 +370,18 @@ jobs:
|
||||
with:
|
||||
cache-bin: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- name: Hermetic safety and authorization tests
|
||||
env:
|
||||
RUST_MIN_STACK: "8388608"
|
||||
# nextest fails when the filter selects no test (`--no-tests=fail`),
|
||||
# so moving these modules to another crate cannot turn this step into
|
||||
# a silent 0-test pass. It does not catch a step that merely shrinks,
|
||||
# so name every package that owns safety tests explicitly:
|
||||
# codewhale-runtime owns `safe_label` (its hostile-authority test)
|
||||
# since RS-2; add the next package when another safety module moves.
|
||||
run: |
|
||||
sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-tui --lib --locked -- auto_review authority sandbox
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-tui -p codewhale-runtime --lib --locked --no-tests=fail -E 'test(auto_review) | test(authority) | test(sandbox)'
|
||||
sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-execpolicy --locked
|
||||
|
||||
lint:
|
||||
@@ -453,9 +460,21 @@ jobs:
|
||||
run: python3 scripts/check-provider-registry.py
|
||||
- name: Check command-contract prototype boundary
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
# The runtime -> UI ratchet baseline is compared with the one at the
|
||||
# PR base too, so a hand-edited JSON cannot raise it.
|
||||
shell: bash
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
python3 scripts/test_check_command_crate_boundaries.py
|
||||
python3 scripts/check-command-crate-boundaries.py
|
||||
baseline="${PR_BASE_SHA:-${PUSH_BEFORE_SHA:-}}"
|
||||
if [[ -n "${baseline}" && ! "${baseline}" =~ ^0+$ ]]; then
|
||||
git fetch --no-tags origin "${baseline}"
|
||||
python3 scripts/check-command-crate-boundaries.py --baseline-ref "${baseline}"
|
||||
else
|
||||
python3 scripts/check-command-crate-boundaries.py
|
||||
fi
|
||||
- name: Check command migration manifest
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
env:
|
||||
|
||||
@@ -156,9 +156,9 @@ casually it reads as taking the work even when credit is preserved.
|
||||
KV-cache effect: frozen prefix vs. append-only history. Never splice a
|
||||
volatile fact into the prefix; append it as a user-role message.
|
||||
- These active modules are repeatedly misidentified as dead; verify consumers
|
||||
before removal: `tui/src/context_budget.rs`, `tui/src/model_registry.rs`,
|
||||
`tui/src/prompt_zones.rs`, `tui/src/tools/remember.rs`, and
|
||||
`config/src/route/`. Native memory lives in `tui/src/native_memory.rs`;
|
||||
before removal: `runtime/src/context_budget.rs`, `tui/src/model_registry.rs`,
|
||||
`runtime/src/prompt_zones.rs`, `tui/src/tools/remember.rs`, and
|
||||
`config/src/route/`. Native memory lives in `runtime/src/native_memory.rs`;
|
||||
`tools/remember.rs` is its capture path.
|
||||
- Environment-specific behavior belongs in `docs/ENVIRONMENTS.md`, not here.
|
||||
- Blocking-call convention (#6149): code on the Tokio runtime — tool
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ scripts/dev-cargo.sh check -p codewhale-tui
|
||||
|
||||
# 2. Run only the tests near your change (one crate, one filter).
|
||||
scripts/dev-test.sh tui fleet_setup
|
||||
# or: scripts/dev-test.sh crates/tui/src/elapsed.rs
|
||||
# or: scripts/dev-test.sh crates/runtime/src/elapsed.rs
|
||||
|
||||
# 3. Run a whole crate's unit suite. scripts/dev-test.sh uses nextest when
|
||||
# it is installed (one process per test, all cores busy, slow tests
|
||||
|
||||
Generated
+28
@@ -1053,6 +1053,33 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-runtime"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"codewhale-config",
|
||||
"codewhale-core",
|
||||
"codewhale-memory",
|
||||
"codewhale-models",
|
||||
"fd-lock",
|
||||
"libc",
|
||||
"lru",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-secrets"
|
||||
version = "0.10.0"
|
||||
@@ -1151,6 +1178,7 @@ dependencies = [
|
||||
"codewhale-paths",
|
||||
"codewhale-protocol",
|
||||
"codewhale-release",
|
||||
"codewhale-runtime",
|
||||
"codewhale-secrets",
|
||||
"codewhale-telemetry",
|
||||
"codewhale-tools",
|
||||
|
||||
@@ -19,6 +19,7 @@ members = [
|
||||
"crates/paths",
|
||||
"crates/protocol",
|
||||
"crates/release",
|
||||
"crates/runtime",
|
||||
"crates/secrets",
|
||||
"crates/state",
|
||||
"crates/telemetry",
|
||||
|
||||
@@ -10,9 +10,17 @@ description = "Colour tokens, themes, and contrast math for Codewhale's terminal
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
# `ratatui` (default) enables the `Color` roles, themes, adaptation, contrast
|
||||
# math and terminal detection. Without it the crate is the renderer-free
|
||||
# core (RGB tuples, ThemeId, theme and hex normalizers) that the headless
|
||||
# runtime links: `codewhale-palette = { default-features = false }`.
|
||||
[features]
|
||||
default = ["ratatui"]
|
||||
ratatui = ["dep:ratatui"]
|
||||
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.10.0" }
|
||||
ratatui = { version = "=0.30.2", features = ["unstable-rendered-line-info"] }
|
||||
ratatui = { version = "=0.30.2", features = ["unstable-rendered-line-info"], optional = true }
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
use ratatui::style::Color;
|
||||
|
||||
use super::detect::PaletteMode;
|
||||
use super::themes::{
|
||||
GRAYSCALE_UI_THEME, LIGHT_UI_THEME, SOLARIZED_LIGHT_UI_THEME, ThemeId, UiTheme,
|
||||
};
|
||||
use super::ids::ThemeId;
|
||||
use super::themes::{GRAYSCALE_UI_THEME, LIGHT_UI_THEME, SOLARIZED_LIGHT_UI_THEME, UiTheme};
|
||||
use super::tokens::*;
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -135,7 +135,7 @@ pub fn chrome_style(theme: &UiTheme, ink: ChromeInk) -> Style {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::themes::SELECTABLE_THEMES;
|
||||
use crate::ids::SELECTABLE_THEMES;
|
||||
|
||||
#[test]
|
||||
fn chrome_has_five_visual_families_with_explicit_safety_roles() {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Theme identifiers, setting normalizers and hex parsing.
|
||||
//!
|
||||
//! Everything here compiles without the `ratatui` feature: a settings
|
||||
//! layer (the headless runtime) validates `theme = "..."` and colour
|
||||
//! strings without linking a renderer. Resolving an id to a `UiTheme`
|
||||
//! lives in `themes` behind the feature.
|
||||
|
||||
/// Stable identifiers for the named themes the user can select. `System`
|
||||
/// defers to `PaletteMode::detect()` (terminal-driven dark/light). Each
|
||||
/// dark/light id resolves to a single fixed `UiTheme`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ThemeId {
|
||||
System,
|
||||
Terminal,
|
||||
Shoreline,
|
||||
ShorelineLight,
|
||||
Underwater,
|
||||
UnderwaterRetro,
|
||||
Whale,
|
||||
WhaleLight,
|
||||
Grayscale,
|
||||
CatppuccinMocha,
|
||||
TokyoNight,
|
||||
Dracula,
|
||||
GruvboxDark,
|
||||
Claude,
|
||||
Matrix,
|
||||
SolarizedLight,
|
||||
Uwu,
|
||||
}
|
||||
|
||||
impl ThemeId {
|
||||
/// Parse a settings string (`"system"`, `"dark"`, `"catppuccin-mocha"`, …).
|
||||
/// Accepts a few aliases (`"whale"` for dark, `"light"` for whale-light)
|
||||
/// so existing config files keep working. Case-insensitive.
|
||||
#[must_use]
|
||||
pub fn from_name(value: &str) -> Option<Self> {
|
||||
match normalize_theme_name(value)? {
|
||||
"system" => Some(Self::System),
|
||||
"terminal" => Some(Self::Terminal),
|
||||
"underwater" | "deepsea" => Some(Self::Underwater),
|
||||
"underwater-retro" | "retro" => Some(Self::UnderwaterRetro),
|
||||
"shoreline" => Some(Self::Shoreline),
|
||||
"shoreline-light" => Some(Self::ShorelineLight),
|
||||
"dark" => Some(Self::Whale),
|
||||
"light" => Some(Self::WhaleLight),
|
||||
"grayscale" => Some(Self::Grayscale),
|
||||
"catppuccin-mocha" => Some(Self::CatppuccinMocha),
|
||||
"tokyo-night" => Some(Self::TokyoNight),
|
||||
"dracula" => Some(Self::Dracula),
|
||||
"gruvbox-dark" => Some(Self::GruvboxDark),
|
||||
"claude" => Some(Self::Claude),
|
||||
"matrix" => Some(Self::Matrix),
|
||||
"solarized-light" => Some(Self::SolarizedLight),
|
||||
"uwu" => Some(Self::Uwu),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical settings string (lowercase, dash-separated). Round-trips
|
||||
/// through `from_name`.
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Shoreline => "shoreline",
|
||||
Self::ShorelineLight => "shoreline-light",
|
||||
Self::Underwater => "underwater",
|
||||
Self::UnderwaterRetro => "underwater-retro",
|
||||
Self::Whale => "dark",
|
||||
Self::WhaleLight => "light",
|
||||
Self::Grayscale => "grayscale",
|
||||
Self::CatppuccinMocha => "catppuccin-mocha",
|
||||
Self::TokyoNight => "tokyo-night",
|
||||
Self::Dracula => "dracula",
|
||||
Self::GruvboxDark => "gruvbox-dark",
|
||||
Self::Claude => "claude",
|
||||
Self::Matrix => "matrix",
|
||||
Self::SolarizedLight => "solarized-light",
|
||||
Self::Uwu => "uwu",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label for picker rows.
|
||||
#[must_use]
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Terminal => "Terminal",
|
||||
Self::Shoreline => "Shoreline",
|
||||
Self::ShorelineLight => "Shoreline Light",
|
||||
Self::Underwater => "Underwater",
|
||||
Self::UnderwaterRetro => "Underwater Retro",
|
||||
Self::Whale => "Blue Stage",
|
||||
Self::WhaleLight => "Blue Stage Light",
|
||||
Self::Grayscale => "Grayscale",
|
||||
Self::CatppuccinMocha => "Catppuccin Mocha",
|
||||
Self::TokyoNight => "Tokyo Night",
|
||||
Self::Dracula => "Dracula",
|
||||
Self::GruvboxDark => "Gruvbox Dark",
|
||||
Self::Claude => "Claude",
|
||||
Self::Matrix => "Matrix",
|
||||
Self::SolarizedLight => "Solarized Light",
|
||||
Self::Uwu => "Uwu",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short tagline for picker rows.
|
||||
#[must_use]
|
||||
pub const fn tagline(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Follow terminal background (COLORFGBG / macOS appearance)",
|
||||
Self::Terminal => "Inherit terminal colors fully (transparent surfaces, ANSI accents)",
|
||||
Self::Shoreline => "Warm charcoal, one restrained blue — the desktop client's palette",
|
||||
Self::ShorelineLight => "Shoreline on warm paper — the desktop client's light mode",
|
||||
Self::Underwater => "The painted ocean field: ombre water, ambient life, the whale",
|
||||
Self::UnderwaterRetro => "Flat phosphor-teal ocean: the legacy deepsea look, no ombre",
|
||||
Self::Whale => "Stage black, action blue, and one Signal Gold human beacon",
|
||||
Self::WhaleLight => "Paper, cobalt action, and one Signal Gold human beacon",
|
||||
Self::Grayscale => "Color-minimal high contrast",
|
||||
Self::CatppuccinMocha => "Soft pastels on warm dark",
|
||||
Self::TokyoNight => "Deep blue/violet night palette",
|
||||
Self::Dracula => "Classic high-contrast purple",
|
||||
Self::GruvboxDark => "Vintage warm earth tones",
|
||||
Self::Claude => "Warm navy & coral",
|
||||
Self::Matrix => "The Matrix films inspired theme",
|
||||
Self::SolarizedLight => {
|
||||
"Solarized light — Light, calming palette on warm ivory — easy on the eyes"
|
||||
}
|
||||
Self::Uwu => "Soft kawaii night — sakura, mint, and peach",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Themes shown in the `/theme` picker, in display order.
|
||||
pub const SELECTABLE_THEMES: &[ThemeId] = &[
|
||||
ThemeId::System,
|
||||
ThemeId::Terminal,
|
||||
ThemeId::Shoreline,
|
||||
ThemeId::ShorelineLight,
|
||||
ThemeId::Underwater,
|
||||
ThemeId::UnderwaterRetro,
|
||||
ThemeId::Whale,
|
||||
ThemeId::WhaleLight,
|
||||
ThemeId::Grayscale,
|
||||
ThemeId::CatppuccinMocha,
|
||||
ThemeId::TokyoNight,
|
||||
ThemeId::Dracula,
|
||||
ThemeId::GruvboxDark,
|
||||
ThemeId::Claude,
|
||||
ThemeId::Matrix,
|
||||
ThemeId::SolarizedLight,
|
||||
ThemeId::Uwu,
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub fn normalize_theme_name(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "auto" | "system" | "default" => Some("system"),
|
||||
"terminal" | "term" | "transparent" | "follow-terminal" | "inherit" => Some("terminal"),
|
||||
"underwater" | "deepsea" | "deep-sea" | "ocean" | "ombre" => Some("underwater"),
|
||||
"underwater-retro" | "retro" | "uw-retro" => Some("underwater-retro"),
|
||||
"shoreline" | "warm" | "charcoal" => Some("shoreline"),
|
||||
"shoreline-light" | "paper" => Some("shoreline-light"),
|
||||
"dark" | "whale" | "whale-dark" => Some("dark"),
|
||||
"light" | "whale-light" => Some("light"),
|
||||
"grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white"
|
||||
| "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"),
|
||||
"catppuccin-mocha" | "catppuccin" | "mocha" => Some("catppuccin-mocha"),
|
||||
"tokyo-night" | "tokyonight" | "tokyo" => Some("tokyo-night"),
|
||||
"dracula" => Some("dracula"),
|
||||
"gruvbox-dark" | "gruvbox" => Some("gruvbox-dark"),
|
||||
"claude" => Some("claude"),
|
||||
"matrix" | "hacker" => Some("matrix"),
|
||||
"solarized-light" | "solarized" => Some("solarized-light"),
|
||||
"uwu" | "owo" | "kawaii" => Some("uwu"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const USER_THEME_PREFIX: &str = "custom:";
|
||||
|
||||
pub fn normalize_user_theme_selector(value: &str) -> Result<Option<String>, String> {
|
||||
let trimmed = value.trim();
|
||||
let Some(slug) = trimmed.strip_prefix(USER_THEME_PREFIX) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let slug = slug.trim().to_ascii_lowercase();
|
||||
if slug.is_empty()
|
||||
|| slug.len() > 64
|
||||
|| !slug
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
|
||||
{
|
||||
return Err(
|
||||
"custom theme names must be 1-64 ASCII letters, digits, '-' or '_'".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(Some(format!("{USER_THEME_PREFIX}{slug}")))
|
||||
}
|
||||
|
||||
pub fn normalize_theme_setting(value: &str) -> Result<String, String> {
|
||||
if let Some(id) = ThemeId::from_name(value) {
|
||||
return Ok(id.name().to_string());
|
||||
}
|
||||
normalize_user_theme_selector(value)?.ok_or_else(|| {
|
||||
format!("invalid theme '{value}'; use a compiled theme name or custom:<name>")
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `#rrggbb` (or `rrggbb`) into an RGB tuple.
|
||||
#[must_use]
|
||||
pub fn parse_hex_rgb(value: &str) -> Option<(u8, u8, u8)> {
|
||||
let hex = value.trim().strip_prefix('#').unwrap_or(value.trim());
|
||||
if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some((r, g, b))
|
||||
}
|
||||
|
||||
/// Canonical lowercase `#rrggbb` form of a hex colour string.
|
||||
#[must_use]
|
||||
pub fn normalize_hex_rgb_color(value: &str) -> Option<String> {
|
||||
let (r, g, b) = parse_hex_rgb(value)?;
|
||||
Some(format!("#{r:02x}{g:02x}{b:02x}"))
|
||||
}
|
||||
|
||||
// Theme ids and hex parsing are what the runtime links (without `ratatui`),
|
||||
// so their tests must not sit behind the `ratatui` gate that `tests.rs` needs.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_rgb_parses_with_or_without_hash_and_rejects_malformed_input() {
|
||||
assert_eq!(parse_hex_rgb("#1a1B26"), Some((26, 27, 38)));
|
||||
assert_eq!(parse_hex_rgb(" 1a1b26 "), Some((26, 27, 38)));
|
||||
assert_eq!(
|
||||
normalize_hex_rgb_color("#1A1B26").as_deref(),
|
||||
Some("#1a1b26")
|
||||
);
|
||||
for bad in ["#123", "#zzzzzz", "", "#1a1b2", "#1a1b267", "#+1a1b2"] {
|
||||
assert_eq!(parse_hex_rgb(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_names_normalize_aliases_and_reject_unknown_names() {
|
||||
assert_eq!(normalize_theme_name(" Default "), Some("system"));
|
||||
assert_eq!(normalize_theme_name("whale"), Some("dark"));
|
||||
assert_eq!(normalize_theme_name("b&w"), Some("grayscale"));
|
||||
assert_eq!(normalize_theme_name("not-a-theme"), None);
|
||||
for id in SELECTABLE_THEMES {
|
||||
assert_eq!(ThemeId::from_name(id.name()), Some(*id), "{}", id.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_theme_selector_accepts_only_bounded_ascii_slugs() {
|
||||
assert_eq!(
|
||||
normalize_user_theme_selector("custom: My_Theme-2 "),
|
||||
Ok(Some("custom:my_theme-2".to_string()))
|
||||
);
|
||||
assert_eq!(normalize_user_theme_selector("dark"), Ok(None));
|
||||
for bad in ["custom:", "custom:a/b", "custom:../x", "custom:ünïcode"] {
|
||||
assert!(normalize_user_theme_selector(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(normalize_user_theme_selector(&format!("custom:{}", "a".repeat(65))).is_err());
|
||||
assert_eq!(normalize_theme_setting("whale").as_deref(), Ok("dark"));
|
||||
assert!(normalize_theme_setting("custom:../x").is_err());
|
||||
assert!(normalize_theme_setting("nope").is_err());
|
||||
}
|
||||
}
|
||||
@@ -8,30 +8,60 @@
|
||||
//! values mapped to UI roles (surface, text, accent, status, mode).
|
||||
//! 3. **Backward-compatible aliases** (`DEEPSEEK_*`) — legacy names that
|
||||
//! delegate to the current Whale palette constants.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! `ratatui` (default) enables layers 2 and 3, themes, adaptation, contrast
|
||||
//! math and terminal background detection. Without it the crate is only the
|
||||
//! renderer-free core: the RGB tuples, [`ThemeId`] and theme-name / hex
|
||||
//! colour normalizers, so a headless settings layer can validate
|
||||
//! `theme = "..."` without linking ratatui.
|
||||
|
||||
mod ids;
|
||||
mod rgb;
|
||||
|
||||
#[cfg(feature = "ratatui")]
|
||||
mod adapt;
|
||||
#[cfg(feature = "ratatui")]
|
||||
mod contrast;
|
||||
#[cfg(feature = "ratatui")]
|
||||
mod detect;
|
||||
#[cfg(feature = "ratatui")]
|
||||
pub mod grammar;
|
||||
#[cfg(feature = "ratatui")]
|
||||
pub mod osc11;
|
||||
#[cfg(feature = "ratatui")]
|
||||
mod themes;
|
||||
#[cfg(feature = "ratatui")]
|
||||
mod tokens;
|
||||
#[cfg(feature = "ratatui")]
|
||||
mod user_theme;
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "ratatui"))]
|
||||
mod tests;
|
||||
|
||||
pub use ids::*;
|
||||
pub use rgb::*;
|
||||
|
||||
#[cfg(feature = "ratatui")]
|
||||
#[allow(unused_imports)]
|
||||
pub use adapt::*;
|
||||
#[cfg(feature = "ratatui")]
|
||||
#[allow(unused_imports)]
|
||||
pub use contrast::*;
|
||||
#[cfg(feature = "ratatui")]
|
||||
#[allow(unused_imports)]
|
||||
pub use detect::*;
|
||||
#[cfg(feature = "ratatui")]
|
||||
#[allow(unused_imports)]
|
||||
pub use grammar::{ChromeInk, SemanticFamily, chrome_style};
|
||||
#[cfg(feature = "ratatui")]
|
||||
#[allow(unused_imports)]
|
||||
pub use osc11::*;
|
||||
#[cfg(feature = "ratatui")]
|
||||
#[allow(unused_imports)]
|
||||
pub use themes::*;
|
||||
#[cfg(feature = "ratatui")]
|
||||
pub use tokens::*;
|
||||
#[cfg(feature = "ratatui")]
|
||||
pub use user_theme::*;
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Raw RGB color tokens: `(u8, u8, u8)` tuples, the palette values that
|
||||
//! do not need a renderer. Available without the `ratatui` feature, so the
|
||||
//! headless runtime can read them; `tokens` builds the `Color` roles on top.
|
||||
|
||||
// Codewhale Whale palette. Semantic colors own state, never decoration alone.
|
||||
pub const WHALE_BG_RGB: (u8, u8, u8) = (7, 12, 29); // #070C1D Ink field
|
||||
pub const WHALE_CHROME_RGB: (u8, u8, u8) = (12, 21, 49); // #0C1531 Navy chrome
|
||||
pub const WHALE_PANEL_RGB: (u8, u8, u8) = (16, 28, 64); // #101C40 Panel surface
|
||||
pub const WHALE_COMPOSER_RGB: (u8, u8, u8) = (20, 35, 82); // #142352 Stage plate (brand navy)
|
||||
pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (26, 44, 99); // #1A2C63 Raised
|
||||
pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (35, 45, 65); // #232D41 Quiet selection surface
|
||||
pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory
|
||||
pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (182, 192, 212); // #B6C0D4
|
||||
pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (147, 160, 184); // #93A0B8
|
||||
pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 153, 179); // #8A99B3 — AA hint on the ink field
|
||||
/// Blue Stage grammar: the interaction blue is the primary accent and the
|
||||
/// Info ink; it has exactly one name. Signal Gold is reserved for the whale
|
||||
/// mark and human-attention roles ([`WHALE_HUMAN_RGB`]), never for general
|
||||
/// interaction.
|
||||
pub const WHALE_ACTION_RGB: (u8, u8, u8) = (106, 166, 220); // #6AA6DC Ombre sky — owns interaction on dark
|
||||
// No TUI consumer yet; the web reads these through scripts/export-design-tokens.py
|
||||
// (`--whale-cobalt`, `--whale-ice`), so they are not dead.
|
||||
pub const WHALE_COBALT_RGB: (u8, u8, u8) = (21, 53, 178); // #1535B2 Ombre cobalt — light-mode action
|
||||
pub const WHALE_ICE_RGB: (u8, u8, u8) = (221, 238, 249); // #DDEEF9 Ice — structure on dark
|
||||
pub const WHALE_CYAN_RGB: (u8, u8, u8) = (120, 188, 232); // #78BCE8 Cyan — bounded accents only
|
||||
pub const WHALE_ACCENT_SECONDARY_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam
|
||||
// Whale Teams (Signal Cut, CWC 2026-08-15) identity accents. These two brand
|
||||
// palette entries have no semantic role in the TUI; they exist only so the
|
||||
// Harbor and Echo whale marks carry their exact CWC accent instead of a
|
||||
// borrowed state color. Never use them for status, mode, or permission.
|
||||
pub const WHALE_BRAND_ORANGE_RGB: (u8, u8, u8) = (255, 138, 61); // #FF8A3D Harbor mooring loop
|
||||
pub const WHALE_BRAND_MAGENTA_RGB: (u8, u8, u8) = (240, 78, 184); // #F04EB8 Echo sonar ticks
|
||||
pub const WHALE_HUMAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold
|
||||
pub const WHALE_WORKING_GREEN_RGB: (u8, u8, u8) = (155, 214, 111); // #9BD66F Working Green
|
||||
pub const WHALE_ERROR_RGB: (u8, u8, u8) = (255, 134, 178); // #FF86B2 Rose danger
|
||||
pub const WHALE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 156, 194); // #FF9CC2
|
||||
pub const WHALE_ERROR_SURFACE_RGB: (u8, u8, u8) = (43, 21, 34); // #2B1522
|
||||
pub const WHALE_ERROR_BORDER_RGB: (u8, u8, u8) = WHALE_ERROR_RGB;
|
||||
pub const WHALE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 219, 232); // #FFDBE8
|
||||
pub const WHALE_WARNING_RGB: (u8, u8, u8) = (255, 122, 89); // #FF7A59 Coral warning
|
||||
pub const WHALE_SUCCESS_RGB: (u8, u8, u8) = WHALE_WORKING_GREEN_RGB; // completed / verified
|
||||
pub const WHALE_BORDER_RGB: (u8, u8, u8) = (42, 63, 114); // #2A3F72, sky at 25% on stage
|
||||
pub const WHALE_REASONING_TEXT_RGB: (u8, u8, u8) = (224, 153, 72); // #E09948
|
||||
pub const WHALE_REASONING_SURFACE_RGB: (u8, u8, u8) = (42, 34, 24); // #2A2218
|
||||
pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (22, 36, 74); // #16244A
|
||||
|
||||
// Solarized Light palette RGB tuples
|
||||
pub const SOLARIZED_BASE03_RGB: (u8, u8, u8) = (0x00, 0x2B, 0x36);
|
||||
pub const SOLARIZED_BASE02_RGB: (u8, u8, u8) = (0x07, 0x36, 0x42);
|
||||
pub const SOLARIZED_BASE01_RGB: (u8, u8, u8) = (0x52, 0x66, 0x6D); // lifted for 4.5:1 muted text
|
||||
pub const SOLARIZED_BASE00_RGB: (u8, u8, u8) = (0x65, 0x7B, 0x83);
|
||||
pub const SOLARIZED_BASE0_RGB: (u8, u8, u8) = (0x72, 0x81, 0x82); // lifted for 3:1 hint text
|
||||
pub const SOLARIZED_BASE1_RGB: (u8, u8, u8) = (0x93, 0xA1, 0xA1);
|
||||
pub const SOLARIZED_BASE2_RGB: (u8, u8, u8) = (0xEE, 0xE8, 0xD5);
|
||||
pub const SOLARIZED_BASE3_RGB: (u8, u8, u8) = (0xFD, 0xF6, 0xE3);
|
||||
pub const SOLARIZED_YELLOW_RGB: (u8, u8, u8) = (0xB4, 0x88, 0x00); // lifted for 3:1 on ivory
|
||||
pub const SOLARIZED_ORANGE_RGB: (u8, u8, u8) = (0xCB, 0x4B, 0x16);
|
||||
pub const SOLARIZED_RED_RGB: (u8, u8, u8) = (0xDC, 0x32, 0x2F);
|
||||
pub const SOLARIZED_BLUE_RGB: (u8, u8, u8) = (0x26, 0x8B, 0xD2);
|
||||
pub const SOLARIZED_CYAN_RGB: (u8, u8, u8) = (0x29, 0x9E, 0x96); // lifted for 3:1 on ivory
|
||||
pub const SOLARIZED_GREEN_RGB: (u8, u8, u8) = (0x7F, 0x92, 0x00); // lifted for 3:1 on ivory/diff bg
|
||||
pub const SOLARIZED_PANEL_RGB: (u8, u8, u8) = (0xF0, 0xED, 0xE7);
|
||||
pub const SOLARIZED_ELEVATED_RGB: (u8, u8, u8) = (0xE4, 0xDF, 0xCF);
|
||||
pub const SOLARIZED_SELECT_RGB: (u8, u8, u8) = (0xD6, 0xD2, 0xC9);
|
||||
|
||||
pub const WHALE_DIFF_ADDED_RGB: (u8, u8, u8) = (87, 199, 133); // #57C785
|
||||
pub const WHALE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (18, 42, 34); // #122A22
|
||||
// Raw colors that are remapped by equality must remain distinct across roles.
|
||||
// These stay in the same perceptual families as action, danger, and human asks
|
||||
// while preserving mode identity for Terminal and community themes.
|
||||
pub const WHALE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (52, 24, 39); // #341827
|
||||
pub const WHALE_MODE_AGENT_RGB: (u8, u8, u8) = (126, 180, 232); // #7EB4E8
|
||||
pub const WHALE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 112, 160); // #FF70A0
|
||||
pub const WHALE_MODE_PLAN_RGB: (u8, u8, u8) = (185, 220, 236); // #B9DCEC Structural Ice
|
||||
pub const WHALE_MODE_OPERATE_RGB: (u8, u8, u8) = (173, 136, 255); // #AD88FF
|
||||
pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = WHALE_ACCENT_SECONDARY_RGB;
|
||||
pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = WHALE_ERROR_RGB;
|
||||
pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = WHALE_TEXT_SOFT_RGB;
|
||||
pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (15, 26, 58); // #0F1A3A
|
||||
pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (24, 44, 94); // #182C5E
|
||||
|
||||
pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (244, 247, 251); // #F4F7FB
|
||||
pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (255, 253, 248); // #FFFDF8
|
||||
pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (232, 238, 248); // #E8EEF8
|
||||
pub const LIGHT_REASONING_RGB: (u8, u8, u8) = (255, 246, 214); // #FFF6D6
|
||||
pub const LIGHT_SUCCESS_RGB: (u8, u8, u8) = (223, 247, 231); // #DFF7E7
|
||||
pub const LIGHT_SUCCESS_FG_RGB: (u8, u8, u8) = (20, 118, 61); // #14763D, readable on every light surface
|
||||
pub const LIGHT_ERROR_RGB: (u8, u8, u8) = (252, 235, 242); // #FCEBF2
|
||||
pub const LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (20, 33, 58); // #14213A
|
||||
pub const LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (91, 103, 128); // #5B6780
|
||||
pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (95, 107, 129); // #5F6B81
|
||||
pub const LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (69, 81, 104); // #455168
|
||||
pub const LIGHT_ACTION_RGB: (u8, u8, u8) = (21, 53, 178); // #1535B2 Ombre cobalt
|
||||
pub const LIGHT_LIVE_RGB: (u8, u8, u8) = (8, 118, 109); // #08766D
|
||||
pub const LIGHT_HUMAN_RGB: (u8, u8, u8) = (122, 85, 0); // #7A5500
|
||||
pub const LIGHT_WARNING_RGB: (u8, u8, u8) = (169, 71, 36); // #A94724
|
||||
pub const LIGHT_DANGER_RGB: (u8, u8, u8) = (180, 35, 90); // #B4235A
|
||||
// Mode shades stay in their parent semantic families while remaining distinct
|
||||
// inputs to the render backend. A `Cell` carries only a `Color`, so reusing the
|
||||
// exact action/human/danger value here would erase the mode role before ANSI
|
||||
// adaptation can preserve it.
|
||||
pub const LIGHT_MODE_AGENT_RGB: (u8, u8, u8) = (22, 54, 178); // #1636B2
|
||||
pub const LIGHT_MODE_YOLO_RGB: (u8, u8, u8) = (181, 35, 90); // #B5235A
|
||||
pub const LIGHT_MODE_PLAN_RGB: (u8, u8, u8) = (52, 92, 128); // #345C80 Structural steel-blue
|
||||
pub const LIGHT_OPERATE_RGB: (u8, u8, u8) = (112, 71, 184); // #7047B8
|
||||
|
||||
pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (169, 184, 207); // #A9B8CF
|
||||
pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (238, 246, 255); // #EEF6FF
|
||||
pub const GRAYSCALE_SURFACE_RGB: (u8, u8, u8) = (10, 10, 10); // #0A0A0A
|
||||
pub const GRAYSCALE_PANEL_RGB: (u8, u8, u8) = (18, 18, 18); // #121212
|
||||
pub const GRAYSCALE_ELEVATED_RGB: (u8, u8, u8) = (31, 31, 31); // #1F1F1F
|
||||
pub const GRAYSCALE_REASONING_RGB: (u8, u8, u8) = (38, 38, 38); // #262626
|
||||
pub const GRAYSCALE_SUCCESS_RGB: (u8, u8, u8) = (34, 34, 34); // #222222
|
||||
pub const GRAYSCALE_ERROR_RGB: (u8, u8, u8) = (42, 42, 42); // #2A2A2A
|
||||
pub const GRAYSCALE_TEXT_BODY_RGB: (u8, u8, u8) = (236, 236, 236); // #ECECEC
|
||||
pub const GRAYSCALE_TEXT_MUTED_RGB: (u8, u8, u8) = (180, 180, 180); // #B4B4B4
|
||||
pub const GRAYSCALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 138, 138); // #8A8A8A
|
||||
pub const GRAYSCALE_TEXT_SOFT_RGB: (u8, u8, u8) = (220, 220, 220); // #DCDCDC
|
||||
pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); // #606060
|
||||
pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62); // #3E3E3E
|
||||
|
||||
pub const MATRIX_SURFACE_RGB: (u8, u8, u8) = (0, 10, 0); // #000A00
|
||||
pub const MATRIX_ELEVATED_RGB: (u8, u8, u8) = (0, 51, 0); // #003300
|
||||
pub const MATRIX_SELECTION_RGB: (u8, u8, u8) = (0, 51, 0); // #003300
|
||||
pub const MATRIX_TEXT_BODY_RGB: (u8, u8, u8) = (136, 255, 136); // #88FF88
|
||||
pub const MATRIX_TEXT_MUTED_RGB: (u8, u8, u8) = (0, 169, 0); // #00A900, lifted for 4.5:1
|
||||
pub const MATRIX_TEXT_HINT_RGB: (u8, u8, u8) = (0, 135, 0); // #008700, lifted for 3:1
|
||||
pub const MATRIX_TEXT_SOFT_RGB: (u8, u8, u8) = (221, 255, 221); // #DDFFDD
|
||||
pub const MATRIX_TEXT_DIM_RGB: (u8, u8, u8) = (0, 108, 0); // #006C00, lifted for 3:1
|
||||
pub const MATRIX_BORDER_RGB: (u8, u8, u8) = (0, 204, 0); // #00CC00
|
||||
|
||||
// Shoreline — the TUI's charcoal theme.
|
||||
//
|
||||
// Warm charcoal ground and warm paper sheet, one restrained blue, and the
|
||||
// whale's ivory ink on both sides. This is the charcoal alternative to the
|
||||
// terminal's navy "Underwater" default in 0.10.0. The
|
||||
// 0.10.0 action pair uses glacial blue on charcoal and deep ocean blue on
|
||||
// paper. The GPUI desktop does not paint these values: its theme is the
|
||||
// separate `GPUI_*` / `GPUI_LIGHT_*` set below.
|
||||
//
|
||||
// Every pair audited by `contrast::theme_contrast_violations` clears its
|
||||
// floor: body roles clear 4.5:1 on all four surfaces, hint/dim and the
|
||||
// status roles clear 3:1.
|
||||
pub const SHORELINE_SURFACE_RGB: (u8, u8, u8) = (33, 31, 35); // #211F23 warm charcoal field
|
||||
pub const SHORELINE_PANEL_RGB: (u8, u8, u8) = (43, 40, 46); // #2B282E raised plate
|
||||
pub const SHORELINE_ELEVATED_RGB: (u8, u8, u8) = (53, 49, 58); // #35313A
|
||||
pub const SHORELINE_COMPOSER_RGB: (u8, u8, u8) = (43, 40, 46); // #2B282E
|
||||
pub const SHORELINE_CHROME_RGB: (u8, u8, u8) = (26, 24, 28); // #1A181C recessed chrome
|
||||
pub const SHORELINE_SELECTION_RGB: (u8, u8, u8) = (44, 70, 84); // #2C4654 deep ocean selection
|
||||
pub const SHORELINE_TEXT_BODY_RGB: (u8, u8, u8) = (242, 236, 229); // #F2ECE5
|
||||
pub const SHORELINE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 210, 220); // #D9D2DC
|
||||
pub const SHORELINE_TEXT_MUTED_RGB: (u8, u8, u8) = (176, 167, 178); // #B0A7B2
|
||||
pub const SHORELINE_TEXT_HINT_RGB: (u8, u8, u8) = (154, 145, 159); // #9A919F
|
||||
pub const SHORELINE_TEXT_DIM_RGB: (u8, u8, u8) = (126, 117, 131); // #7E7583
|
||||
pub const SHORELINE_BORDER_RGB: (u8, u8, u8) = (73, 66, 77); // #49424D
|
||||
pub const SHORELINE_ACTION_RGB: (u8, u8, u8) = (103, 184, 214); // #67B8D6 glacial blue — action and whale identity
|
||||
pub const SHORELINE_LIVE_RGB: (u8, u8, u8) = (127, 214, 198); // #7FD6C6 the live lane
|
||||
pub const SHORELINE_HUMAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold, the human lane
|
||||
pub const SHORELINE_ERROR_RGB: (u8, u8, u8) = (255, 143, 168); // #FF8FA8
|
||||
pub const SHORELINE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 166, 187); // #FFA6BB
|
||||
pub const SHORELINE_ERROR_SURFACE_RGB: (u8, u8, u8) = (58, 32, 41); // #3A2029
|
||||
pub const SHORELINE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 220, 230); // #FFDCE6
|
||||
pub const SHORELINE_WARNING_RGB: (u8, u8, u8) = (240, 168, 104); // #F0A868
|
||||
pub const SHORELINE_SUCCESS_RGB: (u8, u8, u8) = (163, 217, 119); // #A3D977
|
||||
pub const SHORELINE_STATUS_WORKING_RGB: (u8, u8, u8) = SHORELINE_LIVE_RGB;
|
||||
// Mode badges keep the shipped mode ramp: these four are proof-tested distinct
|
||||
// from one another and from every semantic lane, and a mode badge is identity,
|
||||
// not decoration. `mode_agent` must differ from `accent_primary` or
|
||||
// `adapt::theme_semantic_foreground_role` resolves the action lane to a mode
|
||||
// role (`ModeAgent`) before it ever reaches `Action`.
|
||||
pub const SHORELINE_MODE_AGENT_RGB: (u8, u8, u8) = (126, 180, 232); // #7EB4E8
|
||||
pub const SHORELINE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 112, 160); // #FF70A0
|
||||
pub const SHORELINE_MODE_PLAN_RGB: (u8, u8, u8) = (185, 220, 236); // #B9DCEC
|
||||
pub const SHORELINE_MODE_OPERATE_RGB: (u8, u8, u8) = (173, 136, 255); // #AD88FF
|
||||
pub const SHORELINE_DIFF_ADDED_FG_RGB: (u8, u8, u8) = (127, 214, 160); // #7FD6A0
|
||||
pub const SHORELINE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (30, 44, 37); // #1E2C25
|
||||
pub const SHORELINE_DIFF_DELETED_FG_RGB: (u8, u8, u8) = (255, 154, 171); // #FF9AAB
|
||||
pub const SHORELINE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (51, 32, 42); // #33202A
|
||||
|
||||
// Shoreline Light — the same system on warm paper.
|
||||
pub const SHORELINE_LIGHT_SURFACE_RGB: (u8, u8, u8) = (245, 240, 233); // #F5F0E9
|
||||
pub const SHORELINE_LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 229, 224); // #ECE5E0
|
||||
pub const SHORELINE_LIGHT_ELEVATED_RGB: (u8, u8, u8) = (255, 252, 247); // #FFFCF7
|
||||
pub const SHORELINE_LIGHT_COMPOSER_RGB: (u8, u8, u8) = (251, 247, 241); // #FBF7F1
|
||||
pub const SHORELINE_LIGHT_CHROME_RGB: (u8, u8, u8) = (237, 231, 224); // #EDE7E0
|
||||
pub const SHORELINE_LIGHT_SELECTION_RGB: (u8, u8, u8) = (201, 224, 231); // #C9E0E7 pale ocean selection
|
||||
pub const SHORELINE_LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (48, 40, 50); // #302832
|
||||
pub const SHORELINE_LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (74, 65, 76); // #4A414C
|
||||
pub const SHORELINE_LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (107, 96, 110); // #6B606E
|
||||
pub const SHORELINE_LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (117, 112, 128); // #757080
|
||||
pub const SHORELINE_LIGHT_TEXT_DIM_RGB: (u8, u8, u8) = (138, 130, 144); // #8A8290
|
||||
pub const SHORELINE_LIGHT_BORDER_RGB: (u8, u8, u8) = (215, 206, 213); // #D7CED5
|
||||
pub const SHORELINE_LIGHT_ACTION_RGB: (u8, u8, u8) = (0, 102, 132); // #006684 deep ocean blue on warm paper
|
||||
pub const SHORELINE_LIGHT_LIVE_RGB: (u8, u8, u8) = (31, 122, 107); // #1F7A6B
|
||||
pub const SHORELINE_LIGHT_HUMAN_RGB: (u8, u8, u8) = (122, 85, 0); // #7A5500 Signal Gold at AA on paper
|
||||
pub const SHORELINE_LIGHT_ERROR_RGB: (u8, u8, u8) = (180, 35, 90); // #B4235A
|
||||
pub const SHORELINE_LIGHT_ERROR_SURFACE_RGB: (u8, u8, u8) = (251, 228, 236); // #FBE4EC
|
||||
pub const SHORELINE_LIGHT_ERROR_TEXT_RGB: (u8, u8, u8) = (122, 18, 53); // #7A1235
|
||||
pub const SHORELINE_LIGHT_WARNING_RGB: (u8, u8, u8) = (143, 85, 20); // #8F5514
|
||||
pub const SHORELINE_LIGHT_SUCCESS_RGB: (u8, u8, u8) = (47, 107, 58); // #2F6B3A
|
||||
pub const SHORELINE_LIGHT_MODE_AGENT_RGB: (u8, u8, u8) = (22, 54, 178); // #1636B2 distinct from LIGHT action
|
||||
pub const SHORELINE_LIGHT_MODE_YOLO_RGB: (u8, u8, u8) = (181, 35, 90); // #B5235A
|
||||
pub const SHORELINE_LIGHT_MODE_PLAN_RGB: (u8, u8, u8) = (52, 92, 128); // #345C80
|
||||
pub const SHORELINE_LIGHT_MODE_OPERATE_RGB: (u8, u8, u8) = (112, 71, 184); // #7047B8
|
||||
pub const SHORELINE_LIGHT_DIFF_ADDED_FG_RGB: (u8, u8, u8) = (31, 107, 69); // #1F6B45
|
||||
pub const SHORELINE_LIGHT_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (226, 242, 230); // #E2F2E6
|
||||
pub const SHORELINE_LIGHT_DIFF_DELETED_FG_RGB: (u8, u8, u8) = (168, 40, 80); // #A82850
|
||||
pub const SHORELINE_LIGHT_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (251, 230, 236); // #FBE6EC
|
||||
|
||||
// Semantic colors
|
||||
pub const BORDER_COLOR_RGB: (u8, u8, u8) = WHALE_BORDER_RGB;
|
||||
+10
-10
@@ -7,24 +7,24 @@ use super::detect::{
|
||||
BackgroundSource, PaletteMode, palette_mode_for_background,
|
||||
palette_mode_from_apple_interface_style, resolve_terminal_background,
|
||||
};
|
||||
use super::ids::{SELECTABLE_THEMES, ThemeId, normalize_hex_rgb_color, normalize_theme_name};
|
||||
use super::rgb::*;
|
||||
use super::themes::{
|
||||
CATPPUCCIN_MOCHA_UI_THEME, GRAYSCALE_UI_THEME, LIGHT_UI_THEME, MATRIX_UI_THEME,
|
||||
SELECTABLE_THEMES, SOLARIZED_LIGHT_UI_THEME, TERMINAL_UI_THEME, TOKYO_NIGHT_UI_THEME, ThemeId,
|
||||
UI_THEME, UiTheme, normalize_hex_rgb_color, normalize_theme_name, parse_hex_rgb_color,
|
||||
theme_label_for_mode, ui_theme_from_settings,
|
||||
SOLARIZED_LIGHT_UI_THEME, TERMINAL_UI_THEME, TOKYO_NIGHT_UI_THEME, UI_THEME, UiTheme,
|
||||
parse_hex_rgb_color, theme_label_for_mode, ui_theme_from_settings,
|
||||
};
|
||||
use super::tokens::{
|
||||
ACCENT_REASONING_LIVE, DIFF_ADDED, DIFF_ADDED_BG, DIFF_DELETED_BG, GRAYSCALE_BORDER,
|
||||
GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING, GRAYSCALE_SURFACE,
|
||||
GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, LIGHT_ACTION, LIGHT_BORDER,
|
||||
LIGHT_DANGER, LIGHT_ELEVATED, LIGHT_HUMAN, LIGHT_LIVE, LIGHT_PANEL, LIGHT_REASONING,
|
||||
LIGHT_SELECTION_BG, LIGHT_SUCCESS_FG, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_BODY_RGB,
|
||||
LIGHT_TEXT_HINT, LIGHT_WARNING, MODE_AGENT, MODE_PLAN, MODE_YOLO, SELECTION_BG,
|
||||
SOLARIZED_PANEL, SOLARIZED_SURFACE, SOLARIZED_TEXT_BODY, SOLARIZED_TEXT_HINT, STATUS_ERROR,
|
||||
STATUS_WARNING, SURFACE_ERROR, SURFACE_REASONING, SURFACE_REASONING_TINT, SURFACE_TOOL_ACTIVE,
|
||||
TEXT_BODY, TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, WHALE_ACTION, WHALE_BG, WHALE_ERROR,
|
||||
WHALE_HUMAN, WHALE_LIVE, WHALE_PANEL, WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB,
|
||||
WHALE_TEXT_BODY_RGB,
|
||||
LIGHT_SELECTION_BG, LIGHT_SUCCESS_FG, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT,
|
||||
LIGHT_WARNING, MODE_AGENT, MODE_PLAN, MODE_YOLO, SELECTION_BG, SOLARIZED_PANEL,
|
||||
SOLARIZED_SURFACE, SOLARIZED_TEXT_BODY, SOLARIZED_TEXT_HINT, STATUS_ERROR, STATUS_WARNING,
|
||||
SURFACE_ERROR, SURFACE_REASONING, SURFACE_REASONING_TINT, SURFACE_TOOL_ACTIVE, TEXT_BODY,
|
||||
TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, WHALE_ACTION, WHALE_BG, WHALE_ERROR, WHALE_HUMAN,
|
||||
WHALE_LIVE, WHALE_PANEL,
|
||||
};
|
||||
use ratatui::style::Color;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use ratatui::style::Color;
|
||||
|
||||
use super::detect::PaletteMode;
|
||||
use super::ids::{ThemeId, parse_hex_rgb};
|
||||
use super::rgb::*;
|
||||
use super::tokens::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1318,133 +1320,7 @@ pub const UWU_UI_THEME: UiTheme = UiTheme {
|
||||
tool_failed: Color::Rgb(0xff, 0x6b, 0x8a),
|
||||
};
|
||||
|
||||
/// Stable identifiers for the named themes the user can select. `System`
|
||||
/// defers to `PaletteMode::detect()` (terminal-driven dark/light). Each
|
||||
/// dark/light id resolves to a single fixed `UiTheme`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ThemeId {
|
||||
System,
|
||||
Terminal,
|
||||
Shoreline,
|
||||
ShorelineLight,
|
||||
Underwater,
|
||||
UnderwaterRetro,
|
||||
Whale,
|
||||
WhaleLight,
|
||||
Grayscale,
|
||||
CatppuccinMocha,
|
||||
TokyoNight,
|
||||
Dracula,
|
||||
GruvboxDark,
|
||||
Claude,
|
||||
Matrix,
|
||||
SolarizedLight,
|
||||
Uwu,
|
||||
}
|
||||
|
||||
impl ThemeId {
|
||||
/// Parse a settings string (`"system"`, `"dark"`, `"catppuccin-mocha"`, …).
|
||||
/// Accepts a few aliases (`"whale"` for dark, `"light"` for whale-light)
|
||||
/// so existing config files keep working. Case-insensitive.
|
||||
#[must_use]
|
||||
pub fn from_name(value: &str) -> Option<Self> {
|
||||
match normalize_theme_name(value)? {
|
||||
"system" => Some(Self::System),
|
||||
"terminal" => Some(Self::Terminal),
|
||||
"underwater" | "deepsea" => Some(Self::Underwater),
|
||||
"underwater-retro" | "retro" => Some(Self::UnderwaterRetro),
|
||||
"shoreline" => Some(Self::Shoreline),
|
||||
"shoreline-light" => Some(Self::ShorelineLight),
|
||||
"dark" => Some(Self::Whale),
|
||||
"light" => Some(Self::WhaleLight),
|
||||
"grayscale" => Some(Self::Grayscale),
|
||||
"catppuccin-mocha" => Some(Self::CatppuccinMocha),
|
||||
"tokyo-night" => Some(Self::TokyoNight),
|
||||
"dracula" => Some(Self::Dracula),
|
||||
"gruvbox-dark" => Some(Self::GruvboxDark),
|
||||
"claude" => Some(Self::Claude),
|
||||
"matrix" => Some(Self::Matrix),
|
||||
"solarized-light" => Some(Self::SolarizedLight),
|
||||
"uwu" => Some(Self::Uwu),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical settings string (lowercase, dash-separated). Round-trips
|
||||
/// through `from_name`.
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Shoreline => "shoreline",
|
||||
Self::ShorelineLight => "shoreline-light",
|
||||
Self::Underwater => "underwater",
|
||||
Self::UnderwaterRetro => "underwater-retro",
|
||||
Self::Whale => "dark",
|
||||
Self::WhaleLight => "light",
|
||||
Self::Grayscale => "grayscale",
|
||||
Self::CatppuccinMocha => "catppuccin-mocha",
|
||||
Self::TokyoNight => "tokyo-night",
|
||||
Self::Dracula => "dracula",
|
||||
Self::GruvboxDark => "gruvbox-dark",
|
||||
Self::Claude => "claude",
|
||||
Self::Matrix => "matrix",
|
||||
Self::SolarizedLight => "solarized-light",
|
||||
Self::Uwu => "uwu",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label for picker rows.
|
||||
#[must_use]
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Terminal => "Terminal",
|
||||
Self::Shoreline => "Shoreline",
|
||||
Self::ShorelineLight => "Shoreline Light",
|
||||
Self::Underwater => "Underwater",
|
||||
Self::UnderwaterRetro => "Underwater Retro",
|
||||
Self::Whale => "Blue Stage",
|
||||
Self::WhaleLight => "Blue Stage Light",
|
||||
Self::Grayscale => "Grayscale",
|
||||
Self::CatppuccinMocha => "Catppuccin Mocha",
|
||||
Self::TokyoNight => "Tokyo Night",
|
||||
Self::Dracula => "Dracula",
|
||||
Self::GruvboxDark => "Gruvbox Dark",
|
||||
Self::Claude => "Claude",
|
||||
Self::Matrix => "Matrix",
|
||||
Self::SolarizedLight => "Solarized Light",
|
||||
Self::Uwu => "Uwu",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short tagline for picker rows.
|
||||
#[must_use]
|
||||
pub const fn tagline(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Follow terminal background (COLORFGBG / macOS appearance)",
|
||||
Self::Terminal => "Inherit terminal colors fully (transparent surfaces, ANSI accents)",
|
||||
Self::Shoreline => "Warm charcoal, one restrained blue — the desktop client's palette",
|
||||
Self::ShorelineLight => "Shoreline on warm paper — the desktop client's light mode",
|
||||
Self::Underwater => "The painted ocean field: ombre water, ambient life, the whale",
|
||||
Self::UnderwaterRetro => "Flat phosphor-teal ocean: the legacy deepsea look, no ombre",
|
||||
Self::Whale => "Stage black, action blue, and one Signal Gold human beacon",
|
||||
Self::WhaleLight => "Paper, cobalt action, and one Signal Gold human beacon",
|
||||
Self::Grayscale => "Color-minimal high contrast",
|
||||
Self::CatppuccinMocha => "Soft pastels on warm dark",
|
||||
Self::TokyoNight => "Deep blue/violet night palette",
|
||||
Self::Dracula => "Classic high-contrast purple",
|
||||
Self::GruvboxDark => "Vintage warm earth tones",
|
||||
Self::Claude => "Warm navy & coral",
|
||||
Self::Matrix => "The Matrix films inspired theme",
|
||||
Self::SolarizedLight => {
|
||||
"Solarized light — Light, calming palette on warm ivory — easy on the eyes"
|
||||
}
|
||||
Self::Uwu => "Soft kawaii night — sakura, mint, and peach",
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve to a concrete `UiTheme`. For `System` this consults
|
||||
/// `PaletteMode::detect()` exactly once and returns the corresponding
|
||||
/// dark/light theme — callers that want to live-track terminal background
|
||||
@@ -1473,27 +1349,6 @@ impl ThemeId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Themes shown in the `/theme` picker, in display order.
|
||||
pub const SELECTABLE_THEMES: &[ThemeId] = &[
|
||||
ThemeId::System,
|
||||
ThemeId::Terminal,
|
||||
ThemeId::Shoreline,
|
||||
ThemeId::ShorelineLight,
|
||||
ThemeId::Underwater,
|
||||
ThemeId::UnderwaterRetro,
|
||||
ThemeId::Whale,
|
||||
ThemeId::WhaleLight,
|
||||
ThemeId::Grayscale,
|
||||
ThemeId::CatppuccinMocha,
|
||||
ThemeId::TokyoNight,
|
||||
ThemeId::Dracula,
|
||||
ThemeId::GruvboxDark,
|
||||
ThemeId::Claude,
|
||||
ThemeId::Matrix,
|
||||
ThemeId::SolarizedLight,
|
||||
ThemeId::Uwu,
|
||||
];
|
||||
|
||||
impl UiTheme {
|
||||
/// Let the terminal own the ordinary shell while retaining explicit
|
||||
/// semantic surfaces (selection, elevation, errors, diffs, and status).
|
||||
@@ -1534,31 +1389,6 @@ impl UiTheme {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn normalize_theme_name(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "auto" | "system" | "default" => Some("system"),
|
||||
"terminal" | "term" | "transparent" | "follow-terminal" | "inherit" => Some("terminal"),
|
||||
"underwater" | "deepsea" | "deep-sea" | "ocean" | "ombre" => Some("underwater"),
|
||||
"underwater-retro" | "retro" | "uw-retro" => Some("underwater-retro"),
|
||||
"shoreline" | "warm" | "charcoal" => Some("shoreline"),
|
||||
"shoreline-light" | "paper" => Some("shoreline-light"),
|
||||
"dark" | "whale" | "whale-dark" => Some("dark"),
|
||||
"light" | "whale-light" => Some("light"),
|
||||
"grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white"
|
||||
| "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"),
|
||||
"catppuccin-mocha" | "catppuccin" | "mocha" => Some("catppuccin-mocha"),
|
||||
"tokyo-night" | "tokyonight" | "tokyo" => Some("tokyo-night"),
|
||||
"dracula" => Some("dracula"),
|
||||
"gruvbox-dark" | "gruvbox" => Some("gruvbox-dark"),
|
||||
"claude" => Some("claude"),
|
||||
"matrix" | "hacker" => Some("matrix"),
|
||||
"solarized-light" | "solarized" => Some("solarized-light"),
|
||||
"uwu" | "owo" | "kawaii" => Some("uwu"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn theme_label_for_mode(mode: PaletteMode) -> &'static str {
|
||||
match mode {
|
||||
@@ -1579,22 +1409,10 @@ pub fn ui_theme_from_settings(theme: &str, background_color: Option<&str>) -> Ui
|
||||
|
||||
#[must_use]
|
||||
pub fn parse_hex_rgb_color(value: &str) -> Option<Color> {
|
||||
let hex = value.trim().strip_prefix('#').unwrap_or(value.trim());
|
||||
if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
let (r, g, b) = parse_hex_rgb(value)?;
|
||||
Some(Color::Rgb(r, g, b))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn normalize_hex_rgb_color(value: &str) -> Option<String> {
|
||||
hex_rgb_string(parse_hex_rgb_color(value)?)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn hex_rgb_string(color: Color) -> Option<String> {
|
||||
let Color::Rgb(r, g, b) = color else {
|
||||
@@ -1606,6 +1424,7 @@ pub fn hex_rgb_string(color: Color) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ids::{SELECTABLE_THEMES, normalize_theme_name};
|
||||
|
||||
#[test]
|
||||
fn whale_refresh_keeps_theme_ids_and_default_compatibility() {
|
||||
|
||||
@@ -1,108 +1,8 @@
|
||||
//! Color token constants — RGB tuples and semantic `Color` roles.
|
||||
//! Semantic `Color` roles built from the RGB tokens in `rgb`.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
// Codewhale Whale palette. Semantic colors own state, never decoration alone.
|
||||
pub const WHALE_BG_RGB: (u8, u8, u8) = (7, 12, 29); // #070C1D Ink field
|
||||
pub const WHALE_CHROME_RGB: (u8, u8, u8) = (12, 21, 49); // #0C1531 Navy chrome
|
||||
pub const WHALE_PANEL_RGB: (u8, u8, u8) = (16, 28, 64); // #101C40 Panel surface
|
||||
pub const WHALE_COMPOSER_RGB: (u8, u8, u8) = (20, 35, 82); // #142352 Stage plate (brand navy)
|
||||
pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (26, 44, 99); // #1A2C63 Raised
|
||||
pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (35, 45, 65); // #232D41 Quiet selection surface
|
||||
pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory
|
||||
pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (182, 192, 212); // #B6C0D4
|
||||
pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (147, 160, 184); // #93A0B8
|
||||
pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 153, 179); // #8A99B3 — AA hint on the ink field
|
||||
/// Blue Stage grammar: the interaction blue is the primary accent and the
|
||||
/// Info ink; it has exactly one name. Signal Gold is reserved for the whale
|
||||
/// mark and human-attention roles ([`WHALE_HUMAN_RGB`]), never for general
|
||||
/// interaction.
|
||||
pub const WHALE_ACTION_RGB: (u8, u8, u8) = (106, 166, 220); // #6AA6DC Ombre sky — owns interaction on dark
|
||||
// No TUI consumer yet; the web reads these through scripts/export-design-tokens.py
|
||||
// (`--whale-cobalt`, `--whale-ice`), so they are not dead.
|
||||
pub const WHALE_COBALT_RGB: (u8, u8, u8) = (21, 53, 178); // #1535B2 Ombre cobalt — light-mode action
|
||||
pub const WHALE_ICE_RGB: (u8, u8, u8) = (221, 238, 249); // #DDEEF9 Ice — structure on dark
|
||||
pub const WHALE_CYAN_RGB: (u8, u8, u8) = (120, 188, 232); // #78BCE8 Cyan — bounded accents only
|
||||
pub const WHALE_ACCENT_SECONDARY_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam
|
||||
// Whale Teams (Signal Cut, CWC 2026-08-15) identity accents. These two brand
|
||||
// palette entries have no semantic role in the TUI; they exist only so the
|
||||
// Harbor and Echo whale marks carry their exact CWC accent instead of a
|
||||
// borrowed state color. Never use them for status, mode, or permission.
|
||||
pub const WHALE_BRAND_ORANGE_RGB: (u8, u8, u8) = (255, 138, 61); // #FF8A3D Harbor mooring loop
|
||||
pub const WHALE_BRAND_MAGENTA_RGB: (u8, u8, u8) = (240, 78, 184); // #F04EB8 Echo sonar ticks
|
||||
pub const WHALE_HUMAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold
|
||||
pub const WHALE_WORKING_GREEN_RGB: (u8, u8, u8) = (155, 214, 111); // #9BD66F Working Green
|
||||
pub const WHALE_ERROR_RGB: (u8, u8, u8) = (255, 134, 178); // #FF86B2 Rose danger
|
||||
pub const WHALE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 156, 194); // #FF9CC2
|
||||
pub const WHALE_ERROR_SURFACE_RGB: (u8, u8, u8) = (43, 21, 34); // #2B1522
|
||||
pub const WHALE_ERROR_BORDER_RGB: (u8, u8, u8) = WHALE_ERROR_RGB;
|
||||
pub const WHALE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 219, 232); // #FFDBE8
|
||||
pub const WHALE_WARNING_RGB: (u8, u8, u8) = (255, 122, 89); // #FF7A59 Coral warning
|
||||
pub const WHALE_SUCCESS_RGB: (u8, u8, u8) = WHALE_WORKING_GREEN_RGB; // completed / verified
|
||||
pub const WHALE_BORDER_RGB: (u8, u8, u8) = (42, 63, 114); // #2A3F72, sky at 25% on stage
|
||||
pub const WHALE_REASONING_TEXT_RGB: (u8, u8, u8) = (224, 153, 72); // #E09948
|
||||
pub const WHALE_REASONING_SURFACE_RGB: (u8, u8, u8) = (42, 34, 24); // #2A2218
|
||||
pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (22, 36, 74); // #16244A
|
||||
|
||||
// Solarized Light palette RGB tuples
|
||||
pub const SOLARIZED_BASE03_RGB: (u8, u8, u8) = (0x00, 0x2B, 0x36);
|
||||
pub const SOLARIZED_BASE02_RGB: (u8, u8, u8) = (0x07, 0x36, 0x42);
|
||||
pub const SOLARIZED_BASE01_RGB: (u8, u8, u8) = (0x52, 0x66, 0x6D); // lifted for 4.5:1 muted text
|
||||
pub const SOLARIZED_BASE00_RGB: (u8, u8, u8) = (0x65, 0x7B, 0x83);
|
||||
pub const SOLARIZED_BASE0_RGB: (u8, u8, u8) = (0x72, 0x81, 0x82); // lifted for 3:1 hint text
|
||||
pub const SOLARIZED_BASE1_RGB: (u8, u8, u8) = (0x93, 0xA1, 0xA1);
|
||||
pub const SOLARIZED_BASE2_RGB: (u8, u8, u8) = (0xEE, 0xE8, 0xD5);
|
||||
pub const SOLARIZED_BASE3_RGB: (u8, u8, u8) = (0xFD, 0xF6, 0xE3);
|
||||
pub const SOLARIZED_YELLOW_RGB: (u8, u8, u8) = (0xB4, 0x88, 0x00); // lifted for 3:1 on ivory
|
||||
pub const SOLARIZED_ORANGE_RGB: (u8, u8, u8) = (0xCB, 0x4B, 0x16);
|
||||
pub const SOLARIZED_RED_RGB: (u8, u8, u8) = (0xDC, 0x32, 0x2F);
|
||||
pub const SOLARIZED_BLUE_RGB: (u8, u8, u8) = (0x26, 0x8B, 0xD2);
|
||||
pub const SOLARIZED_CYAN_RGB: (u8, u8, u8) = (0x29, 0x9E, 0x96); // lifted for 3:1 on ivory
|
||||
pub const SOLARIZED_GREEN_RGB: (u8, u8, u8) = (0x7F, 0x92, 0x00); // lifted for 3:1 on ivory/diff bg
|
||||
pub const SOLARIZED_PANEL_RGB: (u8, u8, u8) = (0xF0, 0xED, 0xE7);
|
||||
pub const SOLARIZED_ELEVATED_RGB: (u8, u8, u8) = (0xE4, 0xDF, 0xCF);
|
||||
pub const SOLARIZED_SELECT_RGB: (u8, u8, u8) = (0xD6, 0xD2, 0xC9);
|
||||
|
||||
pub const WHALE_DIFF_ADDED_RGB: (u8, u8, u8) = (87, 199, 133); // #57C785
|
||||
pub const WHALE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (18, 42, 34); // #122A22
|
||||
// Raw colors that are remapped by equality must remain distinct across roles.
|
||||
// These stay in the same perceptual families as action, danger, and human asks
|
||||
// while preserving mode identity for Terminal and community themes.
|
||||
pub const WHALE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (52, 24, 39); // #341827
|
||||
pub const WHALE_MODE_AGENT_RGB: (u8, u8, u8) = (126, 180, 232); // #7EB4E8
|
||||
pub const WHALE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 112, 160); // #FF70A0
|
||||
pub const WHALE_MODE_PLAN_RGB: (u8, u8, u8) = (185, 220, 236); // #B9DCEC Structural Ice
|
||||
pub const WHALE_MODE_OPERATE_RGB: (u8, u8, u8) = (173, 136, 255); // #AD88FF
|
||||
pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = WHALE_ACCENT_SECONDARY_RGB;
|
||||
pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = WHALE_ERROR_RGB;
|
||||
pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = WHALE_TEXT_SOFT_RGB;
|
||||
pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (15, 26, 58); // #0F1A3A
|
||||
pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (24, 44, 94); // #182C5E
|
||||
|
||||
pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (244, 247, 251); // #F4F7FB
|
||||
pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (255, 253, 248); // #FFFDF8
|
||||
pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (232, 238, 248); // #E8EEF8
|
||||
pub const LIGHT_REASONING_RGB: (u8, u8, u8) = (255, 246, 214); // #FFF6D6
|
||||
pub const LIGHT_SUCCESS_RGB: (u8, u8, u8) = (223, 247, 231); // #DFF7E7
|
||||
pub const LIGHT_SUCCESS_FG_RGB: (u8, u8, u8) = (20, 118, 61); // #14763D, readable on every light surface
|
||||
pub const LIGHT_ERROR_RGB: (u8, u8, u8) = (252, 235, 242); // #FCEBF2
|
||||
pub const LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (20, 33, 58); // #14213A
|
||||
pub const LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (91, 103, 128); // #5B6780
|
||||
pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (95, 107, 129); // #5F6B81
|
||||
pub const LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (69, 81, 104); // #455168
|
||||
pub const LIGHT_ACTION_RGB: (u8, u8, u8) = (21, 53, 178); // #1535B2 Ombre cobalt
|
||||
pub const LIGHT_LIVE_RGB: (u8, u8, u8) = (8, 118, 109); // #08766D
|
||||
pub const LIGHT_HUMAN_RGB: (u8, u8, u8) = (122, 85, 0); // #7A5500
|
||||
pub const LIGHT_WARNING_RGB: (u8, u8, u8) = (169, 71, 36); // #A94724
|
||||
pub const LIGHT_DANGER_RGB: (u8, u8, u8) = (180, 35, 90); // #B4235A
|
||||
// Mode shades stay in their parent semantic families while remaining distinct
|
||||
// inputs to the render backend. A `Cell` carries only a `Color`, so reusing the
|
||||
// exact action/human/danger value here would erase the mode role before ANSI
|
||||
// adaptation can preserve it.
|
||||
pub const LIGHT_MODE_AGENT_RGB: (u8, u8, u8) = (22, 54, 178); // #1636B2
|
||||
pub const LIGHT_MODE_YOLO_RGB: (u8, u8, u8) = (181, 35, 90); // #B5235A
|
||||
pub const LIGHT_MODE_PLAN_RGB: (u8, u8, u8) = (52, 92, 128); // #345C80 Structural steel-blue
|
||||
pub const LIGHT_OPERATE_RGB: (u8, u8, u8) = (112, 71, 184); // #7047B8
|
||||
use super::rgb::*;
|
||||
|
||||
// Solarized Light palette colors
|
||||
pub const SOLARIZED_TEXT_DIM: Color = Color::Rgb(
|
||||
@@ -197,112 +97,6 @@ pub const SOLARIZED_COMPOSER: Color = Color::Rgb(
|
||||
SOLARIZED_PANEL_RGB.2,
|
||||
);
|
||||
|
||||
pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (169, 184, 207); // #A9B8CF
|
||||
pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (238, 246, 255); // #EEF6FF
|
||||
pub const GRAYSCALE_SURFACE_RGB: (u8, u8, u8) = (10, 10, 10); // #0A0A0A
|
||||
pub const GRAYSCALE_PANEL_RGB: (u8, u8, u8) = (18, 18, 18); // #121212
|
||||
pub const GRAYSCALE_ELEVATED_RGB: (u8, u8, u8) = (31, 31, 31); // #1F1F1F
|
||||
pub const GRAYSCALE_REASONING_RGB: (u8, u8, u8) = (38, 38, 38); // #262626
|
||||
pub const GRAYSCALE_SUCCESS_RGB: (u8, u8, u8) = (34, 34, 34); // #222222
|
||||
pub const GRAYSCALE_ERROR_RGB: (u8, u8, u8) = (42, 42, 42); // #2A2A2A
|
||||
pub const GRAYSCALE_TEXT_BODY_RGB: (u8, u8, u8) = (236, 236, 236); // #ECECEC
|
||||
pub const GRAYSCALE_TEXT_MUTED_RGB: (u8, u8, u8) = (180, 180, 180); // #B4B4B4
|
||||
pub const GRAYSCALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 138, 138); // #8A8A8A
|
||||
pub const GRAYSCALE_TEXT_SOFT_RGB: (u8, u8, u8) = (220, 220, 220); // #DCDCDC
|
||||
pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); // #606060
|
||||
pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62); // #3E3E3E
|
||||
|
||||
pub const MATRIX_SURFACE_RGB: (u8, u8, u8) = (0, 10, 0); // #000A00
|
||||
pub const MATRIX_ELEVATED_RGB: (u8, u8, u8) = (0, 51, 0); // #003300
|
||||
pub const MATRIX_SELECTION_RGB: (u8, u8, u8) = (0, 51, 0); // #003300
|
||||
pub const MATRIX_TEXT_BODY_RGB: (u8, u8, u8) = (136, 255, 136); // #88FF88
|
||||
pub const MATRIX_TEXT_MUTED_RGB: (u8, u8, u8) = (0, 169, 0); // #00A900, lifted for 4.5:1
|
||||
pub const MATRIX_TEXT_HINT_RGB: (u8, u8, u8) = (0, 135, 0); // #008700, lifted for 3:1
|
||||
pub const MATRIX_TEXT_SOFT_RGB: (u8, u8, u8) = (221, 255, 221); // #DDFFDD
|
||||
pub const MATRIX_TEXT_DIM_RGB: (u8, u8, u8) = (0, 108, 0); // #006C00, lifted for 3:1
|
||||
pub const MATRIX_BORDER_RGB: (u8, u8, u8) = (0, 204, 0); // #00CC00
|
||||
|
||||
// Shoreline — the TUI's charcoal theme.
|
||||
//
|
||||
// Warm charcoal ground and warm paper sheet, one restrained blue, and the
|
||||
// whale's ivory ink on both sides. This is the charcoal alternative to the
|
||||
// terminal's navy "Underwater" default in 0.10.0. The
|
||||
// 0.10.0 action pair uses glacial blue on charcoal and deep ocean blue on
|
||||
// paper. The GPUI desktop does not paint these values: its theme is the
|
||||
// separate `GPUI_*` / `GPUI_LIGHT_*` set below.
|
||||
//
|
||||
// Every pair audited by `contrast::theme_contrast_violations` clears its
|
||||
// floor: body roles clear 4.5:1 on all four surfaces, hint/dim and the
|
||||
// status roles clear 3:1.
|
||||
pub const SHORELINE_SURFACE_RGB: (u8, u8, u8) = (33, 31, 35); // #211F23 warm charcoal field
|
||||
pub const SHORELINE_PANEL_RGB: (u8, u8, u8) = (43, 40, 46); // #2B282E raised plate
|
||||
pub const SHORELINE_ELEVATED_RGB: (u8, u8, u8) = (53, 49, 58); // #35313A
|
||||
pub const SHORELINE_COMPOSER_RGB: (u8, u8, u8) = (43, 40, 46); // #2B282E
|
||||
pub const SHORELINE_CHROME_RGB: (u8, u8, u8) = (26, 24, 28); // #1A181C recessed chrome
|
||||
pub const SHORELINE_SELECTION_RGB: (u8, u8, u8) = (44, 70, 84); // #2C4654 deep ocean selection
|
||||
pub const SHORELINE_TEXT_BODY_RGB: (u8, u8, u8) = (242, 236, 229); // #F2ECE5
|
||||
pub const SHORELINE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 210, 220); // #D9D2DC
|
||||
pub const SHORELINE_TEXT_MUTED_RGB: (u8, u8, u8) = (176, 167, 178); // #B0A7B2
|
||||
pub const SHORELINE_TEXT_HINT_RGB: (u8, u8, u8) = (154, 145, 159); // #9A919F
|
||||
pub const SHORELINE_TEXT_DIM_RGB: (u8, u8, u8) = (126, 117, 131); // #7E7583
|
||||
pub const SHORELINE_BORDER_RGB: (u8, u8, u8) = (73, 66, 77); // #49424D
|
||||
pub const SHORELINE_ACTION_RGB: (u8, u8, u8) = (103, 184, 214); // #67B8D6 glacial blue — action and whale identity
|
||||
pub const SHORELINE_LIVE_RGB: (u8, u8, u8) = (127, 214, 198); // #7FD6C6 the live lane
|
||||
pub const SHORELINE_HUMAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold, the human lane
|
||||
pub const SHORELINE_ERROR_RGB: (u8, u8, u8) = (255, 143, 168); // #FF8FA8
|
||||
pub const SHORELINE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 166, 187); // #FFA6BB
|
||||
pub const SHORELINE_ERROR_SURFACE_RGB: (u8, u8, u8) = (58, 32, 41); // #3A2029
|
||||
pub const SHORELINE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 220, 230); // #FFDCE6
|
||||
pub const SHORELINE_WARNING_RGB: (u8, u8, u8) = (240, 168, 104); // #F0A868
|
||||
pub const SHORELINE_SUCCESS_RGB: (u8, u8, u8) = (163, 217, 119); // #A3D977
|
||||
pub const SHORELINE_STATUS_WORKING_RGB: (u8, u8, u8) = SHORELINE_LIVE_RGB;
|
||||
// Mode badges keep the shipped mode ramp: these four are proof-tested distinct
|
||||
// from one another and from every semantic lane, and a mode badge is identity,
|
||||
// not decoration. `mode_agent` must differ from `accent_primary` or
|
||||
// `adapt::theme_semantic_foreground_role` resolves the action lane to a mode
|
||||
// role (`ModeAgent`) before it ever reaches `Action`.
|
||||
pub const SHORELINE_MODE_AGENT_RGB: (u8, u8, u8) = (126, 180, 232); // #7EB4E8
|
||||
pub const SHORELINE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 112, 160); // #FF70A0
|
||||
pub const SHORELINE_MODE_PLAN_RGB: (u8, u8, u8) = (185, 220, 236); // #B9DCEC
|
||||
pub const SHORELINE_MODE_OPERATE_RGB: (u8, u8, u8) = (173, 136, 255); // #AD88FF
|
||||
pub const SHORELINE_DIFF_ADDED_FG_RGB: (u8, u8, u8) = (127, 214, 160); // #7FD6A0
|
||||
pub const SHORELINE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (30, 44, 37); // #1E2C25
|
||||
pub const SHORELINE_DIFF_DELETED_FG_RGB: (u8, u8, u8) = (255, 154, 171); // #FF9AAB
|
||||
pub const SHORELINE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (51, 32, 42); // #33202A
|
||||
|
||||
// Shoreline Light — the same system on warm paper.
|
||||
pub const SHORELINE_LIGHT_SURFACE_RGB: (u8, u8, u8) = (245, 240, 233); // #F5F0E9
|
||||
pub const SHORELINE_LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 229, 224); // #ECE5E0
|
||||
pub const SHORELINE_LIGHT_ELEVATED_RGB: (u8, u8, u8) = (255, 252, 247); // #FFFCF7
|
||||
pub const SHORELINE_LIGHT_COMPOSER_RGB: (u8, u8, u8) = (251, 247, 241); // #FBF7F1
|
||||
pub const SHORELINE_LIGHT_CHROME_RGB: (u8, u8, u8) = (237, 231, 224); // #EDE7E0
|
||||
pub const SHORELINE_LIGHT_SELECTION_RGB: (u8, u8, u8) = (201, 224, 231); // #C9E0E7 pale ocean selection
|
||||
pub const SHORELINE_LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (48, 40, 50); // #302832
|
||||
pub const SHORELINE_LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (74, 65, 76); // #4A414C
|
||||
pub const SHORELINE_LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (107, 96, 110); // #6B606E
|
||||
pub const SHORELINE_LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (117, 112, 128); // #757080
|
||||
pub const SHORELINE_LIGHT_TEXT_DIM_RGB: (u8, u8, u8) = (138, 130, 144); // #8A8290
|
||||
pub const SHORELINE_LIGHT_BORDER_RGB: (u8, u8, u8) = (215, 206, 213); // #D7CED5
|
||||
pub const SHORELINE_LIGHT_ACTION_RGB: (u8, u8, u8) = (0, 102, 132); // #006684 deep ocean blue on warm paper
|
||||
pub const SHORELINE_LIGHT_LIVE_RGB: (u8, u8, u8) = (31, 122, 107); // #1F7A6B
|
||||
pub const SHORELINE_LIGHT_HUMAN_RGB: (u8, u8, u8) = (122, 85, 0); // #7A5500 Signal Gold at AA on paper
|
||||
pub const SHORELINE_LIGHT_ERROR_RGB: (u8, u8, u8) = (180, 35, 90); // #B4235A
|
||||
pub const SHORELINE_LIGHT_ERROR_SURFACE_RGB: (u8, u8, u8) = (251, 228, 236); // #FBE4EC
|
||||
pub const SHORELINE_LIGHT_ERROR_TEXT_RGB: (u8, u8, u8) = (122, 18, 53); // #7A1235
|
||||
pub const SHORELINE_LIGHT_WARNING_RGB: (u8, u8, u8) = (143, 85, 20); // #8F5514
|
||||
pub const SHORELINE_LIGHT_SUCCESS_RGB: (u8, u8, u8) = (47, 107, 58); // #2F6B3A
|
||||
pub const SHORELINE_LIGHT_MODE_AGENT_RGB: (u8, u8, u8) = (22, 54, 178); // #1636B2 distinct from LIGHT action
|
||||
pub const SHORELINE_LIGHT_MODE_YOLO_RGB: (u8, u8, u8) = (181, 35, 90); // #B5235A
|
||||
pub const SHORELINE_LIGHT_MODE_PLAN_RGB: (u8, u8, u8) = (52, 92, 128); // #345C80
|
||||
pub const SHORELINE_LIGHT_MODE_OPERATE_RGB: (u8, u8, u8) = (112, 71, 184); // #7047B8
|
||||
pub const SHORELINE_LIGHT_DIFF_ADDED_FG_RGB: (u8, u8, u8) = (31, 107, 69); // #1F6B45
|
||||
pub const SHORELINE_LIGHT_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (226, 242, 230); // #E2F2E6
|
||||
pub const SHORELINE_LIGHT_DIFF_DELETED_FG_RGB: (u8, u8, u8) = (168, 40, 80); // #A82850
|
||||
pub const SHORELINE_LIGHT_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (251, 230, 236); // #FBE6EC
|
||||
|
||||
// Semantic colors
|
||||
pub const BORDER_COLOR_RGB: (u8, u8, u8) = WHALE_BORDER_RGB;
|
||||
|
||||
pub const WHALE_ACTION: Color =
|
||||
Color::Rgb(WHALE_ACTION_RGB.0, WHALE_ACTION_RGB.1, WHALE_ACTION_RGB.2);
|
||||
pub const WHALE_LIVE: Color = Color::Rgb(
|
||||
|
||||
@@ -7,9 +7,9 @@ use std::path::{Path, PathBuf};
|
||||
use ratatui::style::Color;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ids::{USER_THEME_PREFIX, normalize_theme_setting, normalize_user_theme_selector};
|
||||
use super::{ThemeId, UiTheme, parse_hex_rgb_color};
|
||||
|
||||
pub const USER_THEME_PREFIX: &str = "custom:";
|
||||
pub const USER_THEME_SCHEMA: &str = include_str!("../assets/user-theme.schema.json");
|
||||
const MAX_USER_THEME_BYTES: u64 = 64 * 1024;
|
||||
|
||||
@@ -80,34 +80,6 @@ pub fn user_theme_schema_json() -> &'static str {
|
||||
USER_THEME_SCHEMA
|
||||
}
|
||||
|
||||
pub fn normalize_user_theme_selector(value: &str) -> Result<Option<String>, String> {
|
||||
let trimmed = value.trim();
|
||||
let Some(slug) = trimmed.strip_prefix(USER_THEME_PREFIX) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let slug = slug.trim().to_ascii_lowercase();
|
||||
if slug.is_empty()
|
||||
|| slug.len() > 64
|
||||
|| !slug
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
|
||||
{
|
||||
return Err(
|
||||
"custom theme names must be 1-64 ASCII letters, digits, '-' or '_'".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(Some(format!("{USER_THEME_PREFIX}{slug}")))
|
||||
}
|
||||
|
||||
pub fn normalize_theme_setting(value: &str) -> Result<String, String> {
|
||||
if let Some(id) = ThemeId::from_name(value) {
|
||||
return Ok(id.name().to_string());
|
||||
}
|
||||
normalize_user_theme_selector(value)?.ok_or_else(|| {
|
||||
format!("invalid theme '{value}'; use a compiled theme name or custom:<name>")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_theme_setting(
|
||||
value: &str,
|
||||
background_color: Option<&str>,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "codewhale-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Headless Codewhale runtime, split out of the terminal UI one module at a time; no terminal UI dependency"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
# Test helpers (process-global state guards, snapshots, diff renders) that
|
||||
# the TUI's tests use. It only *adds* `pub` items; it changes no behavior.
|
||||
# The TUI enables it from [dev-dependencies] only, so `-p codewhale-cli` /
|
||||
# `-p codewhale-tui` release builds never see it; a `--workspace
|
||||
# --all-features` build does, which is harmless for helpers.
|
||||
#
|
||||
# `test-thread-scoped-state` is the one behavior switch: it makes the retry
|
||||
# banner and the provider-wide `Retry-After` pause per-thread so concurrent
|
||||
# tests cannot see each other's HTTP retries (see `retry_status`). It takes
|
||||
# effect only with debug assertions, so no optimized build ever carries it,
|
||||
# even under `--all-features`.
|
||||
[features]
|
||||
test-support = []
|
||||
test-thread-scoped-state = []
|
||||
|
||||
[lib]
|
||||
name = "codewhale_runtime"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# This crate must never depend on codewhale-tui, codewhale-cli, ratatui,
|
||||
# crossterm, ansi-to-tui or a terminal component kit.
|
||||
# scripts/check-command-crate-boundaries.py enforces that with `cargo tree -p`.
|
||||
[dependencies]
|
||||
ahash = "0.8"
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-config = { path = "../config", version = "0.10.0" }
|
||||
codewhale-core = { path = "../core", version = "0.10.0" }
|
||||
codewhale-memory = { path = "../memory", version = "0.10.0" }
|
||||
codewhale-models = { path = "../models", version = "0.10.0" }
|
||||
fd-lock = "4.0.4"
|
||||
lru = "0.18"
|
||||
regex = "1.11"
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
codewhale-models = { path = "../models", version = "0.10.0", features = [
|
||||
"test-support",
|
||||
] }
|
||||
tempfile.workspace = true
|
||||
wait-timeout = "0.2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -124,7 +124,6 @@ impl PressureLevel {
|
||||
/// Unused by the engine today; kept as the pressure-level counterpart of
|
||||
/// `ContextBudget::should_compact` so both live next to their thresholds.
|
||||
#[must_use]
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub const fn suggests_compaction(self) -> bool {
|
||||
matches!(self, PressureLevel::High | PressureLevel::Critical)
|
||||
}
|
||||
@@ -247,7 +246,6 @@ impl ContextBudget {
|
||||
/// Whether current input has reached the compaction trigger and compaction
|
||||
/// should be suggested.
|
||||
#[must_use]
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub fn should_compact(&self) -> bool {
|
||||
self.window_tokens > 0 && self.input_tokens >= self.compaction_trigger_tokens
|
||||
}
|
||||
@@ -255,7 +253,6 @@ impl ContextBudget {
|
||||
/// Whether another `additional_input_tokens` of input would fit within the
|
||||
/// available budget (i.e. not exceed the reserved boundary).
|
||||
#[must_use]
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub fn fits_additional(&self, additional_input_tokens: u64) -> bool {
|
||||
additional_input_tokens <= self.available_input_tokens
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub type FastHashMap<K, V> = HashMap<K, V, ahash::RandomState>;
|
||||
pub type FastHashSet<T> = HashSet<T, ahash::RandomState>;
|
||||
@@ -74,7 +74,6 @@ pub enum GoalRunStatus {
|
||||
/// verifier confirmed — see `GoalGate`).
|
||||
Completed,
|
||||
/// The model reported it is blocked and needs the user.
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
Blocked,
|
||||
}
|
||||
|
||||
@@ -134,7 +133,6 @@ impl GoalBudget {
|
||||
}
|
||||
|
||||
/// A token budget for telemetry/UI. It never pauses an unbounded goal.
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub const fn with_token_budget(token_budget: u64) -> Self {
|
||||
Self {
|
||||
token_budget: Some(token_budget),
|
||||
@@ -304,7 +302,6 @@ pub const fn token_budget_exhausted(_progress: GoalProgress, _budget: GoalBudget
|
||||
/// Whether a stop reason represents success (Completed) vs. an early/forced exit.
|
||||
/// Useful for the UI/status projection (#2666 token/time visibility).
|
||||
#[must_use]
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub fn is_success(reason: StopReason) -> bool {
|
||||
matches!(reason, StopReason::Completed)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
|
||||
pub fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
|
||||
hex_bytes(Sha256::digest(bytes.as_ref()))
|
||||
}
|
||||
|
||||
pub(crate) fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
|
||||
pub fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
|
||||
let bytes = bytes.as_ref();
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
@@ -0,0 +1,190 @@
|
||||
//! The one port through which runtime code causes terminal side effects.
|
||||
//!
|
||||
//! The terminal UI is the only owner of the terminal: it alone toggles raw
|
||||
//! mode and writes escape sequences. Runtime code (tools, the engine, the
|
||||
//! runtime API) never links a terminal library; when it needs a terminal
|
||||
//! effect it asks the installed [`HostTerminal`]. The composition root
|
||||
//! installs the TUI's implementation once at startup, for every host it
|
||||
//! launches today (interactive TUI, `exec`, the runtime API server), so
|
||||
//! behavior is unchanged from when the calls were inline.
|
||||
//!
|
||||
//! With nothing installed every method is a no-op. That is the intended
|
||||
//! future shape for stdio hosts (ACP, MCP server, app-server), whose stdout is
|
||||
//! a JSON-RPC stream that must never receive terminal bytes.
|
||||
//!
|
||||
//! Known limitations: the install is process-global and first-wins; a
|
||||
//! process cannot swap hosts after startup, and tests that need the real
|
||||
//! terminal behavior must go through the TUI's own implementation.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use codewhale_config::notifications::NotificationsConfig;
|
||||
|
||||
/// Terminal side effects the runtime may request from its host.
|
||||
pub trait HostTerminal: Send + Sync {
|
||||
/// Leave raw mode if it is on, so an interactive child process sees a
|
||||
/// cooked terminal. Returns whether raw mode was on (and must be resumed).
|
||||
fn suspend_raw_mode(&self) -> bool;
|
||||
|
||||
/// Re-enter raw mode after [`HostTerminal::suspend_raw_mode`] returned
|
||||
/// `true`.
|
||||
fn resume_raw_mode(&self);
|
||||
|
||||
/// Deliver a model-authored notification (the `notify` tool) now,
|
||||
/// honoring the installed notification settings. Returns the delivery
|
||||
/// receipt the tool reports to the model.
|
||||
fn notify_model(&self, title: &str, body: Option<&str>) -> &'static str;
|
||||
|
||||
/// Record whether the terminal has focus; attention policy reads it.
|
||||
fn set_terminal_focused(&self, focused: bool);
|
||||
|
||||
/// Install the process-wide notification method, category gate, sound
|
||||
/// policy and attention condition from `[notifications]`.
|
||||
fn apply_notification_settings(&self, config: &NotificationsConfig);
|
||||
}
|
||||
|
||||
struct NoHostTerminal;
|
||||
|
||||
impl HostTerminal for NoHostTerminal {
|
||||
fn suspend_raw_mode(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn resume_raw_mode(&self) {}
|
||||
|
||||
fn notify_model(&self, _title: &str, _body: Option<&str>) -> &'static str {
|
||||
"notification not sent: no terminal host"
|
||||
}
|
||||
|
||||
fn set_terminal_focused(&self, _focused: bool) {}
|
||||
|
||||
fn apply_notification_settings(&self, _config: &NotificationsConfig) {}
|
||||
}
|
||||
|
||||
static HOST: OnceLock<Box<dyn HostTerminal>> = OnceLock::new();
|
||||
|
||||
/// Install the process's terminal host. The first install wins; later calls
|
||||
/// are ignored and return `false`.
|
||||
pub fn install(host: Box<dyn HostTerminal>) -> bool {
|
||||
HOST.set(host).is_ok()
|
||||
}
|
||||
|
||||
/// The installed terminal host, or a no-op host when none is installed.
|
||||
#[must_use]
|
||||
pub fn host() -> &'static dyn HostTerminal {
|
||||
match HOST.get() {
|
||||
Some(host) => host.as_ref(),
|
||||
None => &NoHostTerminal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw mode suspended for the lifetime of this guard (issue #1690).
|
||||
///
|
||||
/// Created by [`suspend_raw_mode`]; dropping it resumes raw mode only if it
|
||||
/// was on when the guard was created.
|
||||
#[must_use = "raw mode is resumed when the guard drops"]
|
||||
pub struct RawModeSuspension {
|
||||
/// The host that suspended raw mode resumes it.
|
||||
host: &'static dyn HostTerminal,
|
||||
resume: bool,
|
||||
}
|
||||
|
||||
/// Leave raw mode around an interactive child; resume it when the returned
|
||||
/// guard drops, only if it was on to begin with.
|
||||
pub fn suspend_raw_mode() -> RawModeSuspension {
|
||||
suspend_raw_mode_on(host())
|
||||
}
|
||||
|
||||
fn suspend_raw_mode_on(host: &'static dyn HostTerminal) -> RawModeSuspension {
|
||||
RawModeSuspension {
|
||||
host,
|
||||
resume: host.suspend_raw_mode(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RawModeSuspension {
|
||||
fn drop(&mut self) {
|
||||
if self.resume {
|
||||
self.host.resume_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// Records raw-mode calls. Each test owns its own `static` instance, so
|
||||
/// they never share counters.
|
||||
struct CountingHost {
|
||||
raw: AtomicBool,
|
||||
suspends: AtomicUsize,
|
||||
resumes: AtomicUsize,
|
||||
}
|
||||
|
||||
impl CountingHost {
|
||||
const fn new(raw: bool) -> Self {
|
||||
Self {
|
||||
raw: AtomicBool::new(raw),
|
||||
suspends: AtomicUsize::new(0),
|
||||
resumes: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTerminal for CountingHost {
|
||||
fn suspend_raw_mode(&self) -> bool {
|
||||
self.suspends.fetch_add(1, Ordering::SeqCst);
|
||||
self.raw.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn resume_raw_mode(&self) {
|
||||
self.resumes.fetch_add(1, Ordering::SeqCst);
|
||||
self.raw.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn notify_model(&self, _title: &str, _body: Option<&str>) -> &'static str {
|
||||
"unused"
|
||||
}
|
||||
|
||||
fn set_terminal_focused(&self, _focused: bool) {}
|
||||
|
||||
fn apply_notification_settings(&self, _config: &NotificationsConfig) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_host_suspension_is_a_no_op() {
|
||||
// No host is installed in this test binary: suspending reports raw
|
||||
// mode off, so the guard never asks to resume it.
|
||||
let guard = suspend_raw_mode();
|
||||
assert!(!guard.resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_mode_that_was_on_is_resumed_exactly_once_when_the_guard_drops() {
|
||||
static HOST: CountingHost = CountingHost::new(true);
|
||||
let guard = suspend_raw_mode_on(&HOST);
|
||||
assert!(
|
||||
!HOST.raw.load(Ordering::SeqCst),
|
||||
"raw mode is off while suspended"
|
||||
);
|
||||
assert_eq!(HOST.resumes.load(Ordering::SeqCst), 0);
|
||||
drop(guard);
|
||||
assert!(
|
||||
HOST.raw.load(Ordering::SeqCst),
|
||||
"raw mode is back on after the guard"
|
||||
);
|
||||
assert_eq!(HOST.suspends.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(HOST.resumes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_mode_that_was_off_is_never_turned_on_by_the_guard() {
|
||||
static HOST: CountingHost = CountingHost::new(false);
|
||||
drop(suspend_raw_mode_on(&HOST));
|
||||
assert_eq!(HOST.suspends.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(HOST.resumes.load(Ordering::SeqCst), 0);
|
||||
assert!(!HOST.raw.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Codewhale runtime: the headless half of the runtime/TUI split.
|
||||
//!
|
||||
//! Modules move here from `crates/tui/src` with `git mv`, in the order
|
||||
//! `docs/design/TUI_DECONSTRUCTION.md` records. The TUI reaches them through
|
||||
//! one path-alias block in its `lib.rs`, so moved code keeps its
|
||||
//! `crate::<module>` paths on both sides until the split deletes the alias.
|
||||
//!
|
||||
//! This crate never depends on the terminal UI: no `codewhale-tui`, ratatui
|
||||
//! or crossterm (`scripts/check-command-crate-boundaries.py`).
|
||||
|
||||
// Copied verbatim from the TUI crate root: moved code relies on it.
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
|
||||
pub mod context_budget;
|
||||
pub mod continual_harness;
|
||||
pub mod elapsed;
|
||||
pub mod fast_hash;
|
||||
pub mod goal_loop;
|
||||
pub mod hashing;
|
||||
pub mod host_terminal;
|
||||
pub mod llm_response_cache;
|
||||
pub mod media_originals;
|
||||
pub mod model_context;
|
||||
pub mod native_memory;
|
||||
pub mod prompt_zones;
|
||||
pub mod regex_cache;
|
||||
pub mod retry_status;
|
||||
pub mod safe_label;
|
||||
pub mod session_tree;
|
||||
pub mod skill_state;
|
||||
pub mod sleep_guard;
|
||||
pub mod tool_history_repair;
|
||||
pub mod workspace_discovery;
|
||||
@@ -12,11 +12,11 @@ const DEFAULT_CAPACITY: usize = 256;
|
||||
|
||||
static RESPONSE_CACHE: OnceLock<ResponseCache> = OnceLock::new();
|
||||
|
||||
pub(crate) fn response_cache() -> &'static ResponseCache {
|
||||
pub fn response_cache() -> &'static ResponseCache {
|
||||
RESPONSE_CACHE.get_or_init(ResponseCache::new)
|
||||
}
|
||||
|
||||
pub(crate) fn request_is_cacheable(request: &MessageRequest) -> bool {
|
||||
pub fn request_is_cacheable(request: &MessageRequest) -> bool {
|
||||
request.stream != Some(true)
|
||||
&& request.tools.as_ref().is_none_or(Vec::is_empty)
|
||||
&& request.tool_choice.is_none()
|
||||
@@ -24,7 +24,7 @@ pub(crate) fn request_is_cacheable(request: &MessageRequest) -> bool {
|
||||
&& request.top_p.is_none_or(|top_p| top_p == 1.0)
|
||||
}
|
||||
|
||||
pub(crate) struct ResponseCache {
|
||||
pub struct ResponseCache {
|
||||
inner: Mutex<LruCache<[u8; 32], MessageResponse>>,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ impl ResponseCache {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn make_key(
|
||||
pub fn make_key(
|
||||
provider: &str,
|
||||
base_url: &str,
|
||||
path_suffix: Option<&str>,
|
||||
@@ -55,7 +55,7 @@ impl ResponseCache {
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, key: &[u8; 32]) -> Option<MessageResponse> {
|
||||
pub fn get(&self, key: &[u8; 32]) -> Option<MessageResponse> {
|
||||
let mut cache = self.inner.lock().ok()?;
|
||||
cache.get(key).cloned().map(|mut response| {
|
||||
response.usage = Usage::default();
|
||||
@@ -63,7 +63,7 @@ impl ResponseCache {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn put(&self, key: [u8; 32], value: MessageResponse) {
|
||||
pub fn put(&self, key: [u8; 32], value: MessageResponse) {
|
||||
if let Ok(mut cache) = self.inner.lock() {
|
||||
cache.put(key, value);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
-4
@@ -7,7 +7,7 @@ use codewhale_models::SystemBlock;
|
||||
use super::fragment::{FragmentId, FragmentRender, FragmentRole, ModelContextFragment};
|
||||
|
||||
/// Incremental render of WorldState against a previous snapshot.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct WorldStateDiff {
|
||||
/// Fragments whose content hash changed (or are new).
|
||||
@@ -41,13 +41,16 @@ impl WorldState {
|
||||
render
|
||||
}
|
||||
|
||||
// Exported from codewhale-runtime, so clippy now asks for `is_empty`;
|
||||
// the move adds no API (runtime split, behavior-free).
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.fragments.len()
|
||||
}
|
||||
|
||||
/// Full render of every fragment in stable `FragmentId` order.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub fn render_full(&self) -> String {
|
||||
self.fragments
|
||||
@@ -59,7 +62,7 @@ impl WorldState {
|
||||
|
||||
/// Diff against a previous WorldState. Unchanged fragments are retained
|
||||
/// (listed, not reinjected into `updated`).
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub fn render_diff(&self, previous: Option<&WorldState>) -> WorldStateDiff {
|
||||
let Some(previous) = previous else {
|
||||
@@ -220,7 +223,7 @@ impl WorldStateSnapshot {
|
||||
}
|
||||
|
||||
/// Flat text fallback for callers that still expect `SystemPrompt::Text`.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub fn render_text(&self) -> String {
|
||||
let world = self.world_state.render_full();
|
||||
File renamed without changes.
@@ -315,7 +315,6 @@ pub struct TurnScratch {
|
||||
pub user_message: Option<Message>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
impl TurnScratch {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -338,7 +337,6 @@ impl TurnScratch {
|
||||
///
|
||||
/// **Phase 1 scaffolding** — not yet wired into the engine request path.
|
||||
/// Currently the engine continues to use `MessageRequest` directly.
|
||||
#[expect(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreeZoneRequest<'a> {
|
||||
pub prefix: &'a FrozenPrefix,
|
||||
@@ -357,7 +355,6 @@ pub struct ThreeZoneRequest<'a> {
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
impl<'a> ThreeZoneRequest<'a> {
|
||||
/// Build the full message list from system prompt, append-log messages,
|
||||
/// and scratch user message. The returned vector is serialized as the
|
||||
@@ -8,7 +8,7 @@ const DEFAULT_USER_REGEX_CACHE_CAPACITY: usize = 64;
|
||||
|
||||
static USER_REGEX_CACHE: OnceLock<UserRegexCache> = OnceLock::new();
|
||||
|
||||
pub(crate) fn compile_user_regex(pattern: &str) -> Result<Regex, regex::Error> {
|
||||
pub fn compile_user_regex(pattern: &str) -> Result<Regex, regex::Error> {
|
||||
user_regex_cache().compile(pattern)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
//! Read-side caveat (0.9.4): the renderer this module was written for was
|
||||
//! the legacy footer's retry banner, which went with `FooterWidget`. The
|
||||
//! *producer* — `client::send_with_retry` — is still live and still records
|
||||
//! every retry, and `client`'s own tests read it back through [`snapshot`].
|
||||
//! every retry, and `client`'s own tests read it back through `snapshot`.
|
||||
//! The read surface (`snapshot`, the countdown, the banner fields) is
|
||||
//! therefore test-gated (`#[cfg(test)]` where possible,
|
||||
//! `cfg_attr(not(test))` allows where prod constructs); a renderer
|
||||
//! therefore test-gated (`cfg(any(test, feature = "test-support"))` where
|
||||
//! possible, so the TUI's tests reach it through the `test-support`
|
||||
//! feature; `cfg_attr(not(test))` allows where prod constructs); a renderer
|
||||
//! restores it by dropping the gates. Give the banner a renderer, or
|
||||
//! delete the producer too — but not half of it.
|
||||
//!
|
||||
@@ -52,19 +53,14 @@ pub enum RetryState {
|
||||
/// starts. `since` records when the row was set so a future polish
|
||||
/// pass can age it out automatically; today the engine clears it on
|
||||
/// `TurnStarted`.
|
||||
Failed {
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
reason: String,
|
||||
#[expect(dead_code)]
|
||||
since: Instant,
|
||||
},
|
||||
Failed { reason: String, since: Instant },
|
||||
}
|
||||
|
||||
impl RetryState {
|
||||
/// Wall-clock seconds remaining on the active banner, or `None` if
|
||||
/// not active. Saturates at zero — the renderer should treat any
|
||||
/// negative remaining as "firing now".
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub fn seconds_remaining(&self) -> Option<u64> {
|
||||
match self {
|
||||
@@ -81,7 +77,7 @@ impl RetryState {
|
||||
/// Whether the failure row should still be shown. Mirrors the
|
||||
/// "until next turn" rule in the issue spec; the engine clears it
|
||||
/// explicitly via [`clear`] on `TurnStarted`.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub fn is_failed(&self) -> bool {
|
||||
matches!(self, Self::Failed { .. })
|
||||
@@ -90,7 +86,7 @@ impl RetryState {
|
||||
|
||||
/// Lazy-init the cell on first read so callers don't have to initialize
|
||||
/// process-wide state at boot.
|
||||
#[cfg(not(test))]
|
||||
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
|
||||
fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
static STATE: OnceLock<Mutex<RetryState>> = OnceLock::new();
|
||||
let mut state = STATE
|
||||
@@ -100,7 +96,7 @@ fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
f(&mut state)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
|
||||
fn with_rate_limit<R>(f: impl FnOnce(&mut Option<Instant>) -> R) -> R {
|
||||
static STATE: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
|
||||
let mut state = STATE
|
||||
@@ -119,7 +115,17 @@ fn with_rate_limit<R>(f: impl FnOnce(&mut Option<Instant>) -> R) -> R {
|
||||
/// provider pause into another test's assertions. Scoping by thread removes the
|
||||
/// race at its source rather than asking every future test that happens to
|
||||
/// perform HTTP to remember a lock.
|
||||
#[cfg(test)]
|
||||
///
|
||||
/// This changes behavior (a `Retry-After` pause no longer holds across tokio
|
||||
/// worker threads), so it is *not* part of `test-support`, which only adds
|
||||
/// helpers. The TUI's tests opt in with `test-thread-scoped-state`, and even
|
||||
/// then only a build with debug assertions gets it: an optimized build, e.g.
|
||||
/// `cargo build --release --workspace --all-features`, keeps the process-wide
|
||||
/// pause. Cargo unifies features across one build, so under
|
||||
/// `cargo test --workspace` (or a debug `--all-features` build) the debug
|
||||
/// `codewhale` binary that integration tests spawn gets the per-thread pause
|
||||
/// too; release and optimized binaries never do.
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
#[allow(clippy::type_complexity)]
|
||||
static STATE: OnceLock<Mutex<std::collections::HashMap<std::thread::ThreadId, RetryState>>> =
|
||||
@@ -133,7 +139,7 @@ fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
.or_insert(RetryState::Idle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
fn with_rate_limit<R>(f: impl FnOnce(&mut Option<Instant>) -> R) -> R {
|
||||
#[allow(clippy::type_complexity)]
|
||||
static STATE: OnceLock<
|
||||
@@ -148,7 +154,7 @@ fn with_rate_limit<R>(f: impl FnOnce(&mut Option<Instant>) -> R) -> R {
|
||||
|
||||
/// Read snapshot for renderers. No production renderer exists since the
|
||||
/// legacy footer went away; `client` retry tests are the only readers.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub fn snapshot() -> RetryState {
|
||||
with_state(|state| state.clone())
|
||||
@@ -214,7 +220,7 @@ pub fn clear() {
|
||||
with_state(|state| *state = RetryState::Idle);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn clear_rate_limit() {
|
||||
with_rate_limit(|current| *current = None);
|
||||
}
|
||||
@@ -223,7 +229,7 @@ pub fn clear_rate_limit() {
|
||||
/// parallel runner can't observe a torn read. The guard is exported so
|
||||
/// tests in *other* modules (e.g. footer rendering tests) can hold the
|
||||
/// same lock as the ones in `retry_status::tests`.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static GUARD: Mutex<()> = Mutex::new(());
|
||||
GUARD.lock().unwrap_or_else(|e| e.into_inner())
|
||||
@@ -41,7 +41,7 @@ const FINGERPRINT_HEX_LEN: usize = 12;
|
||||
/// on the allowlist. There is deliberately no constructor that takes
|
||||
/// arbitrary text verbatim.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SafeLabel {
|
||||
pub struct SafeLabel {
|
||||
text: String,
|
||||
redacted: bool,
|
||||
}
|
||||
@@ -50,7 +50,7 @@ impl SafeLabel {
|
||||
/// A generic identifier-shaped value: provider id, route id, reasoning
|
||||
/// tier. Allows `A-Z a-z 0-9 . _ : - + @` and rejects every slash. Model
|
||||
/// ids with a slash must use [`Self::catalog_model`] instead.
|
||||
pub(crate) fn identifier(raw: &str) -> Self {
|
||||
pub fn identifier(raw: &str) -> Self {
|
||||
let trimmed = raw.trim();
|
||||
if identifier_is_allowlisted(trimmed) {
|
||||
Self {
|
||||
@@ -66,7 +66,7 @@ impl SafeLabel {
|
||||
/// id exists in the active local model catalog; a vendor-looking prefix is
|
||||
/// never authority by itself. Non-slash ids retain the generic identifier
|
||||
/// boundary for custom compatible deployments.
|
||||
pub(crate) fn catalog_model(raw: &str) -> Self {
|
||||
pub fn catalog_model(raw: &str) -> Self {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.contains('/') {
|
||||
return Self::identifier(raw);
|
||||
@@ -83,7 +83,7 @@ impl SafeLabel {
|
||||
|
||||
/// A short human phrase: the same allowlist plus spaces, parentheses, and
|
||||
/// commas, for host-supplied presentation labels.
|
||||
pub(crate) fn phrase(raw: &str) -> Self {
|
||||
pub fn phrase(raw: &str) -> Self {
|
||||
let trimmed = raw.trim();
|
||||
if phrase_is_allowlisted(trimmed) {
|
||||
Self {
|
||||
@@ -104,14 +104,14 @@ impl SafeLabel {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_str(&self) -> &str {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// True when the original value failed the allowlist and only its
|
||||
/// fingerprint is being published.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_redacted(&self) -> bool {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn is_redacted(&self) -> bool {
|
||||
self.redacted
|
||||
}
|
||||
}
|
||||
@@ -237,18 +237,18 @@ const REDACTED_PATH: &str = "<path-redacted>";
|
||||
/// are themselves allowlisted — the path, query, fragment, and userinfo are
|
||||
/// never published, because a deployment path can *be* the credential;
|
||||
/// - a path-shaped token (POSIX absolute, `~/`, Windows drive, or anything
|
||||
/// containing a backslash) collapses to [`REDACTED_PATH`];
|
||||
/// containing a backslash) collapses to `REDACTED_PATH`;
|
||||
/// - a token carrying a quote character (`"`, `'`, or a backtick) is replaced
|
||||
/// wholesale: quoted spans are where hostile identifiers hide;
|
||||
/// - anything else must be a short, ordinary word — ASCII alphanumerics plus
|
||||
/// `-`, `_`, `.`, bounded by [`MAX_ERROR_WORD_LEN`] and rejected by
|
||||
/// [`looks_opaque`] — with only a small set of sentence punctuation allowed
|
||||
/// at its edges. Everything else becomes [`REDACTED_WORD`].
|
||||
/// `-`, `_`, `.`, bounded by `MAX_ERROR_WORD_LEN` and rejected by
|
||||
/// `looks_opaque` — with only a small set of sentence punctuation allowed
|
||||
/// at its edges. Everything else becomes `REDACTED_WORD`.
|
||||
///
|
||||
/// The result therefore contains no filesystem path, no URL path, no quoted
|
||||
/// span, no token-shaped run, and no control character, and is truncated to
|
||||
/// [`MAX_ERROR_LEN`].
|
||||
pub(crate) fn safe_error_text(raw: &str) -> String {
|
||||
/// `MAX_ERROR_LEN`.
|
||||
pub fn safe_error_text(raw: &str) -> String {
|
||||
let redacted = codewhale_config::persistence::redact_secrets(raw);
|
||||
let mut out = String::with_capacity(redacted.len().min(MAX_ERROR_LEN));
|
||||
let mut last_was_redacted = false;
|
||||
File renamed without changes.
@@ -67,7 +67,6 @@ impl SkillStateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), expect(dead_code))]
|
||||
pub fn disabled(&self) -> Vec<String> {
|
||||
self.disabled.iter().cloned().collect()
|
||||
}
|
||||
File renamed without changes.
@@ -14,15 +14,15 @@ const CRASH_REPAIR_CONTENT: &str =
|
||||
"Tool call interrupted by process exit; terminal status: crashed_and_repaired.";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ToolRepairReceipt {
|
||||
pub(crate) repaired_call_ids: Vec<String>,
|
||||
pub(crate) duplicate_result_ids: Vec<String>,
|
||||
pub(crate) orphan_result_ids: Vec<String>,
|
||||
pub struct ToolRepairReceipt {
|
||||
pub repaired_call_ids: Vec<String>,
|
||||
pub duplicate_result_ids: Vec<String>,
|
||||
pub orphan_result_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolRepairReceipt {
|
||||
#[must_use]
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.repaired_call_ids.is_empty()
|
||||
&& self.duplicate_result_ids.is_empty()
|
||||
&& self.orphan_result_ids.is_empty()
|
||||
@@ -46,7 +46,7 @@ impl ToolRepairReceipt {
|
||||
/// quarantined by removing them from model-visible history. Every dangling
|
||||
/// call receives a synthetic error result directly after its assistant call
|
||||
/// message. A visible system receipt makes the repair apparent after resume.
|
||||
pub(crate) fn repair_tool_call_pairs(messages: &mut Vec<Message>) -> ToolRepairReceipt {
|
||||
pub fn repair_tool_call_pairs(messages: &mut Vec<Message>) -> ToolRepairReceipt {
|
||||
repair_tool_call_pairs_inner(messages, true)
|
||||
}
|
||||
|
||||
@@ -55,9 +55,7 @@ pub(crate) fn repair_tool_call_pairs(messages: &mut Vec<Message>) -> ToolRepairR
|
||||
/// Anthropic-style APIs interpret a final assistant message as a completion
|
||||
/// prefill. Pair repair must therefore leave the synthetic user tool result as
|
||||
/// the request tail; the durable session-facing path owns the visible receipt.
|
||||
pub(crate) fn repair_tool_call_pairs_for_provider(
|
||||
messages: &mut Vec<Message>,
|
||||
) -> ToolRepairReceipt {
|
||||
pub fn repair_tool_call_pairs_for_provider(messages: &mut Vec<Message>) -> ToolRepairReceipt {
|
||||
repair_tool_call_pairs_inner(messages, false)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::Path;
|
||||
|
||||
/// Directories that must remain discoverable for `@`-mention completion and
|
||||
/// fuzzy file resolution even when excluded by `.gitignore`.
|
||||
pub(crate) const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
|
||||
pub const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
|
||||
|
||||
/// Root-relative directories that are too large or generated to discover
|
||||
/// with gitignore disabled. Exact user-specified paths may still resolve.
|
||||
@@ -31,14 +31,14 @@ const DISCOVERY_EXCLUDED_DIR_NAMES: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Check whether `path` is under a root-relative excluded discovery subtree.
|
||||
pub(crate) fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
|
||||
pub fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
|
||||
DISCOVERY_EXCLUDED_SUBDIRS
|
||||
.iter()
|
||||
.any(|excluded| path.starts_with(walk_root.join(excluded)))
|
||||
}
|
||||
|
||||
/// Filter for walks that turn off gitignore to surface explicit hidden paths.
|
||||
pub(crate) fn should_skip_unignored_discovery_entry(walk_root: &Path, path: &Path) -> bool {
|
||||
pub fn should_skip_unignored_discovery_entry(walk_root: &Path, path: &Path) -> bool {
|
||||
if path == walk_root {
|
||||
return false;
|
||||
}
|
||||
@@ -23,6 +23,18 @@ pub fn strip_ansi_into(s: &str, out: &mut String) {
|
||||
strip_ansi_impl(s, out, false);
|
||||
}
|
||||
|
||||
/// Drop control bytes from a streaming text chunk, keeping printable
|
||||
/// characters, `\n` and `\t`. The terminal UI applies it to every streamed
|
||||
/// chunk before rendering; notification payloads apply it after stripping
|
||||
/// whole escape sequences.
|
||||
#[must_use]
|
||||
pub fn sanitize_stream_chunk(chunk: &str) -> String {
|
||||
chunk
|
||||
.chars()
|
||||
.filter(|c| *c == '\n' || *c == '\t' || !c.is_control())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold,
|
||||
/// underline, reset) pass through untouched so a renderer that understands
|
||||
/// them can paint the output as the tool emitted it. Everything else — OSC
|
||||
|
||||
@@ -44,7 +44,7 @@ cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
Use both or the workspace gate only when the risk genuinely spans both.
|
||||
`scripts/dev-test.sh <area|path> [filter]` prints and runs the fastest
|
||||
targeted invocation for a source path (for example
|
||||
`scripts/dev-test.sh crates/tui/src/elapsed.rs`). It uses `cargo nextest
|
||||
`scripts/dev-test.sh crates/runtime/src/elapsed.rs`). It uses `cargo nextest
|
||||
run` when nextest is installed (`CODEWHALE_DEV_NEXTEST=0` forces libtest)
|
||||
and applies `scripts/dev-cache.sh` so a new worktree gets an isolated
|
||||
Cargo build-dir. For PTY
|
||||
|
||||
@@ -45,6 +45,7 @@ codewhale-palette = { path = "../palette", version = "0.10.0" }
|
||||
codewhale-paths = { path = "../paths", version = "0.10.0" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.10.0" }
|
||||
codewhale-release = { path = "../release", version = "0.10.0" }
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.0" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.10.0" }
|
||||
codewhale-telemetry = { path = "../telemetry", version = "0.10.0" }
|
||||
codewhale-tools = { path = "../tools", version = "0.10.0" }
|
||||
@@ -139,6 +140,10 @@ rusty_alloc-api = { workspace = true, optional = true }
|
||||
codewhale-build-support = { path = "../build-support", version = "0.10.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.0", features = [
|
||||
"test-support",
|
||||
"test-thread-scoped-state",
|
||||
] }
|
||||
codewhale-models = { path = "../models", version = "0.10.0", features = [
|
||||
"test-support",
|
||||
] }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
#[path = "../src/session_tree.rs"]
|
||||
#[allow(dead_code)] // The probe intentionally exercises only the journal hot paths.
|
||||
mod session_tree;
|
||||
|
||||
use codewhale_models::Message;
|
||||
use codewhale_runtime::session_tree;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_tree::SessionJournal;
|
||||
use std::io::Write;
|
||||
|
||||
@@ -16,32 +16,22 @@
|
||||
//! (the registry smoke tests execute every command) and avoids caching
|
||||
//! auth material on [`App`].
|
||||
//!
|
||||
//! ## Recording
|
||||
//!
|
||||
//! Uses platform-specific command-line tools (sox, rec, arecord) to capture
|
||||
//! 16kHz mono 16-bit PCM audio. Records until a silence gap is detected or
|
||||
//! the maximum duration is reached (default 10 s).
|
||||
//! Recording, transcription and the headless dictation cycle live in
|
||||
//! `crate::voice`; this module keeps the slash commands and the capture loop
|
||||
//! that updates the composer while the user speaks.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::commands::CommandResult;
|
||||
use crate::commands::traits::{CommandInfo, RegisterCommand};
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::voice::{
|
||||
is_available, process_voice_control, record_audio, resolve_asr_choice, split_send_suffix,
|
||||
transcribe, transcribe_groq, transcribe_local_whisper,
|
||||
};
|
||||
use codewhale_localization::{MessageId, tr};
|
||||
|
||||
/// Transcription model requested from the provider's chat-completions API.
|
||||
const ASR_MODEL: &str = "mimo-v2.5-asr";
|
||||
const GROQ_ASR_MODEL: &str = "whisper-large-v3-turbo";
|
||||
/// Local whisper binary names to probe (whisper.cpp, faster-whisper, OpenAI whisper).
|
||||
const LOCAL_WHISPER_BINS: &[&str] = &["whisper", "whisper.cpp", "whisper-cpp", "faster-whisper"];
|
||||
/// Model used for the AI-assisted voice-control pipeline.
|
||||
const VOICE_CONTROL_MODEL: &str = "mimo-v2.5";
|
||||
|
||||
pub(in crate::commands) const VOICE_INFO: CommandInfo = CommandInfo {
|
||||
name: "voice",
|
||||
aliases: &["yuyin", "语音"],
|
||||
@@ -97,364 +87,6 @@ impl RegisterCommand for VoiceControlCmd {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recorder detection ----------------------------------------------------
|
||||
|
||||
/// Platform-specific recorder definitions.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Recorder {
|
||||
cmd: &'static str,
|
||||
/// CLI arguments for piping raw 16kHz mono S16_LE PCM to stdout.
|
||||
pipe_args: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn detect_recorder() -> Option<Recorder> {
|
||||
// Operator kill-switch: a headless `serve --http` host has no business
|
||||
// opening a microphone; disabling voice here makes `GET /v1/voice`
|
||||
// report `available: false` and every dictate call fail closed.
|
||||
if std::env::var_os("CODEWHALE_DISABLE_VOICE").is_some() {
|
||||
return None;
|
||||
}
|
||||
let candidates: &[Recorder] = if cfg!(target_os = "macos") {
|
||||
&[
|
||||
Recorder {
|
||||
cmd: "sox",
|
||||
pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
},
|
||||
Recorder {
|
||||
cmd: "rec",
|
||||
pipe_args: &["-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
},
|
||||
]
|
||||
} else if cfg!(target_os = "linux") {
|
||||
&[
|
||||
Recorder {
|
||||
cmd: "arecord",
|
||||
pipe_args: &["-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"],
|
||||
},
|
||||
Recorder {
|
||||
cmd: "sox",
|
||||
pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
},
|
||||
]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&[Recorder {
|
||||
cmd: "sox",
|
||||
pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
}]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.find(|r| {
|
||||
Command::new(r.cmd)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Check whether voice recording is available on this system.
|
||||
pub fn is_available() -> bool {
|
||||
detect_recorder().is_some()
|
||||
}
|
||||
|
||||
// --- WAV encoding ----------------------------------------------------------
|
||||
|
||||
/// Encode raw 16kHz mono S16_LE PCM samples as a WAV buffer.
|
||||
fn encode_wav(samples: &[i16]) -> Vec<u8> {
|
||||
let data_size = (samples.len() * 2) as u32;
|
||||
let sample_rate: u32 = 16000;
|
||||
let mut buf = Vec::with_capacity(44 + data_size as usize);
|
||||
|
||||
// RIFF header
|
||||
buf.extend_from_slice(b"RIFF");
|
||||
buf.extend_from_slice(&(36 + data_size).to_le_bytes());
|
||||
buf.extend_from_slice(b"WAVE");
|
||||
|
||||
// fmt chunk
|
||||
buf.extend_from_slice(b"fmt ");
|
||||
buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
|
||||
buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
buf.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||
buf.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
|
||||
buf.extend_from_slice(&2u16.to_le_bytes()); // block align
|
||||
buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
|
||||
|
||||
// data chunk
|
||||
buf.extend_from_slice(b"data");
|
||||
buf.extend_from_slice(&data_size.to_le_bytes());
|
||||
for &sample in samples {
|
||||
buf.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
// --- Recording -------------------------------------------------------------
|
||||
|
||||
/// Maximum recording duration in seconds before auto-stopping.
|
||||
pub const MAX_RECORD_SECS: u64 = 10;
|
||||
/// Minimum segment duration in seconds to consider as valid speech.
|
||||
const MIN_SEGMENT_SECS: f64 = 0.3;
|
||||
|
||||
/// Record audio from the default microphone.
|
||||
///
|
||||
/// Returns raw 16kHz mono S16_LE PCM samples. Returns `None` if no recorder
|
||||
/// is available, the recording failed, or no speech was detected.
|
||||
fn record_audio() -> Option<(Vec<i16>, Duration)> {
|
||||
let recorder = detect_recorder()?;
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut child = Command::new(recorder.cmd)
|
||||
.args(recorder.pipe_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let stdout = child.stdout.take()?;
|
||||
let mut reader = std::io::BufReader::new(stdout);
|
||||
let mut all_samples: Vec<i16> = Vec::with_capacity(16000 * MAX_RECORD_SECS as usize);
|
||||
|
||||
// Read until timeout or silence
|
||||
let mut buf = [0u8; 320]; // 10ms of 16kHz S16_LE
|
||||
let max_duration = Duration::from_secs(MAX_RECORD_SECS);
|
||||
let mut silence_samples = 0u32;
|
||||
let mut had_speech = false;
|
||||
let speech_threshold: i16 = 500; // RMS-based speech detection threshold
|
||||
let silence_duration_samples = 16000u32; // 1 second of silence to stop
|
||||
|
||||
loop {
|
||||
use std::io::Read;
|
||||
match reader.read_exact(&mut buf) {
|
||||
Ok(()) => {
|
||||
let chunk: Vec<i16> = buf
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.copied()
|
||||
.map(i16::from_le_bytes)
|
||||
.collect();
|
||||
|
||||
// Simple RMS-based VAD
|
||||
let rms = (chunk.iter().map(|&s| (s as f64) * (s as f64)).sum::<f64>()
|
||||
/ chunk.len() as f64)
|
||||
.sqrt();
|
||||
let is_speech = rms > speech_threshold as f64;
|
||||
|
||||
if is_speech {
|
||||
had_speech = true;
|
||||
silence_samples = 0;
|
||||
} else if had_speech {
|
||||
silence_samples += chunk.len() as u32;
|
||||
}
|
||||
|
||||
if had_speech {
|
||||
all_samples.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if start.elapsed() > max_duration {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
if had_speech && silence_samples >= silence_duration_samples {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.wait();
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let min_samples = (MIN_SEGMENT_SECS * 16000.0) as usize;
|
||||
if all_samples.len() < min_samples {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((all_samples, elapsed))
|
||||
}
|
||||
|
||||
// --- Auto-send suffix ------------------------------------------------------
|
||||
|
||||
/// Trailing phrases that mean "submit this" — the human-readable form of
|
||||
/// `SEND_SUFFIX_RE`; keep in sync with the regex when either changes.
|
||||
pub const SEND_PHRASES: &[&str] = &["send it", "发送", "發送"];
|
||||
|
||||
/// Matches an explicit send instruction at the end of transcribed text:
|
||||
/// "send it" (any spacing/case) or 发送/發送, with trailing punctuation.
|
||||
static SEND_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)(?:^|[\s,,.。!!??]+)(?:send\s*it|发送|發送)[\s.。!!??]*$").unwrap()
|
||||
});
|
||||
|
||||
/// Split a transcript into the message remainder and whether it ended with an
|
||||
/// explicit send instruction. `"ship the fix, send it"` → `("ship the fix", true)`.
|
||||
fn split_send_suffix(text: &str) -> (&str, bool) {
|
||||
match SEND_SUFFIX_RE.find(text) {
|
||||
Some(found) => (text[..found.start()].trim(), true),
|
||||
None => (text.trim(), false),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Transcription ---------------------------------------------------------
|
||||
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(data)
|
||||
}
|
||||
|
||||
fn chat_completions_url(base_url: &str) -> String {
|
||||
format!("{}/chat/completions", base_url.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
async fn post_chat_completions(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
mut body: serde_json::Value,
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
crate::client::apply_openrouter_vendor(&mut body, openrouter_vendor);
|
||||
let _inference = crate::client::acquire_remote_control_inference_participant().await;
|
||||
let client = crate::tls::reqwest_client();
|
||||
let resp = client
|
||||
.post(chat_completions_url(base_url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("API returned status {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response: {e}"))
|
||||
}
|
||||
|
||||
/// Send audio to the provider's API for plain transcription.
|
||||
///
|
||||
/// Uses the chat completions endpoint with `input_audio` content blocks.
|
||||
async fn transcribe(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
audio_samples: &[i16],
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
transcribe_with_model(
|
||||
api_key,
|
||||
base_url,
|
||||
audio_samples,
|
||||
ASR_MODEL,
|
||||
openrouter_vendor,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn transcribe_with_model(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
audio_samples: &[i16],
|
||||
model: &str,
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let wav = encode_wav(audio_samples);
|
||||
let data_url = format!("data:audio/wav;base64,{}", base64_encode(&wav));
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": data_url
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"asr_options": {
|
||||
"language": "auto"
|
||||
}
|
||||
});
|
||||
|
||||
let data = post_chat_completions(api_key, base_url, body, openrouter_vendor).await?;
|
||||
data["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok_or_else(|| "no transcription in response".to_string())
|
||||
}
|
||||
|
||||
/// Process audio through the voice-control pipeline: AI-assisted dictation
|
||||
/// that sees the current composer text, mirroring MiMo Code's
|
||||
/// `processVoiceControl`. Used when `/voice-control` is enabled.
|
||||
async fn process_voice_control(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
audio_samples: &[i16],
|
||||
current_text: &str,
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let wav = encode_wav(audio_samples);
|
||||
let data_url = format!("data:audio/wav;base64,{}", base64_encode(&wav));
|
||||
|
||||
let user_context = serde_json::json!({
|
||||
"current_text": current_text,
|
||||
"cursor": "end",
|
||||
});
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": VOICE_CONTROL_MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a voice input assistant. Transcribe the user's speech. Output JSON: {\"text\": \"transcribed text\"}."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": user_context.to_string() },
|
||||
{ "type": "input_audio", "input_audio": { "data": data_url } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"response_format": { "type": "json_object" }
|
||||
});
|
||||
|
||||
let data = post_chat_completions(api_key, base_url, body, openrouter_vendor).await?;
|
||||
let content = data["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "no response content".to_string())?;
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(content)
|
||||
.map_err(|e| format!("failed to parse voice control JSON: {e}"))?;
|
||||
|
||||
parsed["text"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "no text field in voice control response".to_string())
|
||||
}
|
||||
|
||||
// --- Capture orchestration (UI event loop) ---------------------------------
|
||||
|
||||
/// What the UI should do with a finished capture.
|
||||
@@ -466,118 +98,6 @@ pub enum VoiceCaptureOutcome {
|
||||
Send(String),
|
||||
}
|
||||
|
||||
/// Detect best free ASR for this host — local whisper > Groq free > provider fallback.
|
||||
/// Works on macOS (brew install whisper-cpp), Windows (whisper.cpp binary),
|
||||
/// Linux (apt), and HarmonyOS (falls back to cloud).
|
||||
fn detect_free_asr() -> &'static str {
|
||||
for bin in LOCAL_WHISPER_BINS {
|
||||
if Command::new(bin)
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
{
|
||||
return "local-whisper";
|
||||
}
|
||||
}
|
||||
if std::env::var("GROQ_API_KEY").is_ok_and(|v| !v.trim().is_empty()) {
|
||||
return "groq";
|
||||
}
|
||||
"provider"
|
||||
}
|
||||
|
||||
/// Transcribe via local whisper.cpp (free, offline, cross-platform).
|
||||
///
|
||||
/// The whole body is synchronous — temp-file I/O plus `Command::output()`,
|
||||
/// which blocks for the entire subprocess run — so it runs on the blocking
|
||||
/// pool rather than a Tokio worker (blocking-call convention, #6149).
|
||||
async fn transcribe_local_whisper(audio_samples: &[i16]) -> Result<String, String> {
|
||||
let wav = encode_wav(audio_samples);
|
||||
tokio::task::spawn_blocking(move || transcribe_local_whisper_blocking(&wav))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn transcribe_local_whisper_blocking(wav: &[u8]) -> Result<String, String> {
|
||||
let tmp = std::env::temp_dir().join(format!("cw-voice-{}.wav", std::process::id()));
|
||||
std::fs::write(&tmp, wav).map_err(|e| e.to_string())?;
|
||||
// Try each local binary until one succeeds; whisper.cpp outputs to stdout or file.
|
||||
for bin in LOCAL_WHISPER_BINS {
|
||||
let output = Command::new(bin)
|
||||
.arg(tmp.to_string_lossy().as_ref())
|
||||
.arg("--model")
|
||||
.arg("tiny")
|
||||
.arg("--language")
|
||||
.arg("auto")
|
||||
.arg("--output-txt")
|
||||
.output();
|
||||
if let Ok(out) = output
|
||||
&& out.status.success()
|
||||
{
|
||||
let txt = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if !txt.is_empty() {
|
||||
return Ok(txt);
|
||||
}
|
||||
// Some builds write to .txt sidecar
|
||||
let sidecar = tmp.with_extension("txt");
|
||||
if let Ok(s) = std::fs::read_to_string(&sidecar) {
|
||||
let _ = std::fs::remove_file(&sidecar);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if !s.trim().is_empty() {
|
||||
return Ok(s.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err("local whisper not available".into())
|
||||
}
|
||||
|
||||
/// Transcribe via Groq Whisper large-v3-turbo (free tier, ~$0.04/hr, fast).
|
||||
/// Groq is NOT a full CodeWhale provider yet — this is a direct ASR call
|
||||
/// using `GROQ_API_KEY` only (no provider setup needed). Uses the same
|
||||
/// chat-completions `input_audio` path as Xiaomi so no `multipart` feature.
|
||||
async fn transcribe_groq(audio_samples: &[i16]) -> Result<String, String> {
|
||||
let api_key = std::env::var("GROQ_API_KEY").map_err(|_| "GROQ_API_KEY not set".to_string())?;
|
||||
let base_url = "https://api.groq.com/openai/v1";
|
||||
transcribe_with_model(&api_key, base_url, audio_samples, GROQ_ASR_MODEL, None).await
|
||||
}
|
||||
|
||||
/// Perform a complete record + transcribe cycle with live interim display.
|
||||
///
|
||||
/// Runs in the UI event loop (see [`AppAction::VoiceCapture`]) so provider
|
||||
/// credentials come from the live [`Config`] rather than state cached on
|
||||
/// [`App`]. Recording happens on a blocking thread; transcription uses the
|
||||
/// shared async HTTP client. Every failure path returns a localized message
|
||||
/// so callers can surface it as a status line.
|
||||
/// Resolve ASR model/provider preference.
|
||||
/// Priority: explicit config `voice.asr_model` > env `CODEWHALE_ASR_MODEL` > auto-detect (local-whisper > groq > xiaomi).
|
||||
fn resolve_asr_choice(_config: &Config) -> (String, String) {
|
||||
// Check explicit env override first (free, cross-platform)
|
||||
if let Ok(m) = std::env::var("CODEWHALE_ASR_MODEL") {
|
||||
let m = m.trim().to_ascii_lowercase();
|
||||
if m.contains("groq") || m.contains("whisper") {
|
||||
return ("groq".into(), GROQ_ASR_MODEL.into());
|
||||
}
|
||||
if m.contains("local") || m.contains("whisper.cpp") {
|
||||
return ("local-whisper".into(), "tiny".into());
|
||||
}
|
||||
if m.contains("mimo") || m.contains("xiaomi") {
|
||||
return ("provider".into(), ASR_MODEL.into());
|
||||
}
|
||||
}
|
||||
// Auto-detect best free: local whisper (offline, no key) > Groq free tier > Xiaomi ASR (needs key)
|
||||
let free = detect_free_asr();
|
||||
match free {
|
||||
"local-whisper" => ("local-whisper".into(), "tiny".into()),
|
||||
"groq" => ("groq".into(), GROQ_ASR_MODEL.into()),
|
||||
_ => ("provider".into(), ASR_MODEL.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Status line while recording: the localized recording label, the latest
|
||||
/// interim transcript once one exists, and how to stop. The capture is awaited
|
||||
/// on the UI loop, so no key can end it — `record_audio` stops after a second
|
||||
@@ -591,6 +111,13 @@ fn recording_status(locale: codewhale_localization::Locale, interim: Option<&str
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a complete record + transcribe cycle with live interim display.
|
||||
///
|
||||
/// Runs in the UI event loop (see [`AppAction::VoiceCapture`]) so provider
|
||||
/// credentials come from the live [`Config`] rather than state cached on
|
||||
/// [`App`]. Recording happens on a blocking thread; transcription uses the
|
||||
/// shared async HTTP client. Every failure path returns a localized message
|
||||
/// so callers can surface it as a status line.
|
||||
pub async fn capture_and_transcribe(
|
||||
app: &mut App,
|
||||
config: &Config,
|
||||
@@ -763,179 +290,6 @@ pub async fn capture_and_transcribe(
|
||||
Ok(VoiceCaptureOutcome::Insert(clean.to_string()))
|
||||
}
|
||||
|
||||
// --- Headless capture (HTTP/native-client path) ----------------------------
|
||||
|
||||
/// What a headless dictation should do with the finished transcript.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DictateMode {
|
||||
/// Transcribe and return the text for insertion into the composer.
|
||||
Insert,
|
||||
/// Transcribe, then apply the "send it" / 发送 suffix contract. The
|
||||
/// outcome's `send` flag tells the client to submit; a bare send
|
||||
/// instruction yields empty `text` so the client submits its own draft.
|
||||
Send,
|
||||
/// AI-assisted dictation that sees the client's composer text — the
|
||||
/// `/voice-control` pipeline. Only provider ASR can see context; free
|
||||
/// ASR kinds degrade to plain transcription with `assisted: false`.
|
||||
Control(String),
|
||||
}
|
||||
|
||||
/// Machine-readable failure for the headless path so HTTP clients can
|
||||
/// localize by `reason` rather than parsing message text.
|
||||
#[derive(Debug)]
|
||||
pub enum DictateError {
|
||||
/// No supported recorder binary on this host.
|
||||
NoRecorder,
|
||||
/// Recording produced no usable speech segment.
|
||||
NoSpeech,
|
||||
/// The selected/fallback ASR needs a provider key that isn't configured.
|
||||
NoProviderAuth,
|
||||
/// ASR request or transcription failed.
|
||||
Transcription(String),
|
||||
}
|
||||
|
||||
impl DictateError {
|
||||
pub fn reason(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NoRecorder => "no_recorder",
|
||||
Self::NoSpeech => "no_speech",
|
||||
Self::NoProviderAuth => "no_provider_auth",
|
||||
Self::Transcription(_) => "transcription_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DictateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoRecorder => write!(f, "no supported voice recorder on this host"),
|
||||
Self::NoSpeech => write!(f, "no speech detected"),
|
||||
Self::NoProviderAuth => {
|
||||
write!(f, "provider ASR requires a configured API key")
|
||||
}
|
||||
Self::Transcription(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one headless record→transcribe cycle.
|
||||
#[derive(Debug)]
|
||||
pub struct DictationOutcome {
|
||||
/// Final transcript (send suffix already stripped for `Send` mode).
|
||||
pub text: String,
|
||||
/// `Send` mode only: the transcript ended with an explicit send phrase.
|
||||
pub send: bool,
|
||||
/// `Control` mode only: the composer context reached the model. False
|
||||
/// when a free ASR kind handled the audio and never saw the context.
|
||||
pub assisted: bool,
|
||||
/// Which ASR backend was selected for this capture.
|
||||
pub asr_kind: String,
|
||||
pub asr_model: String,
|
||||
}
|
||||
|
||||
/// Detected recorder binary name, if any (`"sox"`, `"arecord"`, `"rec"`).
|
||||
pub fn recorder_command() -> Option<&'static str> {
|
||||
detect_recorder().map(|r| r.cmd)
|
||||
}
|
||||
|
||||
/// Resolved ASR selection (`kind`, `model`) for capability reporting.
|
||||
pub fn asr_choice(config: &Config) -> (String, String) {
|
||||
resolve_asr_choice(config)
|
||||
}
|
||||
|
||||
/// One record→transcribe cycle with no UI surface: the HTTP/native-client
|
||||
/// equivalent of [`capture_and_transcribe`]. Recording runs on a blocking
|
||||
/// thread; transcription follows the same ASR dispatch as the TUI —
|
||||
/// explicit `CODEWHALE_ASR_MODEL` > local whisper > Groq > provider —
|
||||
/// but resolves the provider key lazily so free ASR kinds work without
|
||||
/// provider auth.
|
||||
pub async fn dictate_once(
|
||||
config: &Config,
|
||||
mode: DictateMode,
|
||||
) -> Result<DictationOutcome, DictateError> {
|
||||
if !is_available() {
|
||||
return Err(DictateError::NoRecorder);
|
||||
}
|
||||
let (samples, _duration) = tokio::task::spawn_blocking(record_audio)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or(DictateError::NoSpeech)?;
|
||||
|
||||
let (asr_kind, asr_model) = resolve_asr_choice(config);
|
||||
let base_url = config.active_route_base_url();
|
||||
let openrouter_vendor = config
|
||||
.openrouter_vendor()
|
||||
.map_err(|e| DictateError::Transcription(e.to_string()))?;
|
||||
let provider_key = || {
|
||||
config
|
||||
.active_route_api_key()
|
||||
.map_err(|_| DictateError::NoProviderAuth)
|
||||
};
|
||||
|
||||
let mut assisted = false;
|
||||
let text = match asr_kind.as_str() {
|
||||
"local-whisper" => match transcribe_local_whisper(&samples).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => transcribe(
|
||||
&provider_key()?,
|
||||
&base_url,
|
||||
&samples,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
},
|
||||
"groq" => match transcribe_groq(&samples).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => transcribe(
|
||||
&provider_key()?,
|
||||
&base_url,
|
||||
&samples,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
},
|
||||
_ => {
|
||||
let api_key = provider_key()?;
|
||||
match &mode {
|
||||
DictateMode::Control(composer) => {
|
||||
assisted = true;
|
||||
process_voice_control(
|
||||
&api_key,
|
||||
&base_url,
|
||||
&samples,
|
||||
composer,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?
|
||||
}
|
||||
_ => transcribe(&api_key, &base_url, &samples, openrouter_vendor.as_deref())
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let clean = text.trim().to_string();
|
||||
let (text, send) = match mode {
|
||||
DictateMode::Send => {
|
||||
let (remainder, wants_send) = split_send_suffix(&clean);
|
||||
(remainder.to_string(), wants_send)
|
||||
}
|
||||
_ => (clean, false),
|
||||
};
|
||||
Ok(DictationOutcome {
|
||||
text,
|
||||
send,
|
||||
assisted,
|
||||
asr_kind,
|
||||
asr_model,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Command handlers ------------------------------------------------------
|
||||
|
||||
/// Handle the `/voice` command: toggle voice input. Toggling on requests a
|
||||
@@ -1015,107 +369,4 @@ mod tests {
|
||||
recording_status(Locale::De, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_requests_preserve_openrouter_vendor_pin() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"choices": [{ "message": { "content": "{\"text\":\"hello\"}" } }]
|
||||
})))
|
||||
.expect(3)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base_url = format!("{}/v1", server.uri());
|
||||
transcribe(
|
||||
"fixture-key",
|
||||
&base_url,
|
||||
&[0; 16],
|
||||
Some("chutes/region-fixture"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
process_voice_control(
|
||||
"fixture-key",
|
||||
&base_url,
|
||||
&[0; 16],
|
||||
"existing text",
|
||||
Some("chutes/region-fixture"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
transcribe_with_model("fixture-key", &base_url, &[0; 16], GROQ_ASR_MODEL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 3);
|
||||
for request in &requests[..2] {
|
||||
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
||||
assert_eq!(
|
||||
body["provider"],
|
||||
serde_json::json!({"order": ["chutes/region-fixture"], "allow_fallbacks": false})
|
||||
);
|
||||
}
|
||||
let independent: serde_json::Value = serde_json::from_slice(&requests[2].body).unwrap();
|
||||
assert!(independent.get("provider").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_encoding_produces_valid_header() {
|
||||
let samples = vec![0i16; 16000]; // 1 second of silence
|
||||
let wav = encode_wav(&samples);
|
||||
assert_eq!(&wav[0..4], b"RIFF");
|
||||
assert_eq!(&wav[8..12], b"WAVE");
|
||||
assert_eq!(&wav[12..16], b"fmt ");
|
||||
// data size = 16000 * 2 = 32000
|
||||
assert_eq!(&wav[4..8], &(36 + 32000u32).to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_encoding_empty_is_minimal() {
|
||||
let wav = encode_wav(&[]);
|
||||
assert_eq!(wav.len(), 44);
|
||||
assert_eq!(&wav[4..8], &36u32.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_suffix_detected_and_stripped() {
|
||||
assert_eq!(split_send_suffix("send it"), ("", true));
|
||||
assert_eq!(split_send_suffix("Send It!"), ("", true));
|
||||
assert_eq!(split_send_suffix("发送"), ("", true));
|
||||
assert_eq!(split_send_suffix("發送。"), ("", true));
|
||||
assert_eq!(
|
||||
split_send_suffix("ship the fix, send it"),
|
||||
("ship the fix", true)
|
||||
);
|
||||
assert_eq!(
|
||||
split_send_suffix("修复这个问题,发送"),
|
||||
("修复这个问题", true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_suffix_leaves_plain_text_alone() {
|
||||
assert_eq!(split_send_suffix("send it now"), ("send it now", false));
|
||||
assert_eq!(
|
||||
split_send_suffix("帮我发送一封邮件"),
|
||||
("帮我发送一封邮件", false)
|
||||
);
|
||||
assert_eq!(split_send_suffix("发送邮件"), ("发送邮件", false));
|
||||
assert_eq!(
|
||||
split_send_suffix("resend it to the queue"),
|
||||
("resend it to the queue", false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorder_detection_does_not_crash() {
|
||||
// Just verify the function runs without panicking
|
||||
let _ = is_available();
|
||||
}
|
||||
}
|
||||
@@ -15092,7 +15092,7 @@ fn notifications_canonical_condition_overrides_and_unsets_to_legacy() {
|
||||
Some(CompletionSound::Off)
|
||||
);
|
||||
assert_eq!(
|
||||
crate::tui::notifications::settings_projection(&config)
|
||||
crate::notify::settings_projection(&config.notifications_config())
|
||||
.unwrap()
|
||||
.1,
|
||||
std::time::Duration::ZERO
|
||||
|
||||
@@ -7590,15 +7590,14 @@ async fn isolated_runtime_chat_provider_request_contains_no_host_context_or_tool
|
||||
"isolated Chat must expose no provider tools"
|
||||
);
|
||||
// #6517: the engine and the Runtime Chat relay once carried two different
|
||||
// isolated-chat prompts. The engine must send exactly what the relay does.
|
||||
// isolated-chat prompts. The engine sends the one shared constant; the
|
||||
// relay's own test pins `dedicated_chat_system_prompt(None)` to the same
|
||||
// constant, so core tests need no edge into the relay (runtime ratchet).
|
||||
let system = match request.system.as_ref() {
|
||||
Some(SystemPrompt::Text(text)) => text.clone(),
|
||||
other => panic!("isolated Chat should send one text system prompt: {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
system,
|
||||
crate::runtime_chat_relay::dedicated_chat_system_prompt(None)
|
||||
);
|
||||
assert_eq!(system, ISOLATED_CHAT_SYSTEM_PROMPT);
|
||||
let serialized = serde_json::to_string(&request).expect("serialize captured request");
|
||||
assert!(serialized.contains("Say hello."), "{serialized}");
|
||||
assert!(serialized.contains("Attachment omitted"), "{serialized}");
|
||||
@@ -12450,10 +12449,12 @@ fn question_tool_survives_the_tool_surface_in_every_posture() {
|
||||
.any(|tool| tool.name == REQUEST_USER_INPUT_NAME)
|
||||
);
|
||||
|
||||
// Headless exec's default deny list; `exec_agent` tests pin that
|
||||
// `exec_disallowed_tools(None)` produces it.
|
||||
let headless = policy_for_catalog(
|
||||
vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
|
||||
None,
|
||||
crate::exec_agent::exec_disallowed_tools(None),
|
||||
Some(vec![REQUEST_USER_INPUT_NAME.to_string()]),
|
||||
);
|
||||
assert!(
|
||||
!headless
|
||||
|
||||
@@ -328,8 +328,9 @@ pub(crate) async fn run_exec_agent(
|
||||
// and explicit `always` are truthful outside the interactive TUI. With no
|
||||
// focus-reporting channel, fail closed to focused; only explicit `always`
|
||||
// may authorize a headless desktop notification.
|
||||
crate::tui::notifications::set_terminal_focused(true);
|
||||
let _ = crate::tui::notifications::settings(config);
|
||||
let terminal = crate::host_terminal::host();
|
||||
terminal.set_terminal_focused(true);
|
||||
terminal.apply_notification_settings(&config.notifications_config());
|
||||
|
||||
validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
|
||||
let fleet_authority = tool_authority_json
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub(crate) type FastHashMap<K, V> = HashMap<K, V, ahash::RandomState>;
|
||||
pub(crate) type FastHashSet<T> = HashSet<T, ahash::RandomState>;
|
||||
@@ -18,7 +18,7 @@ use codewhale_config::route::{CapabilityState, RouteCapabilities, RouteLimits};
|
||||
|
||||
use crate::config::ApiProvider;
|
||||
use crate::model_registry::{self, ModelMetadata};
|
||||
use crate::tui::model_picker::format_picker_context_window;
|
||||
use crate::utils::format_context_window;
|
||||
|
||||
/// Resolved capability badges for one Fleet route.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -107,10 +107,10 @@ const fn catalog_provenance(source: &CatalogSource) -> &'static str {
|
||||
fn badges_from_route(limits: &RouteLimits, capabilities: &RouteCapabilities) -> Vec<String> {
|
||||
let mut badges = Vec::new();
|
||||
if let Some(context) = limits.context_tokens {
|
||||
badges.push(format!("{} ctx", format_picker_context_window(context)));
|
||||
badges.push(format!("{} ctx", format_context_window(context)));
|
||||
}
|
||||
if let Some(output) = limits.output_tokens {
|
||||
badges.push(format!("{} out", format_picker_context_window(output)));
|
||||
badges.push(format!("{} out", format_context_window(output)));
|
||||
}
|
||||
push_state_badge(&mut badges, capabilities.native_tool_calls, "tools");
|
||||
push_state_badge(&mut badges, capabilities.reasoning, "reasoning");
|
||||
@@ -124,16 +124,10 @@ fn badges_from_route(limits: &RouteLimits, capabilities: &RouteCapabilities) ->
|
||||
fn badges_from_registry(meta: &ModelMetadata) -> Vec<String> {
|
||||
let mut badges = Vec::new();
|
||||
if let Some(context) = meta.context_window {
|
||||
badges.push(format!(
|
||||
"{} ctx",
|
||||
format_picker_context_window(u64::from(context))
|
||||
));
|
||||
badges.push(format!("{} ctx", format_context_window(u64::from(context))));
|
||||
}
|
||||
if let Some(output) = meta.max_output {
|
||||
badges.push(format!(
|
||||
"{} out",
|
||||
format_picker_context_window(u64::from(output))
|
||||
));
|
||||
badges.push(format!("{} out", format_context_window(u64::from(output))));
|
||||
}
|
||||
if meta.supports_reasoning {
|
||||
badges.push("reasoning".to_string());
|
||||
@@ -248,9 +242,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn token_labels_match_picker_vocabulary() {
|
||||
assert_eq!(format_picker_context_window(1_000_000), "1M");
|
||||
assert_eq!(format_picker_context_window(1_050_000), "1.05M");
|
||||
assert_eq!(format_picker_context_window(262_144), "262K");
|
||||
assert_eq!(format_picker_context_window(500), "500");
|
||||
assert_eq!(format_context_window(1_000_000), "1M");
|
||||
assert_eq!(format_context_window(1_050_000), "1.05M");
|
||||
assert_eq!(format_context_window(262_144), "262K");
|
||||
assert_eq!(format_context_window(500), "500");
|
||||
}
|
||||
}
|
||||
+16
-19
@@ -36,9 +36,7 @@ pub mod computer_meter;
|
||||
mod config;
|
||||
pub mod config_keys;
|
||||
mod config_persistence;
|
||||
mod context_budget;
|
||||
mod context_report;
|
||||
mod continual_harness;
|
||||
mod core;
|
||||
mod cost_status;
|
||||
mod credentials;
|
||||
@@ -47,51 +45,42 @@ pub mod dispatch_runner;
|
||||
mod doctor;
|
||||
mod doctor_fix;
|
||||
mod dsh_credentials;
|
||||
mod elapsed;
|
||||
mod error_taxonomy;
|
||||
mod eval;
|
||||
mod external_credentials;
|
||||
mod fast_hash;
|
||||
mod features;
|
||||
mod fleet;
|
||||
mod goal_loop;
|
||||
mod hashing;
|
||||
mod hooks;
|
||||
mod image_attach;
|
||||
mod import_claude;
|
||||
mod integrations;
|
||||
mod lane_control;
|
||||
mod llm_client;
|
||||
mod llm_response_cache;
|
||||
mod local_ollama;
|
||||
mod logging;
|
||||
mod lsp;
|
||||
mod mcp;
|
||||
mod mcp_server;
|
||||
mod media_originals;
|
||||
mod model_context;
|
||||
mod model_inventory;
|
||||
mod model_profile;
|
||||
mod model_registry;
|
||||
mod model_relevance;
|
||||
mod model_routing;
|
||||
mod models_dev_live;
|
||||
mod native_memory;
|
||||
mod network_policy;
|
||||
mod notify;
|
||||
mod oauth;
|
||||
mod operate;
|
||||
mod plugins;
|
||||
mod pricing;
|
||||
mod project_context;
|
||||
mod project_context_cache;
|
||||
mod prompt_zones;
|
||||
mod prompts;
|
||||
mod provider_catalog_live;
|
||||
mod provider_lake;
|
||||
mod provider_readiness;
|
||||
mod purge;
|
||||
pub mod reasoning_preference;
|
||||
mod regex_cache;
|
||||
mod remote_control;
|
||||
mod remote_setup;
|
||||
pub mod repl;
|
||||
@@ -99,7 +88,6 @@ mod repo_law;
|
||||
mod request_manifest;
|
||||
mod request_tuning;
|
||||
mod resource_telemetry;
|
||||
mod retry_status;
|
||||
pub mod rlm;
|
||||
mod route_billing;
|
||||
mod route_budget;
|
||||
@@ -112,7 +100,6 @@ mod runtime_handoff;
|
||||
mod runtime_log;
|
||||
mod runtime_policy;
|
||||
mod runtime_threads;
|
||||
mod safe_label;
|
||||
mod sandbox;
|
||||
mod scorecard;
|
||||
mod session_diagnostics;
|
||||
@@ -129,12 +116,9 @@ mod session_manager;
|
||||
mod session_peek;
|
||||
mod session_projection;
|
||||
mod session_resume;
|
||||
pub mod session_tree;
|
||||
mod settings;
|
||||
mod shell_dispatcher;
|
||||
mod skill_state;
|
||||
mod skills;
|
||||
mod sleep_guard;
|
||||
mod snapshot;
|
||||
mod startup_trace;
|
||||
mod task_manager;
|
||||
@@ -144,8 +128,17 @@ mod test_support;
|
||||
// TLS bootstrap and platform client builders live in codewhale-release;
|
||||
// `crate::tls::*` keeps resolving for every caller.
|
||||
use codewhale_release::tls;
|
||||
// Runtime split path alias: modules that moved to `crates/runtime` keep
|
||||
// resolving as `crate::<module>` inside this crate. One block, no per-item
|
||||
// re-exports; the split deletes it by rewriting these paths to
|
||||
// `codewhale_runtime::` (docs/design/TUI_DECONSTRUCTION.md).
|
||||
use codewhale_runtime::{
|
||||
context_budget, continual_harness, elapsed, fast_hash, goal_loop, hashing, host_terminal,
|
||||
llm_response_cache, media_originals, model_context, native_memory, prompt_zones, regex_cache,
|
||||
retry_status, safe_label, session_tree, skill_state, sleep_guard, tool_history_repair,
|
||||
workspace_discovery,
|
||||
};
|
||||
mod todo_snapshot;
|
||||
mod tool_history_repair;
|
||||
mod tool_inspection;
|
||||
mod tool_output_receipts;
|
||||
mod tools;
|
||||
@@ -156,10 +149,10 @@ pub use tui::ambient_life::pet_sim as pet;
|
||||
mod turn_route_plan;
|
||||
mod utils;
|
||||
mod vision;
|
||||
mod voice;
|
||||
mod work_graph;
|
||||
mod worker_profile;
|
||||
mod working_set;
|
||||
mod workspace_discovery;
|
||||
mod workspace_trust;
|
||||
|
||||
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
|
||||
@@ -1773,6 +1766,10 @@ fn run_with_args(args: Vec<String>) -> Result<()> {
|
||||
startup_trace::mark_process_start();
|
||||
configure_windows_console_utf8();
|
||||
install_rustls_crypto_provider();
|
||||
// The TUI is the terminal host for every mode this binary runs
|
||||
// (interactive, exec, serve): runtime code reaches raw mode and
|
||||
// notification delivery only through this port.
|
||||
crate::tui::ui::install_host_terminal();
|
||||
|
||||
// ── Process hardening (#2183) ─────────────────────────────────────────
|
||||
// MUST run before Tokio is booted and before any threads are spawned.
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,296 @@
|
||||
//! Notification policy shared by every host: the typed payload, the
|
||||
//! delivery-method and category gate, attention (focus) rules, the
|
||||
//! per-event sound policy and audio cues.
|
||||
//!
|
||||
//! Runtime code owns *what* may notify and *which* sound it selects. The
|
||||
//! terminal UI owns *delivery*: `tui::notifications` is the only code that
|
||||
//! writes OSC 9 / 99 / 777 escapes, the taskbar progress sequence or the
|
||||
//! window title. Runtime callers that need a delivery (the `notify` tool,
|
||||
//! headless exec) reach it through `crate::host_terminal`, never by
|
||||
//! importing the TUI.
|
||||
//!
|
||||
//! Known limitation: `tui::notifications::notify_with_sinks` still combines
|
||||
//! the policy below with transport selection, so the runtime API's native
|
||||
//! notification preparation calls it with null sinks. Splitting transport
|
||||
//! out of it is the remaining step before this module can own the whole
|
||||
//! decision.
|
||||
|
||||
pub mod audio;
|
||||
pub mod payload;
|
||||
pub mod sound_policy;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::{NotificationCondition, NotificationMethod, NotificationsConfig};
|
||||
use payload::NotificationKind;
|
||||
|
||||
/// Notification delivery method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Method {
|
||||
/// Automatically pick the best protocol for the current terminal.
|
||||
/// See `tui::notifications::resolve_method` for the canonical resolution table.
|
||||
#[default]
|
||||
Auto,
|
||||
/// OSC 9 escape: `\x1b]9;<msg>\x07`
|
||||
Osc9,
|
||||
/// Plain BEL character: `\x07`
|
||||
Bel,
|
||||
/// macOS Notification Center via `osascript`.
|
||||
///
|
||||
/// Only reachable through [`Method::Auto`], and only on the macOS
|
||||
/// terminals that expose no notification escape of their own (Apple
|
||||
/// Terminal, the VS Code and JetBrains embedded terminals, plain tmux
|
||||
/// without `LC_TERMINAL`). iTerm2, WezTerm, Ghostty, and kitty are
|
||||
/// matched earlier in `tui::notifications::resolve_method` and never get here.
|
||||
///
|
||||
/// Known limitation (#4834): `display notification` is a Standard
|
||||
/// Additions command, so the banner is attributed to the *bundled*
|
||||
/// host process. `/usr/bin/osascript` is unbundled, so macOS credits
|
||||
/// `com.apple.ScriptEditor2` — which is what supplies the Script
|
||||
/// Editor icon and owns the System Settings → Notifications entry
|
||||
/// (alert style, previews, Do Not Disturb). `display notification`
|
||||
/// takes no icon parameter; fixing the attribution requires shipping
|
||||
/// a real `.app` bundle, not a change in this file.
|
||||
MacOS,
|
||||
/// Kitty notification protocol (OSC 99) with ST terminator.
|
||||
/// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL.
|
||||
Kitty,
|
||||
/// Ghostty notification protocol (OSC 777).
|
||||
/// Uses `ESC ] 777 ; notify ; title ; message BEL`.
|
||||
Ghostty,
|
||||
/// Suppress all notifications.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// Truthful result from one notification delivery attempt.
|
||||
///
|
||||
/// Callers that surface a receipt (notably the model-facing `notify` tool)
|
||||
/// use this instead of claiming a notification was sent when user policy,
|
||||
/// focus, or the configured delivery method suppressed it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeliveryOutcome {
|
||||
/// The notification was handed to the resolved transport.
|
||||
Delivered(Method),
|
||||
/// A background OS/audio worker was started; acceptance is unverified.
|
||||
Dispatched(Method),
|
||||
/// Banner bytes were sent, but the selected audio could not be dispatched.
|
||||
DeliveredWithoutSound(Method),
|
||||
/// A native dispatch was attempted, but selected audio was unavailable.
|
||||
DispatchedWithoutSound(Method),
|
||||
/// The bell-only transport has no authorized cue (off or rate limited).
|
||||
SuppressedBySound,
|
||||
/// The terminal is still in the foreground, or has only just lost focus.
|
||||
SuppressedByAttention,
|
||||
/// The event completed before the configured duration threshold.
|
||||
SuppressedByThreshold,
|
||||
/// Notification delivery is explicitly disabled.
|
||||
SuppressedByMethod,
|
||||
/// Quiet mode or the per-event allow-list suppressed this category.
|
||||
SuppressedByGate,
|
||||
/// The selected terminal protocol produced no transport bytes.
|
||||
UnsupportedTransport,
|
||||
/// The terminal transport could not be written.
|
||||
DeliveryFailed,
|
||||
}
|
||||
|
||||
impl DeliveryOutcome {
|
||||
/// Short, stable receipt text for command/tool surfaces.
|
||||
#[must_use]
|
||||
pub fn receipt(self) -> &'static str {
|
||||
match self {
|
||||
Self::Delivered(_) => "notification sent",
|
||||
Self::Dispatched(_) => "notification dispatch attempted",
|
||||
Self::DeliveredWithoutSound(_) => "notification sent; sound unavailable",
|
||||
Self::DispatchedWithoutSound(_) => "notification dispatch attempted; sound unavailable",
|
||||
Self::SuppressedBySound => "notification not sent: sound is off or rate limited",
|
||||
Self::SuppressedByAttention => "notification not sent: attention policy blocked it",
|
||||
Self::SuppressedByThreshold => "notification not sent: below the duration threshold",
|
||||
Self::SuppressedByMethod => "notification not sent: notifications are off",
|
||||
Self::SuppressedByGate => {
|
||||
"notification not sent: quiet mode or event settings blocked it"
|
||||
}
|
||||
Self::UnsupportedTransport => {
|
||||
"notification not sent: terminal transport is unsupported"
|
||||
}
|
||||
Self::DeliveryFailed => "notification not sent: terminal delivery failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification gate (#5041) ────────────────────────────────────────
|
||||
//
|
||||
// One policy switchboard between "an event happened" and "the user's
|
||||
// desktop is interrupted". `[notifications].quiet` silences every
|
||||
// category; `[notifications.events]` disables individual categories. The
|
||||
// gate is installed from config by `tui::notifications::settings` and consulted by
|
||||
// `tui::notifications::notify_done` ahead of every delivery mechanism, so a disabled
|
||||
// category can never leak through one specific protocol.
|
||||
|
||||
/// Which notification categories may reach the user's desktop.
|
||||
///
|
||||
/// The category set mirrors [`NotificationKind`] one-to-one. Default:
|
||||
/// everything enabled, quiet off — matching the pre-#5041 behavior.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NotificationGate {
|
||||
/// Suppress every category when `true` (`[notifications].quiet`).
|
||||
pub quiet: bool,
|
||||
pub turn_complete: bool,
|
||||
pub subagent_terminal: bool,
|
||||
pub approval_needed: bool,
|
||||
pub input_needed: bool,
|
||||
pub elevation_needed: bool,
|
||||
pub model_notify: bool,
|
||||
}
|
||||
|
||||
impl Default for NotificationGate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quiet: false,
|
||||
turn_complete: true,
|
||||
subagent_terminal: true,
|
||||
approval_needed: true,
|
||||
input_needed: true,
|
||||
elevation_needed: true,
|
||||
model_notify: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationGate {
|
||||
/// Project the `[notifications]` config block onto a gate.
|
||||
#[must_use]
|
||||
pub fn from_config(notif: &NotificationsConfig) -> Self {
|
||||
Self {
|
||||
quiet: notif.quiet,
|
||||
turn_complete: notif.events.turn_complete,
|
||||
subagent_terminal: notif.events.subagent_terminal,
|
||||
approval_needed: notif.events.approval_needed,
|
||||
input_needed: notif.events.input_needed,
|
||||
elevation_needed: notif.events.elevation_needed,
|
||||
model_notify: notif.events.model_notify,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an event of `kind` may be delivered under this gate.
|
||||
#[must_use]
|
||||
pub fn allows(self, kind: NotificationKind) -> bool {
|
||||
if self.quiet {
|
||||
return false;
|
||||
}
|
||||
match kind {
|
||||
NotificationKind::TurnComplete => self.turn_complete,
|
||||
NotificationKind::SubagentTerminal => self.subagent_terminal,
|
||||
NotificationKind::ApprovalNeeded => self.approval_needed,
|
||||
NotificationKind::InputNeeded => self.input_needed,
|
||||
NotificationKind::ElevationNeeded => self.elevation_needed,
|
||||
NotificationKind::ModelNotify => self.model_notify,
|
||||
}
|
||||
}
|
||||
|
||||
const QUIET_BIT: u8 = 1 << 0;
|
||||
const TURN_COMPLETE_BIT: u8 = 1 << 1;
|
||||
const SUBAGENT_TERMINAL_BIT: u8 = 1 << 2;
|
||||
const APPROVAL_NEEDED_BIT: u8 = 1 << 3;
|
||||
const INPUT_NEEDED_BIT: u8 = 1 << 4;
|
||||
const ELEVATION_NEEDED_BIT: u8 = 1 << 5;
|
||||
const MODEL_NOTIFY_BIT: u8 = 1 << 6;
|
||||
|
||||
pub(crate) const fn to_bits(self) -> u8 {
|
||||
(self.quiet as u8 * Self::QUIET_BIT)
|
||||
| (self.turn_complete as u8 * Self::TURN_COMPLETE_BIT)
|
||||
| (self.subagent_terminal as u8 * Self::SUBAGENT_TERMINAL_BIT)
|
||||
| (self.approval_needed as u8 * Self::APPROVAL_NEEDED_BIT)
|
||||
| (self.input_needed as u8 * Self::INPUT_NEEDED_BIT)
|
||||
| (self.elevation_needed as u8 * Self::ELEVATION_NEEDED_BIT)
|
||||
| (self.model_notify as u8 * Self::MODEL_NOTIFY_BIT)
|
||||
}
|
||||
|
||||
pub(crate) const fn from_bits(bits: u8) -> Self {
|
||||
Self {
|
||||
quiet: bits & Self::QUIET_BIT != 0,
|
||||
turn_complete: bits & Self::TURN_COMPLETE_BIT != 0,
|
||||
subagent_terminal: bits & Self::SUBAGENT_TERMINAL_BIT != 0,
|
||||
approval_needed: bits & Self::APPROVAL_NEEDED_BIT != 0,
|
||||
input_needed: bits & Self::INPUT_NEEDED_BIT != 0,
|
||||
elevation_needed: bits & Self::ELEVATION_NEEDED_BIT != 0,
|
||||
model_notify: bits & Self::MODEL_NOTIFY_BIT != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attention delivery policy installed from the resolved notification config.
|
||||
///
|
||||
/// The default is background-only. A newly started TUI is treated as focused
|
||||
/// until the terminal explicitly reports `FocusLost`, so the safe startup
|
||||
/// behavior is silence rather than an unexpected banner or bell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AttentionCondition {
|
||||
Always = 0,
|
||||
Unfocused = 1,
|
||||
Never = 2,
|
||||
}
|
||||
|
||||
pub(crate) const DEFAULT_UNFOCUSED_GRACE: Duration = Duration::from_secs(2);
|
||||
#[must_use]
|
||||
pub(crate) fn attention_delivery_allowed_at(
|
||||
condition: AttentionCondition,
|
||||
focused: bool,
|
||||
unfocused_since_ms: u64,
|
||||
now_ms: u64,
|
||||
) -> bool {
|
||||
match condition {
|
||||
AttentionCondition::Always => true,
|
||||
AttentionCondition::Never => false,
|
||||
AttentionCondition::Unfocused => {
|
||||
!focused
|
||||
&& unfocused_since_ms > 0
|
||||
&& now_ms.saturating_sub(unfocused_since_ms)
|
||||
>= DEFAULT_UNFOCUSED_GRACE.as_millis() as u64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native hosts provide focus observations; the same grace/condition rule
|
||||
/// applies before either native sound or banner preparation.
|
||||
pub(crate) fn native_attention_allowed(
|
||||
config: &NotificationsConfig,
|
||||
focused: bool,
|
||||
unfocused_for: Duration,
|
||||
) -> bool {
|
||||
let condition = match config.condition.unwrap_or(NotificationCondition::Unfocused) {
|
||||
NotificationCondition::Always => AttentionCondition::Always,
|
||||
NotificationCondition::Unfocused => AttentionCondition::Unfocused,
|
||||
NotificationCondition::Never => AttentionCondition::Never,
|
||||
};
|
||||
let elapsed = unfocused_for.as_millis().min(u128::from(u64::MAX - 1)) as u64;
|
||||
attention_delivery_allowed_at(condition, focused, 1, elapsed + 1)
|
||||
}
|
||||
|
||||
/// Resolve the effective notification method/threshold/include-summary tuple
|
||||
/// for a completed turn, taking the high-level
|
||||
/// `[tui].notification_condition` override into account on top of the
|
||||
/// lower-level `[notifications]` block.
|
||||
///
|
||||
/// Returns `None` only when the high-level attention policy is `never`.
|
||||
/// `Method::Off` remains a valid projection so the event gate can report
|
||||
/// that both banner and sound are disabled.
|
||||
#[must_use]
|
||||
pub fn settings_projection(notif: &NotificationsConfig) -> Option<(Method, Duration, bool)> {
|
||||
let method = match notif.method {
|
||||
NotificationMethod::Auto => Method::Auto,
|
||||
NotificationMethod::Osc9 => Method::Osc9,
|
||||
NotificationMethod::Bel => Method::Bel,
|
||||
NotificationMethod::Kitty => Method::Kitty,
|
||||
NotificationMethod::Ghostty => Method::Ghostty,
|
||||
NotificationMethod::Off => Method::Off,
|
||||
};
|
||||
match notif.condition.unwrap_or(NotificationCondition::Unfocused) {
|
||||
NotificationCondition::Always => Some((method, Duration::ZERO, notif.include_summary)),
|
||||
NotificationCondition::Unfocused => Some((
|
||||
method,
|
||||
Duration::from_secs(notif.threshold_secs),
|
||||
notif.include_summary,
|
||||
)),
|
||||
NotificationCondition::Never => None,
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,7 @@ pub fn sanitize_field(text: &str) -> String {
|
||||
// drops the ESC byte but leaves the parameter tail behind — good
|
||||
// enough for a terminal that will never re-interpret it, wrong for a
|
||||
// notification banner that would render a literal `[31m`.
|
||||
super::ui::sanitize_stream_chunk(&strip_escape_sequences(text))
|
||||
codewhale_secrets::sanitize::sanitize_stream_chunk(&strip_escape_sequences(text))
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let redacted = redact_structured(line.trim());
|
||||
@@ -2,7 +2,7 @@
|
||||
//! attention, duration, method, quiet and category gates. This policy selects
|
||||
//! one cue and preserves per-category repeat history across settings updates.
|
||||
|
||||
use super::notification_payload::NotificationKind;
|
||||
use super::payload::NotificationKind;
|
||||
use crate::config::{CompletionSound, NotificationsConfig};
|
||||
pub use codewhale_config::notifications::NotificationEvent as SoundEvent;
|
||||
use std::path::PathBuf;
|
||||
@@ -2,9 +2,11 @@
|
||||
//! policy owners. This endpoint prepares an attempt; it never submits a banner.
|
||||
|
||||
use super::*;
|
||||
use crate::notify::payload::NotificationPayload;
|
||||
use crate::notify::{
|
||||
DeliveryOutcome, Method, NotificationGate, audio as notification_audio, sound_policy,
|
||||
};
|
||||
use crate::runtime_threads::{RuntimeEventRecord, RuntimeTurnStatus};
|
||||
use crate::tui::notification_payload::NotificationPayload;
|
||||
use crate::tui::{notification_audio, notifications, sound_policy};
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -174,33 +176,33 @@ fn prepare_payload(
|
||||
unfocused_for: Duration,
|
||||
) -> PreparedNotification {
|
||||
let notification_config = config.notifications_config();
|
||||
let Some((method, threshold, _)) = notifications::settings_projection(config) else {
|
||||
let Some((method, threshold, _)) = crate::notify::settings_projection(¬ification_config)
|
||||
else {
|
||||
return PreparedNotification::suppressed("suppressed");
|
||||
};
|
||||
let method = match method {
|
||||
notifications::Method::Auto => notifications::Method::MacOS,
|
||||
notifications::Method::Off => notifications::Method::Off,
|
||||
Method::Auto => Method::MacOS,
|
||||
Method::Off => Method::Off,
|
||||
_ => return PreparedNotification::suppressed("unsupported_method"),
|
||||
};
|
||||
let attention =
|
||||
notifications::native_attention_allowed(¬ification_config, focused, unfocused_for);
|
||||
let threshold =
|
||||
if payload.kind() == crate::tui::notification_payload::NotificationKind::TurnComplete {
|
||||
threshold
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
crate::notify::native_attention_allowed(¬ification_config, focused, unfocused_for);
|
||||
let threshold = if payload.kind() == crate::notify::payload::NotificationKind::TurnComplete {
|
||||
threshold
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let mut sound = "off";
|
||||
// Capture the shared policy's intended sinks. Neither closure performs IO;
|
||||
// the response says prepared, never dispatched/delivered. The native host
|
||||
// owns the subsequent permission check and submission receipt.
|
||||
let outcome = notifications::notify_with_sinks(
|
||||
let outcome = crate::tui::notifications::notify_with_sinks(
|
||||
method,
|
||||
false,
|
||||
payload,
|
||||
threshold,
|
||||
elapsed,
|
||||
notifications::NotificationGate::from_config(¬ification_config),
|
||||
NotificationGate::from_config(¬ification_config),
|
||||
attention,
|
||||
&mut std::io::sink(),
|
||||
&mut |kind, bell| {
|
||||
@@ -221,9 +223,9 @@ fn prepare_payload(
|
||||
};
|
||||
notification_audio::AudioOutcome::Dispatched
|
||||
},
|
||||
&mut |_| notifications::DeliveryOutcome::Dispatched(notifications::Method::MacOS),
|
||||
&mut |_| DeliveryOutcome::Dispatched(Method::MacOS),
|
||||
);
|
||||
if !matches!(outcome, notifications::DeliveryOutcome::Dispatched(_)) {
|
||||
if !matches!(outcome, DeliveryOutcome::Dispatched(_)) {
|
||||
return PreparedNotification::suppressed("suppressed");
|
||||
}
|
||||
PreparedNotification {
|
||||
|
||||
@@ -23,7 +23,7 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
use crate::commands::voice as voice_core;
|
||||
use crate::voice as voice_core;
|
||||
use voice_core::{DictateError, DictateMode};
|
||||
|
||||
/// One mic per host — serialize captures so concurrent dictate requests get
|
||||
|
||||
@@ -390,24 +390,8 @@ impl ShellDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Disable raw mode; guard restores it only if it was already enabled.
|
||||
let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
|
||||
if raw_mode_was_enabled {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
}
|
||||
struct FgRawModeGuard {
|
||||
restore: bool,
|
||||
}
|
||||
impl Drop for FgRawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.restore {
|
||||
let _ = crossterm::terminal::enable_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = FgRawModeGuard {
|
||||
restore: raw_mode_was_enabled,
|
||||
};
|
||||
// Leave raw mode; the guard restores it only if it was already enabled.
|
||||
let _raw_mode = crate::host_terminal::suspend_raw_mode();
|
||||
|
||||
let mut cmd = self.build_command(shell_command);
|
||||
cmd.current_dir(cwd);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `notify` tool — model-callable desktop notification (#1322).
|
||||
//!
|
||||
//! Routes through the existing `tui::notifications` infrastructure (OSC 9
|
||||
//! Delivered by the terminal host through `crate::host_terminal` (OSC 9
|
||||
//! for known capable terminals, BEL fallback on macOS / Linux, `MessageBeep`
|
||||
//! on Windows when explicitly opted in). The model decides when to fire —
|
||||
//! the tool is intended for "long task done, come back" beats and
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! Honors the user's `[notifications]` config: `method = "off"` silences
|
||||
//! the tool entirely, and `quiet` / `events.model-notify = false` gate the
|
||||
//! category through the process-wide [`NotificationGate`]. Output messages
|
||||
//! category through the process-wide notification gate. Output messages
|
||||
//! are length-capped so a runaway model can't paint a paragraph into the
|
||||
//! terminal title bar.
|
||||
|
||||
@@ -19,7 +19,6 @@ use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_str, required_str,
|
||||
};
|
||||
use crate::tui::notifications::{NotificationPayload, notify_done};
|
||||
|
||||
/// Maximum chars passed through for the title — keeps the OSC 9 escape
|
||||
/// reasonable on terminals that wrap long titles awkwardly.
|
||||
@@ -66,8 +65,9 @@ impl ToolSpec for NotifyTool {
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
// No filesystem or shell side effects; the only output is a single
|
||||
// terminal-escape write to stdout. Mark as ReadOnly so the
|
||||
// No filesystem or shell side effects; the only output is one
|
||||
// notification the terminal host delivers (or declines to, when
|
||||
// none is installed). Mark as ReadOnly so the
|
||||
// approval-requirement default is `Auto` and the tool routes
|
||||
// through without prompting.
|
||||
vec![ToolCapability::ReadOnly]
|
||||
@@ -93,43 +93,20 @@ impl ToolSpec for NotifyTool {
|
||||
return Err(ToolError::execution_failed("title must not be empty"));
|
||||
}
|
||||
|
||||
// #4834: model-authored text is the least trusted input that can
|
||||
// reach Notification Center, so it goes through the typed payload
|
||||
// like every other event kind — bounded, control-byte-stripped,
|
||||
// and redacted for credentials, absolute paths, and raw tool JSON.
|
||||
let payload = NotificationPayload::model_notify(
|
||||
title,
|
||||
if body.is_empty() { None } else { Some(body) },
|
||||
);
|
||||
// #4834: the host builds the typed payload from this text (bounded,
|
||||
// control-byte-stripped, redacted) and applies the user's method,
|
||||
// gate and attention policy. Threshold zero: the model has already
|
||||
// decided this is the moment.
|
||||
let receipt = crate::host_terminal::host()
|
||||
.notify_model(title, if body.is_empty() { None } else { Some(body) });
|
||||
|
||||
let in_tmux = std::env::var("TMUX")
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Threshold = 0 so the notification always fires; the model has
|
||||
// already decided this is the moment.
|
||||
let outcome = notify_done(
|
||||
crate::tui::notifications::configured_method(),
|
||||
in_tmux,
|
||||
&payload,
|
||||
std::time::Duration::ZERO,
|
||||
std::time::Duration::from_secs(1),
|
||||
);
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"{}: {title}",
|
||||
outcome.receipt()
|
||||
)))
|
||||
Ok(ToolResult::success(format!("{receipt}: {title}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tui::notifications::{
|
||||
Method, configured_method, current_notification_gate, install_configured_method,
|
||||
notify_done_to,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
fn ctx() -> ToolContext {
|
||||
@@ -200,59 +177,25 @@ mod tests {
|
||||
assert!(!required.iter().any(|v| v.as_str() == Some("body")));
|
||||
}
|
||||
|
||||
/// Restores the process-wide configured method after a test mutates it,
|
||||
/// mirroring `NotificationGateRestore` in `tui::notifications`.
|
||||
struct ConfiguredMethodRestore(Method);
|
||||
|
||||
impl ConfiguredMethodRestore {
|
||||
fn capture() -> Self {
|
||||
Self(configured_method())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfiguredMethodRestore {
|
||||
fn drop(&mut self) {
|
||||
install_configured_method(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_method_off_silences_the_tool_emission() {
|
||||
let _restore = ConfiguredMethodRestore::capture();
|
||||
install_configured_method(Method::Off);
|
||||
|
||||
// The emission chain `execute` drives: the installed method decides
|
||||
// suppression before any sink write, with the gate loaded from the
|
||||
// process-wide state.
|
||||
let payload = NotificationPayload::model_notify("done", None);
|
||||
let mut sink = Vec::new();
|
||||
notify_done_to(
|
||||
configured_method(),
|
||||
false,
|
||||
&payload,
|
||||
std::time::Duration::ZERO,
|
||||
std::time::Duration::from_secs(1),
|
||||
current_notification_gate(),
|
||||
&mut sink,
|
||||
);
|
||||
assert!(
|
||||
sink.is_empty(),
|
||||
"configured method=off must silence the notify tool path"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_method_off_still_reports_success_to_the_model() {
|
||||
// The description promises a *silent* no-op: the model sees success
|
||||
// (nothing to retry), the user's desktop stays quiet.
|
||||
let _restore = ConfiguredMethodRestore::capture();
|
||||
install_configured_method(Method::Off);
|
||||
async fn the_model_sees_success_whatever_the_host_did() {
|
||||
// The description promises a *silent* no-op: whatever the host did
|
||||
// with the notification, the model sees success (nothing to retry).
|
||||
// No host is installed in this test binary, so this is the no-host
|
||||
// receipt; `method = "off"` through the TUI host is covered by
|
||||
// `tui::notifications` (`notify_model_honors_the_installed_off_method`)
|
||||
// and `tui::ui::terminal` (`tui_host_routes_the_notify_tool_through_the_installed_method`).
|
||||
|
||||
let result = NotifyTool
|
||||
.execute(json!({"title": "done"}), &ctx())
|
||||
.await
|
||||
.expect("ok");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("done"));
|
||||
// The receipt is the installed host's, not a hard-coded "sent": no
|
||||
// host is installed in this test binary, so it is the no-host one.
|
||||
assert_eq!(
|
||||
result.content,
|
||||
"notification not sent: no terminal host: done"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2283,25 +2283,9 @@ impl ShellManager {
|
||||
child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
|
||||
remove_readonly_redirect_env(&mut cmd, &exec_env.env);
|
||||
|
||||
// Disable raw mode before spawn; restore only if raw mode was active
|
||||
// on entry (issue #1690).
|
||||
let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
|
||||
if raw_mode_was_enabled {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
}
|
||||
struct SyncRawModeGuard {
|
||||
restore: bool,
|
||||
}
|
||||
impl Drop for SyncRawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.restore {
|
||||
let _ = crossterm::terminal::enable_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = SyncRawModeGuard {
|
||||
restore: raw_mode_was_enabled,
|
||||
};
|
||||
// Leave raw mode before spawn; the guard restores it only if raw mode
|
||||
// was active on entry (issue #1690).
|
||||
let _raw_mode = crate::host_terminal::suspend_raw_mode();
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
@@ -2443,25 +2427,9 @@ impl ShellManager {
|
||||
}
|
||||
install_parent_death_signal(&mut cmd);
|
||||
|
||||
// Disable raw mode before spawn; restore only if raw mode was active
|
||||
// on entry (issue #1690).
|
||||
let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
|
||||
if raw_mode_was_enabled {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
}
|
||||
struct InteractiveRawModeGuard {
|
||||
restore: bool,
|
||||
}
|
||||
impl Drop for InteractiveRawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.restore {
|
||||
let _ = crossterm::terminal::enable_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = InteractiveRawModeGuard {
|
||||
restore: raw_mode_was_enabled,
|
||||
};
|
||||
// Leave raw mode before spawn; the guard restores it only if raw mode
|
||||
// was active on entry (issue #1690).
|
||||
let _raw_mode = crate::host_terminal::suspend_raw_mode();
|
||||
|
||||
child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
|
||||
|
||||
|
||||
@@ -76,8 +76,6 @@ pub mod menu_style;
|
||||
pub mod model_picker;
|
||||
pub mod motion;
|
||||
pub mod mouse_ui;
|
||||
pub(crate) mod notification_audio;
|
||||
pub mod notification_payload;
|
||||
pub mod notifications;
|
||||
pub mod ocean;
|
||||
pub mod onboarding;
|
||||
@@ -105,7 +103,6 @@ mod shell_job_routing;
|
||||
pub mod shell_key_routing;
|
||||
pub mod sidebar;
|
||||
pub mod slash_menu;
|
||||
pub mod sound_policy;
|
||||
pub mod spinner;
|
||||
pub mod startup_defaults;
|
||||
pub mod startup_input;
|
||||
|
||||
@@ -46,6 +46,7 @@ use crate::tui::views::{
|
||||
ActionHint, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
|
||||
render_underwater_surface,
|
||||
};
|
||||
use crate::utils::format_context_window;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_palette as palette;
|
||||
|
||||
@@ -3083,11 +3084,11 @@ fn model_row_meta_chips(row: &ModelPickerRow) -> Vec<String> {
|
||||
"user declared (unverified)".to_string(),
|
||||
row.metadata
|
||||
.context_window
|
||||
.map(|value| format_picker_context_window(u64::from(value)))
|
||||
.map(|value| format_context_window(u64::from(value)))
|
||||
.unwrap_or_else(|| "context unknown".into()),
|
||||
row.metadata
|
||||
.max_output
|
||||
.map(|value| format!("{} out", format_picker_context_window(u64::from(value))))
|
||||
.map(|value| format!("{} out", format_context_window(u64::from(value))))
|
||||
.unwrap_or_else(|| "output unknown".into()),
|
||||
match &row.metadata.pricing {
|
||||
PickerPricing::Known(price) => format!("estimate {price}"),
|
||||
@@ -3117,7 +3118,7 @@ fn model_row_meta_chips(row: &ModelPickerRow) -> Vec<String> {
|
||||
}
|
||||
let mut chips = Vec::new();
|
||||
if let Some(context_window) = row.metadata.context_window {
|
||||
chips.push(format_picker_context_window(u64::from(context_window)));
|
||||
chips.push(format_context_window(u64::from(context_window)));
|
||||
}
|
||||
// The reasoning stance is the most decision-relevant fact for a coding
|
||||
// harness, so it sits before the limits/modality chips — the chip budget
|
||||
@@ -3148,7 +3149,7 @@ fn model_row_meta_chips(row: &ModelPickerRow) -> Vec<String> {
|
||||
};
|
||||
chips.push(format!(
|
||||
"{} out{suffix}",
|
||||
format_picker_context_window(u64::from(max_output))
|
||||
format_context_window(u64::from(max_output))
|
||||
));
|
||||
}
|
||||
// Modality and tool facts are shown only when the catalog genuinely knows
|
||||
@@ -3749,7 +3750,7 @@ fn render_picker_model_hint(
|
||||
if provider == Some(ApiProvider::OpenaiCodex) {
|
||||
parts.push(format!(
|
||||
"{} ctx · ChatGPT route",
|
||||
format_picker_context_window(u64::from(context_window))
|
||||
format_context_window(u64::from(context_window))
|
||||
));
|
||||
} else if provider == Some(ApiProvider::Moonshot)
|
||||
&& id.trim().eq_ignore_ascii_case("k3")
|
||||
@@ -3761,7 +3762,7 @@ fn render_picker_model_hint(
|
||||
// `context_window` setting when the plan includes 1M.
|
||||
parts.push(format!(
|
||||
"{} ctx (plan floor; raise via context_window)",
|
||||
format_picker_context_window(u64::from(context_window))
|
||||
format_context_window(u64::from(context_window))
|
||||
));
|
||||
} else {
|
||||
let suffix = if metadata.context_window_unverified {
|
||||
@@ -3771,7 +3772,7 @@ fn render_picker_model_hint(
|
||||
};
|
||||
parts.push(format!(
|
||||
"{} ctx{}",
|
||||
format_picker_context_window(u64::from(context_window)),
|
||||
format_context_window(u64::from(context_window)),
|
||||
suffix
|
||||
));
|
||||
}
|
||||
@@ -3785,7 +3786,7 @@ fn render_picker_model_hint(
|
||||
};
|
||||
parts.push(format!(
|
||||
"{} out{}",
|
||||
format_picker_context_window(u64::from(max_output)),
|
||||
format_context_window(u64::from(max_output)),
|
||||
suffix
|
||||
));
|
||||
}
|
||||
@@ -3880,23 +3881,6 @@ fn catalog_refresh_error_label(error: CatalogRefreshError) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_picker_context_window(tokens: u64) -> String {
|
||||
if tokens >= 1_000_000 {
|
||||
if tokens.is_multiple_of(1_000_000) {
|
||||
format!("{}M", tokens / 1_000_000)
|
||||
} else {
|
||||
format!("{:.2}M", tokens as f64 / 1_000_000.0)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
} else if tokens >= 1_000 {
|
||||
format!("{}K", tokens / 1_000)
|
||||
} else {
|
||||
tokens.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelPickerView {
|
||||
/// Rebuild model rows from a fresh app/config snapshot (readiness + catalog).
|
||||
pub fn re_resolve_from_app(&mut self, app: &App, config: &Config) {
|
||||
@@ -5645,7 +5629,7 @@ mod tests {
|
||||
assert!(
|
||||
picker.projection.borrow().as_ref().unwrap().rows[index]
|
||||
.meta
|
||||
.contains(&format_picker_context_window(777_000))
|
||||
.contains(&format_context_window(777_000))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+118
-300
@@ -13,7 +13,7 @@
|
||||
//!
|
||||
//! Every mechanism is fed a [`NotificationPayload`] — a typed, bounded,
|
||||
//! redaction-aware value — rather than a free-form `String` (#4834). See
|
||||
//! [`crate::tui::notification_payload`] for the per-kind disclosure
|
||||
//! [`crate::notify::payload`] for the per-kind disclosure
|
||||
//! policy.
|
||||
//!
|
||||
//! Delivery is governed by one [`NotificationGate`] (#5041):
|
||||
@@ -27,101 +27,12 @@ use std::sync::atomic::{AtomicU8, AtomicU64};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::notification_payload::NotificationKind;
|
||||
pub use super::notification_payload::NotificationPayload;
|
||||
|
||||
/// Notification delivery method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Method {
|
||||
/// Automatically pick the best protocol for the current terminal.
|
||||
/// See [`resolve_method`] for the canonical resolution table.
|
||||
#[default]
|
||||
Auto,
|
||||
/// OSC 9 escape: `\x1b]9;<msg>\x07`
|
||||
Osc9,
|
||||
/// Plain BEL character: `\x07`
|
||||
Bel,
|
||||
/// macOS Notification Center via `osascript`.
|
||||
///
|
||||
/// Only reachable through [`Method::Auto`], and only on the macOS
|
||||
/// terminals that expose no notification escape of their own (Apple
|
||||
/// Terminal, the VS Code and JetBrains embedded terminals, plain tmux
|
||||
/// without `LC_TERMINAL`). iTerm2, WezTerm, Ghostty, and kitty are
|
||||
/// matched earlier in [`resolve_method`] and never get here.
|
||||
///
|
||||
/// Known limitation (#4834): `display notification` is a Standard
|
||||
/// Additions command, so the banner is attributed to the *bundled*
|
||||
/// host process. `/usr/bin/osascript` is unbundled, so macOS credits
|
||||
/// `com.apple.ScriptEditor2` — which is what supplies the Script
|
||||
/// Editor icon and owns the System Settings → Notifications entry
|
||||
/// (alert style, previews, Do Not Disturb). `display notification`
|
||||
/// takes no icon parameter; fixing the attribution requires shipping
|
||||
/// a real `.app` bundle, not a change in this file.
|
||||
MacOS,
|
||||
/// Kitty notification protocol (OSC 99) with ST terminator.
|
||||
/// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL.
|
||||
Kitty,
|
||||
/// Ghostty notification protocol (OSC 777).
|
||||
/// Uses `ESC ] 777 ; notify ; title ; message BEL`.
|
||||
Ghostty,
|
||||
/// Suppress all notifications.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// Truthful result from one notification delivery attempt.
|
||||
///
|
||||
/// Callers that surface a receipt (notably the model-facing `notify` tool)
|
||||
/// use this instead of claiming a notification was sent when user policy,
|
||||
/// focus, or the configured delivery method suppressed it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeliveryOutcome {
|
||||
/// The notification was handed to the resolved transport.
|
||||
Delivered(Method),
|
||||
/// A background OS/audio worker was started; acceptance is unverified.
|
||||
Dispatched(Method),
|
||||
/// Banner bytes were sent, but the selected audio could not be dispatched.
|
||||
DeliveredWithoutSound(Method),
|
||||
/// A native dispatch was attempted, but selected audio was unavailable.
|
||||
DispatchedWithoutSound(Method),
|
||||
/// The bell-only transport has no authorized cue (off or rate limited).
|
||||
SuppressedBySound,
|
||||
/// The terminal is still in the foreground, or has only just lost focus.
|
||||
SuppressedByAttention,
|
||||
/// The event completed before the configured duration threshold.
|
||||
SuppressedByThreshold,
|
||||
/// Notification delivery is explicitly disabled.
|
||||
SuppressedByMethod,
|
||||
/// Quiet mode or the per-event allow-list suppressed this category.
|
||||
SuppressedByGate,
|
||||
/// The selected terminal protocol produced no transport bytes.
|
||||
UnsupportedTransport,
|
||||
/// The terminal transport could not be written.
|
||||
DeliveryFailed,
|
||||
}
|
||||
|
||||
impl DeliveryOutcome {
|
||||
/// Short, stable receipt text for command/tool surfaces.
|
||||
#[must_use]
|
||||
pub fn receipt(self) -> &'static str {
|
||||
match self {
|
||||
Self::Delivered(_) => "notification sent",
|
||||
Self::Dispatched(_) => "notification dispatch attempted",
|
||||
Self::DeliveredWithoutSound(_) => "notification sent; sound unavailable",
|
||||
Self::DispatchedWithoutSound(_) => "notification dispatch attempted; sound unavailable",
|
||||
Self::SuppressedBySound => "notification not sent: sound is off or rate limited",
|
||||
Self::SuppressedByAttention => "notification not sent: attention policy blocked it",
|
||||
Self::SuppressedByThreshold => "notification not sent: below the duration threshold",
|
||||
Self::SuppressedByMethod => "notification not sent: notifications are off",
|
||||
Self::SuppressedByGate => {
|
||||
"notification not sent: quiet mode or event settings blocked it"
|
||||
}
|
||||
Self::UnsupportedTransport => {
|
||||
"notification not sent: terminal transport is unsupported"
|
||||
}
|
||||
Self::DeliveryFailed => "notification not sent: terminal delivery failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::notify::payload::NotificationKind;
|
||||
pub use crate::notify::payload::NotificationPayload;
|
||||
use crate::notify::{
|
||||
AttentionCondition, DeliveryOutcome, Method, NotificationGate, attention_delivery_allowed_at,
|
||||
settings_projection,
|
||||
};
|
||||
|
||||
/// Process-wide configured delivery method. Installed before the event loop
|
||||
/// starts and updated by live Settings, so producers such as the model-facing
|
||||
@@ -263,107 +174,6 @@ fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification gate (#5041) ────────────────────────────────────────
|
||||
//
|
||||
// One policy switchboard between "an event happened" and "the user's
|
||||
// desktop is interrupted". `[notifications].quiet` silences every
|
||||
// category; `[notifications.events]` disables individual categories. The
|
||||
// gate is installed from config by [`settings`] and consulted by
|
||||
// [`notify_done`] ahead of every delivery mechanism, so a disabled
|
||||
// category can never leak through one specific protocol.
|
||||
|
||||
/// Which notification categories may reach the user's desktop.
|
||||
///
|
||||
/// The category set mirrors [`NotificationKind`] one-to-one. Default:
|
||||
/// everything enabled, quiet off — matching the pre-#5041 behavior.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NotificationGate {
|
||||
/// Suppress every category when `true` (`[notifications].quiet`).
|
||||
pub quiet: bool,
|
||||
pub turn_complete: bool,
|
||||
pub subagent_terminal: bool,
|
||||
pub approval_needed: bool,
|
||||
pub input_needed: bool,
|
||||
pub elevation_needed: bool,
|
||||
pub model_notify: bool,
|
||||
}
|
||||
|
||||
impl Default for NotificationGate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quiet: false,
|
||||
turn_complete: true,
|
||||
subagent_terminal: true,
|
||||
approval_needed: true,
|
||||
input_needed: true,
|
||||
elevation_needed: true,
|
||||
model_notify: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationGate {
|
||||
/// Project the `[notifications]` config block onto a gate.
|
||||
#[must_use]
|
||||
pub fn from_config(notif: &crate::config::NotificationsConfig) -> Self {
|
||||
Self {
|
||||
quiet: notif.quiet,
|
||||
turn_complete: notif.events.turn_complete,
|
||||
subagent_terminal: notif.events.subagent_terminal,
|
||||
approval_needed: notif.events.approval_needed,
|
||||
input_needed: notif.events.input_needed,
|
||||
elevation_needed: notif.events.elevation_needed,
|
||||
model_notify: notif.events.model_notify,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an event of `kind` may be delivered under this gate.
|
||||
#[must_use]
|
||||
pub fn allows(self, kind: NotificationKind) -> bool {
|
||||
if self.quiet {
|
||||
return false;
|
||||
}
|
||||
match kind {
|
||||
NotificationKind::TurnComplete => self.turn_complete,
|
||||
NotificationKind::SubagentTerminal => self.subagent_terminal,
|
||||
NotificationKind::ApprovalNeeded => self.approval_needed,
|
||||
NotificationKind::InputNeeded => self.input_needed,
|
||||
NotificationKind::ElevationNeeded => self.elevation_needed,
|
||||
NotificationKind::ModelNotify => self.model_notify,
|
||||
}
|
||||
}
|
||||
|
||||
const QUIET_BIT: u8 = 1 << 0;
|
||||
const TURN_COMPLETE_BIT: u8 = 1 << 1;
|
||||
const SUBAGENT_TERMINAL_BIT: u8 = 1 << 2;
|
||||
const APPROVAL_NEEDED_BIT: u8 = 1 << 3;
|
||||
const INPUT_NEEDED_BIT: u8 = 1 << 4;
|
||||
const ELEVATION_NEEDED_BIT: u8 = 1 << 5;
|
||||
const MODEL_NOTIFY_BIT: u8 = 1 << 6;
|
||||
|
||||
const fn to_bits(self) -> u8 {
|
||||
(self.quiet as u8 * Self::QUIET_BIT)
|
||||
| (self.turn_complete as u8 * Self::TURN_COMPLETE_BIT)
|
||||
| (self.subagent_terminal as u8 * Self::SUBAGENT_TERMINAL_BIT)
|
||||
| (self.approval_needed as u8 * Self::APPROVAL_NEEDED_BIT)
|
||||
| (self.input_needed as u8 * Self::INPUT_NEEDED_BIT)
|
||||
| (self.elevation_needed as u8 * Self::ELEVATION_NEEDED_BIT)
|
||||
| (self.model_notify as u8 * Self::MODEL_NOTIFY_BIT)
|
||||
}
|
||||
|
||||
const fn from_bits(bits: u8) -> Self {
|
||||
Self {
|
||||
quiet: bits & Self::QUIET_BIT != 0,
|
||||
turn_complete: bits & Self::TURN_COMPLETE_BIT != 0,
|
||||
subagent_terminal: bits & Self::SUBAGENT_TERMINAL_BIT != 0,
|
||||
approval_needed: bits & Self::APPROVAL_NEEDED_BIT != 0,
|
||||
input_needed: bits & Self::INPUT_NEEDED_BIT != 0,
|
||||
elevation_needed: bits & Self::ELEVATION_NEEDED_BIT != 0,
|
||||
model_notify: bits & Self::MODEL_NOTIFY_BIT != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything on, quiet off — the pre-#5041 behavior, and the effective
|
||||
/// policy until the first [`settings`] call installs the configured gate.
|
||||
const GATE_DEFAULT_BITS: u8 = 0b0111_1110;
|
||||
@@ -372,19 +182,6 @@ const GATE_DEFAULT_BITS: u8 = 0b0111_1110;
|
||||
/// a single atomic load.
|
||||
static NOTIFICATION_GATE: AtomicU8 = AtomicU8::new(GATE_DEFAULT_BITS);
|
||||
|
||||
/// Attention delivery policy installed from the resolved notification config.
|
||||
///
|
||||
/// The default is background-only. A newly started TUI is treated as focused
|
||||
/// until the terminal explicitly reports `FocusLost`, so the safe startup
|
||||
/// behavior is silence rather than an unexpected banner or bell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AttentionCondition {
|
||||
Always = 0,
|
||||
Unfocused = 1,
|
||||
Never = 2,
|
||||
}
|
||||
|
||||
const DEFAULT_UNFOCUSED_GRACE: Duration = Duration::from_secs(2);
|
||||
static ATTENTION_CONDITION: AtomicU8 = AtomicU8::new(AttentionCondition::Unfocused as u8);
|
||||
static UNFOCUSED_SINCE_MS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -410,25 +207,6 @@ fn current_attention_condition() -> AttentionCondition {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn attention_delivery_allowed_at(
|
||||
condition: AttentionCondition,
|
||||
focused: bool,
|
||||
unfocused_since_ms: u64,
|
||||
now_ms: u64,
|
||||
) -> bool {
|
||||
match condition {
|
||||
AttentionCondition::Always => true,
|
||||
AttentionCondition::Never => false,
|
||||
AttentionCondition::Unfocused => {
|
||||
!focused
|
||||
&& unfocused_since_ms > 0
|
||||
&& now_ms.saturating_sub(unfocused_since_ms)
|
||||
>= DEFAULT_UNFOCUSED_GRACE.as_millis() as u64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn attention_delivery_allowed() -> bool {
|
||||
attention_delivery_allowed_at(
|
||||
@@ -439,25 +217,6 @@ fn attention_delivery_allowed() -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Native hosts provide focus observations; the same grace/condition rule
|
||||
/// applies before either native sound or banner preparation.
|
||||
pub(crate) fn native_attention_allowed(
|
||||
config: &crate::config::NotificationsConfig,
|
||||
focused: bool,
|
||||
unfocused_for: Duration,
|
||||
) -> bool {
|
||||
let condition = match config
|
||||
.condition
|
||||
.unwrap_or(crate::config::NotificationCondition::Unfocused)
|
||||
{
|
||||
crate::config::NotificationCondition::Always => AttentionCondition::Always,
|
||||
crate::config::NotificationCondition::Unfocused => AttentionCondition::Unfocused,
|
||||
crate::config::NotificationCondition::Never => AttentionCondition::Never,
|
||||
};
|
||||
let elapsed = unfocused_for.as_millis().min(u128::from(u64::MAX - 1)) as u64;
|
||||
attention_delivery_allowed_at(condition, focused, 1, elapsed + 1)
|
||||
}
|
||||
|
||||
/// Install `gate` as the process-wide notification policy.
|
||||
pub fn install_notification_gate(gate: NotificationGate) {
|
||||
NOTIFICATION_GATE.store(gate.to_bits(), Ordering::SeqCst);
|
||||
@@ -486,7 +245,7 @@ pub fn notify_done_to<W: Write>(
|
||||
gate: NotificationGate,
|
||||
sink: &mut W,
|
||||
) -> DeliveryOutcome {
|
||||
let mut policy = super::sound_policy::EventSoundPolicy::default();
|
||||
let mut policy = crate::notify::sound_policy::EventSoundPolicy::default();
|
||||
notify_with_sinks(
|
||||
method,
|
||||
in_tmux,
|
||||
@@ -496,8 +255,8 @@ pub fn notify_done_to<W: Write>(
|
||||
gate,
|
||||
true,
|
||||
sink,
|
||||
&mut |kind, bell| policy.decide(super::sound_policy::event_for_kind(kind), 0, bell),
|
||||
&mut super::notification_audio::emit_terminal,
|
||||
&mut |kind, bell| policy.decide(crate::notify::sound_policy::event_for_kind(kind), 0, bell),
|
||||
&mut crate::notify::audio::emit_terminal,
|
||||
&mut |_| DeliveryOutcome::UnsupportedTransport,
|
||||
)
|
||||
}
|
||||
@@ -513,15 +272,18 @@ pub(crate) fn notify_with_sinks(
|
||||
gate: NotificationGate,
|
||||
attention_allowed: bool,
|
||||
sink: &mut dyn Write,
|
||||
decide_sound: &mut dyn FnMut(NotificationKind, bool) -> super::sound_policy::SoundDecision,
|
||||
decide_sound: &mut dyn FnMut(
|
||||
NotificationKind,
|
||||
bool,
|
||||
) -> crate::notify::sound_policy::SoundDecision,
|
||||
audio: &mut dyn FnMut(
|
||||
&super::sound_policy::SoundCue,
|
||||
&crate::notify::sound_policy::SoundCue,
|
||||
&mut dyn Write,
|
||||
) -> super::notification_audio::AudioOutcome,
|
||||
) -> crate::notify::audio::AudioOutcome,
|
||||
native: &mut dyn FnMut(&NotificationPayload) -> DeliveryOutcome,
|
||||
) -> DeliveryOutcome {
|
||||
use super::notification_audio::AudioOutcome;
|
||||
use super::sound_policy::SoundDecision;
|
||||
use crate::notify::audio::AudioOutcome;
|
||||
use crate::notify::sound_policy::SoundDecision;
|
||||
if !attention_allowed {
|
||||
return DeliveryOutcome::SuppressedByAttention;
|
||||
}
|
||||
@@ -617,13 +379,36 @@ pub fn notify_done(
|
||||
attention_delivery_allowed(),
|
||||
&mut io::stdout(),
|
||||
&mut |kind, bell| {
|
||||
super::sound_policy::decide(kind, super::sound_policy::epoch_millis_now(), bell)
|
||||
crate::notify::sound_policy::decide(
|
||||
kind,
|
||||
crate::notify::sound_policy::epoch_millis_now(),
|
||||
bell,
|
||||
)
|
||||
},
|
||||
&mut super::notification_audio::dispatch,
|
||||
&mut crate::notify::audio::dispatch,
|
||||
&mut dispatch_native,
|
||||
)
|
||||
}
|
||||
|
||||
/// The `notify` tool's delivery: a typed model-notify payload through the
|
||||
/// configured method, installed gate and attention policy, threshold zero.
|
||||
pub(crate) fn notify_model(title: &str, body: Option<&str>) -> &'static str {
|
||||
// #4834: model-authored text is the least trusted input that can reach
|
||||
// Notification Center, so it goes through the typed payload like every
|
||||
// other event kind: bounded, control-byte-stripped, and redacted for
|
||||
// credentials, absolute paths, and raw tool JSON.
|
||||
let payload = NotificationPayload::model_notify(title, body);
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
notify_done(
|
||||
configured_method(),
|
||||
in_tmux,
|
||||
&payload,
|
||||
Duration::ZERO,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.receipt()
|
||||
}
|
||||
|
||||
/// Set the terminal taskbar progress state via OSC 9 ; 4.
|
||||
///
|
||||
/// Windows Terminal supports this to show progress on the taskbar icon:
|
||||
@@ -1123,48 +908,22 @@ use crate::tui::app::App;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_models::{ContentBlock, Message};
|
||||
|
||||
/// Resolve the effective notification method/threshold/include-summary tuple
|
||||
/// for a completed turn, taking the high-level
|
||||
/// `[tui].notification_condition` override into account on top of the
|
||||
/// lower-level `[notifications]` block.
|
||||
///
|
||||
/// Returns `None` only when the high-level attention policy is `never`.
|
||||
/// `Method::Off` remains a valid projection so the event gate can report
|
||||
/// that both banner and sound are disabled.
|
||||
#[must_use]
|
||||
pub fn settings_projection(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
|
||||
let notif = config.notifications_config();
|
||||
let method = match notif.method {
|
||||
crate::config::NotificationMethod::Auto => Method::Auto,
|
||||
crate::config::NotificationMethod::Osc9 => Method::Osc9,
|
||||
crate::config::NotificationMethod::Bel => Method::Bel,
|
||||
crate::config::NotificationMethod::Kitty => Method::Kitty,
|
||||
crate::config::NotificationMethod::Ghostty => Method::Ghostty,
|
||||
crate::config::NotificationMethod::Off => Method::Off,
|
||||
};
|
||||
match notif
|
||||
.condition
|
||||
.unwrap_or(crate::config::NotificationCondition::Unfocused)
|
||||
{
|
||||
crate::config::NotificationCondition::Always => {
|
||||
Some((method, Duration::ZERO, notif.include_summary))
|
||||
}
|
||||
crate::config::NotificationCondition::Unfocused => Some((
|
||||
method,
|
||||
Duration::from_secs(notif.threshold_secs),
|
||||
notif.include_summary,
|
||||
)),
|
||||
crate::config::NotificationCondition::Never => None,
|
||||
}
|
||||
pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
|
||||
apply_settings(&config.notifications_config())
|
||||
}
|
||||
|
||||
pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
|
||||
let notif = config.notifications_config();
|
||||
/// Install the process-wide method, gate, sound policy and attention
|
||||
/// condition from `[notifications]`; returns the resolved projection.
|
||||
pub(crate) fn apply_settings(
|
||||
notif: &crate::config::NotificationsConfig,
|
||||
) -> Option<(Method, Duration, bool)> {
|
||||
// Install the category/quiet gate (#5041) so `notify_done` honors
|
||||
// `[notifications].quiet` and `[notifications.events]`.
|
||||
install_notification_gate(NotificationGate::from_config(¬if));
|
||||
super::sound_policy::reconfigure(super::sound_policy::EventSoundPolicy::from_config(¬if));
|
||||
let projection = settings_projection(config);
|
||||
install_notification_gate(NotificationGate::from_config(notif));
|
||||
crate::notify::sound_policy::reconfigure(
|
||||
crate::notify::sound_policy::EventSoundPolicy::from_config(notif),
|
||||
);
|
||||
let projection = settings_projection(notif);
|
||||
let method = projection.map_or(Method::Off, |(method, _, _)| method);
|
||||
install_configured_method(method);
|
||||
|
||||
@@ -1341,7 +1100,7 @@ pub fn latest_assistant_text(messages: &[Message]) -> Option<String> {
|
||||
pub fn text_summary(text: &str) -> Option<String> {
|
||||
const MAX_CHARS: usize = 360;
|
||||
|
||||
let sanitized = super::ui::sanitize_stream_chunk(text);
|
||||
let sanitized = codewhale_secrets::sanitize::sanitize_stream_chunk(text);
|
||||
let collapsed = sanitized
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
@@ -1367,6 +1126,63 @@ pub fn text_summary(text: &str) -> Option<String> {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::notify::DEFAULT_UNFOCUSED_GRACE;
|
||||
|
||||
/// Moved from `tools::notify`: the `notify` tool's emission chain, where
|
||||
/// the installed method decides suppression before any sink write.
|
||||
#[test]
|
||||
fn configured_method_off_silences_the_tool_emission() {
|
||||
let _lock = env_lock();
|
||||
let _restore = ConfiguredMethodRestore::capture();
|
||||
install_configured_method(Method::Off);
|
||||
|
||||
// The emission chain `execute` drives: the installed method decides
|
||||
// suppression before any sink write, with the gate loaded from the
|
||||
// process-wide state.
|
||||
let payload = NotificationPayload::model_notify("done", None);
|
||||
let mut sink = Vec::new();
|
||||
notify_done_to(
|
||||
configured_method(),
|
||||
false,
|
||||
&payload,
|
||||
std::time::Duration::ZERO,
|
||||
std::time::Duration::from_secs(1),
|
||||
current_notification_gate(),
|
||||
&mut sink,
|
||||
);
|
||||
assert!(
|
||||
sink.is_empty(),
|
||||
"configured method=off must silence the notify tool path"
|
||||
);
|
||||
}
|
||||
|
||||
/// The function the host port calls for the `notify` tool must read the
|
||||
/// installed method: `[notifications] method = "off"` silences the tool
|
||||
/// and the receipt says so. A `notify_model` that ignored
|
||||
/// `configured_method()` would pass the test above but fail this one.
|
||||
#[test]
|
||||
fn notify_model_honors_the_installed_off_method() {
|
||||
let _lock = env_lock();
|
||||
let _method_restore = ConfiguredMethodRestore::capture();
|
||||
let _gate_restore = NotificationGateRestore::capture();
|
||||
let previous_condition = current_attention_condition();
|
||||
// `condition = "always"` so the attention policy (checked first) lets
|
||||
// the call reach the method check whatever the test runner's focus.
|
||||
let off: crate::config::Config = toml::from_str(
|
||||
r#"
|
||||
[notifications]
|
||||
method = "off"
|
||||
condition = "always"
|
||||
"#,
|
||||
)
|
||||
.expect("method=off config should parse");
|
||||
let _ = settings(&off);
|
||||
|
||||
let receipt = notify_model("done", Some("all tests pass"));
|
||||
|
||||
install_attention_condition(previous_condition);
|
||||
assert_eq!(receipt, DeliveryOutcome::SuppressedByMethod.receipt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_whale_is_static_when_focused_or_motion_disabled() {
|
||||
@@ -1805,6 +1621,8 @@ mod tests {
|
||||
/// pair can never silently fall back to `Auto`.
|
||||
#[test]
|
||||
fn configured_method_round_trips_every_variant() {
|
||||
// Serialized with the tests that deliver through the installed method.
|
||||
let _lock = env_lock();
|
||||
let _restore = ConfiguredMethodRestore::capture();
|
||||
for method in [
|
||||
Method::Auto,
|
||||
@@ -2062,7 +1880,7 @@ mod tests {
|
||||
assert!(body.ends_with("..."));
|
||||
assert_eq!(
|
||||
body.chars().count(),
|
||||
super::super::notification_payload::PREVIEW_MAX_CHARS
|
||||
crate::notify::payload::PREVIEW_MAX_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2656,10 +2474,10 @@ mod tideline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod unified_audio_tests {
|
||||
use super::super::notification_audio::AudioOutcome;
|
||||
use super::super::sound_policy::{self, EventSoundPolicy, SoundCue, SoundDecision};
|
||||
use super::*;
|
||||
use crate::config::{CompletionSound, NotificationConfigUpdate, NotificationsConfig};
|
||||
use crate::notify::audio::AudioOutcome;
|
||||
use crate::notify::sound_policy::{self, EventSoundPolicy, SoundCue, SoundDecision};
|
||||
|
||||
fn payloads() -> [NotificationPayload; 6] {
|
||||
[
|
||||
|
||||
@@ -1242,16 +1242,9 @@ pub(crate) fn build_session_snapshot(
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Strip ANSI control codes / non-printable bytes from a streaming
|
||||
/// text chunk. `pub(super)` because `tui::notifications` consumes it
|
||||
/// from `crate::tui::ui` for its per-turn message composition.
|
||||
pub(crate) fn sanitize_stream_chunk(chunk: &str) -> String {
|
||||
// Keep printable characters and common whitespace; drop control bytes.
|
||||
chunk
|
||||
.chars()
|
||||
.filter(|c| *c == '\n' || *c == '\t' || !c.is_control())
|
||||
.collect()
|
||||
}
|
||||
/// The stream sanitizer lives with the other output sanitizers in
|
||||
/// `codewhale-secrets`; the event loop reaches it through this module.
|
||||
pub(crate) use codewhale_secrets::sanitize::sanitize_stream_chunk;
|
||||
|
||||
/// Ensure an in-flight streaming Assistant cell exists in history and return
|
||||
/// its index. Thinking cells go through `streaming_thinking::ensure_active_entry`
|
||||
|
||||
@@ -1027,3 +1027,75 @@ mod screen_mode_tests {
|
||||
assert_eq!(inline_viewport_rows(&backend), 24);
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminal UI's implementation of the runtime's one terminal port
|
||||
/// (`crate::host_terminal`). The composition root installs it for every host
|
||||
/// this binary launches, so runtime code (the shell tools, the dispatcher)
|
||||
/// reaches raw mode only through it and never links crossterm.
|
||||
struct TuiHostTerminal;
|
||||
|
||||
impl crate::host_terminal::HostTerminal for TuiHostTerminal {
|
||||
fn suspend_raw_mode(&self) -> bool {
|
||||
let was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
|
||||
if was_enabled {
|
||||
let _ = disable_raw_mode();
|
||||
}
|
||||
was_enabled
|
||||
}
|
||||
|
||||
fn resume_raw_mode(&self) {
|
||||
let _ = enable_raw_mode();
|
||||
}
|
||||
|
||||
fn notify_model(&self, title: &str, body: Option<&str>) -> &'static str {
|
||||
crate::tui::notifications::notify_model(title, body)
|
||||
}
|
||||
|
||||
fn set_terminal_focused(&self, focused: bool) {
|
||||
crate::tui::notifications::set_terminal_focused(focused);
|
||||
}
|
||||
|
||||
fn apply_notification_settings(&self, config: &crate::config::NotificationsConfig) {
|
||||
let _ = crate::tui::notifications::apply_settings(config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the TUI as the process's terminal host. Idempotent: the first
|
||||
/// install wins.
|
||||
pub(crate) fn install_host_terminal() {
|
||||
let _ = crate::host_terminal::install(Box::new(TuiHostTerminal));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_terminal_tests {
|
||||
use super::TuiHostTerminal;
|
||||
use crate::host_terminal::HostTerminal;
|
||||
use crate::notify::DeliveryOutcome;
|
||||
use crate::tui::notifications::{configured_method, install_configured_method};
|
||||
|
||||
/// The `notify` tool reaches delivery only through the installed host:
|
||||
/// the TUI's host must hand the model's text to the delivery path that
|
||||
/// honors the installed method, so `method = "off"` stays silent.
|
||||
#[test]
|
||||
fn tui_host_routes_the_notify_tool_through_the_installed_method() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let previous_method = configured_method();
|
||||
let config = |text: &str| -> crate::config::Config {
|
||||
toml::from_str(text).expect("notifications config should parse")
|
||||
};
|
||||
// Settings reach the host the way the composition root sends them;
|
||||
// `condition = "always"` so the attention policy (checked first)
|
||||
// lets the call reach the method check whatever the runner's focus.
|
||||
TuiHostTerminal.apply_notification_settings(
|
||||
&config("[notifications]\nmethod = \"off\"\ncondition = \"always\"\n")
|
||||
.notifications_config(),
|
||||
);
|
||||
|
||||
let receipt = TuiHostTerminal.notify_model("done", None);
|
||||
|
||||
TuiHostTerminal
|
||||
.apply_notification_settings(&config("[notifications]\n").notifications_config());
|
||||
install_configured_method(previous_method);
|
||||
assert_eq!(receipt, DeliveryOutcome::SuppressedByMethod.receipt());
|
||||
}
|
||||
}
|
||||
@@ -27326,9 +27326,9 @@ fn notification_settings_tui_always_keeps_configured_method_no_threshold() {
|
||||
};
|
||||
|
||||
let (method, threshold, include_summary) =
|
||||
crate::tui::notifications::settings_projection(&config)
|
||||
crate::notify::settings_projection(&config.notifications_config())
|
||||
.expect("notification should be enabled");
|
||||
assert_eq!(method, crate::tui::notifications::Method::Bel);
|
||||
assert_eq!(method, crate::notify::Method::Bel);
|
||||
assert_eq!(threshold, Duration::ZERO);
|
||||
assert!(include_summary);
|
||||
}
|
||||
@@ -27343,7 +27343,7 @@ fn notification_settings_tui_never_disables_notifications() {
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
assert!(crate::tui::notifications::settings_projection(&config).is_none());
|
||||
assert!(crate::notify::settings_projection(&config.notifications_config()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -27366,9 +27366,9 @@ fn notification_settings_no_tui_override_uses_notifications_block() {
|
||||
};
|
||||
|
||||
let (method, threshold, include_summary) =
|
||||
crate::tui::notifications::settings_projection(&config)
|
||||
crate::notify::settings_projection(&config.notifications_config())
|
||||
.expect("notification should be enabled");
|
||||
assert_eq!(method, crate::tui::notifications::Method::Osc9);
|
||||
assert_eq!(method, crate::notify::Method::Osc9);
|
||||
assert_eq!(threshold, Duration::from_secs(45));
|
||||
assert!(!include_summary);
|
||||
}
|
||||
@@ -27464,7 +27464,7 @@ fn completed_turn_notification_truncates_long_text() {
|
||||
// so the bound the type promises is the bound the OS receives.
|
||||
assert_eq!(
|
||||
preview.chars().count(),
|
||||
crate::tui::notification_payload::PREVIEW_MAX_CHARS
|
||||
crate::notify::payload::PREVIEW_MAX_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,27 @@ impl io::Write for CountingWriter {
|
||||
const LOG_FINGERPRINT_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const LOG_FINGERPRINT_PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
|
||||
/// Compact token-count label for a model's context or output window:
|
||||
/// `1M`, `1.05M`, `262K`, `500`. Shared by the model picker and the fleet
|
||||
/// capability badges. Not the same scale as `agent_roster::format_tokens`
|
||||
/// (`1.2k`), which labels usage rather than window size.
|
||||
pub(crate) fn format_context_window(tokens: u64) -> String {
|
||||
if tokens >= 1_000_000 {
|
||||
if tokens.is_multiple_of(1_000_000) {
|
||||
format!("{}M", tokens / 1_000_000)
|
||||
} else {
|
||||
format!("{:.2}M", tokens as f64 / 1_000_000.0)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
} else if tokens >= 1_000 {
|
||||
format!("{}K", tokens / 1_000)
|
||||
} else {
|
||||
tokens.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a stable, non-reversible log label for an identifier.
|
||||
///
|
||||
/// This is meant for correlation in diagnostics where the raw value may be a
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
//! Voice capture and transcription core: recorder detection, WAV encoding,
|
||||
//! ASR selection and dispatch, the send-suffix contract, and the headless
|
||||
//! record→transcribe cycle.
|
||||
//!
|
||||
//! Shared by the TUI's `/voice` commands (`commands::voice`, which keeps the
|
||||
//! slash commands and the composer-updating capture loop) and the runtime
|
||||
//! API's `/v1/voice` routes (`runtime_api::voice`). No UI types live here.
|
||||
//!
|
||||
//! ## Recording
|
||||
//!
|
||||
//! Uses platform-specific command-line tools (sox, rec, arecord) to capture
|
||||
//! 16kHz mono 16-bit PCM audio. Records until a silence gap is detected or
|
||||
//! the maximum duration is reached (default 10 s).
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Transcription model requested from the provider's chat-completions API.
|
||||
const ASR_MODEL: &str = "mimo-v2.5-asr";
|
||||
const GROQ_ASR_MODEL: &str = "whisper-large-v3-turbo";
|
||||
/// Local whisper binary names to probe (whisper.cpp, faster-whisper, OpenAI whisper).
|
||||
const LOCAL_WHISPER_BINS: &[&str] = &["whisper", "whisper.cpp", "whisper-cpp", "faster-whisper"];
|
||||
/// Model used for the AI-assisted voice-control pipeline.
|
||||
const VOICE_CONTROL_MODEL: &str = "mimo-v2.5";
|
||||
|
||||
// --- Recorder detection ----------------------------------------------------
|
||||
|
||||
/// Platform-specific recorder definitions.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Recorder {
|
||||
cmd: &'static str,
|
||||
/// CLI arguments for piping raw 16kHz mono S16_LE PCM to stdout.
|
||||
pipe_args: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn detect_recorder() -> Option<Recorder> {
|
||||
// Operator kill-switch: a headless `serve --http` host has no business
|
||||
// opening a microphone; disabling voice here makes `GET /v1/voice`
|
||||
// report `available: false` and every dictate call fail closed.
|
||||
if std::env::var_os("CODEWHALE_DISABLE_VOICE").is_some() {
|
||||
return None;
|
||||
}
|
||||
let candidates: &[Recorder] = if cfg!(target_os = "macos") {
|
||||
&[
|
||||
Recorder {
|
||||
cmd: "sox",
|
||||
pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
},
|
||||
Recorder {
|
||||
cmd: "rec",
|
||||
pipe_args: &["-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
},
|
||||
]
|
||||
} else if cfg!(target_os = "linux") {
|
||||
&[
|
||||
Recorder {
|
||||
cmd: "arecord",
|
||||
pipe_args: &["-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"],
|
||||
},
|
||||
Recorder {
|
||||
cmd: "sox",
|
||||
pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
},
|
||||
]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&[Recorder {
|
||||
cmd: "sox",
|
||||
pipe_args: &["-d", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"],
|
||||
}]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.find(|r| {
|
||||
Command::new(r.cmd)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Check whether voice recording is available on this system.
|
||||
pub fn is_available() -> bool {
|
||||
detect_recorder().is_some()
|
||||
}
|
||||
|
||||
// --- WAV encoding ----------------------------------------------------------
|
||||
|
||||
/// Encode raw 16kHz mono S16_LE PCM samples as a WAV buffer.
|
||||
fn encode_wav(samples: &[i16]) -> Vec<u8> {
|
||||
let data_size = (samples.len() * 2) as u32;
|
||||
let sample_rate: u32 = 16000;
|
||||
let mut buf = Vec::with_capacity(44 + data_size as usize);
|
||||
|
||||
// RIFF header
|
||||
buf.extend_from_slice(b"RIFF");
|
||||
buf.extend_from_slice(&(36 + data_size).to_le_bytes());
|
||||
buf.extend_from_slice(b"WAVE");
|
||||
|
||||
// fmt chunk
|
||||
buf.extend_from_slice(b"fmt ");
|
||||
buf.extend_from_slice(&16u32.to_le_bytes()); // chunk size
|
||||
buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
buf.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||
buf.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
buf.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate
|
||||
buf.extend_from_slice(&2u16.to_le_bytes()); // block align
|
||||
buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
|
||||
|
||||
// data chunk
|
||||
buf.extend_from_slice(b"data");
|
||||
buf.extend_from_slice(&data_size.to_le_bytes());
|
||||
for &sample in samples {
|
||||
buf.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
// --- Recording -------------------------------------------------------------
|
||||
|
||||
/// Maximum recording duration in seconds before auto-stopping.
|
||||
pub const MAX_RECORD_SECS: u64 = 10;
|
||||
/// Minimum segment duration in seconds to consider as valid speech.
|
||||
const MIN_SEGMENT_SECS: f64 = 0.3;
|
||||
|
||||
/// Record audio from the default microphone.
|
||||
///
|
||||
/// Returns raw 16kHz mono S16_LE PCM samples. Returns `None` if no recorder
|
||||
/// is available, the recording failed, or no speech was detected.
|
||||
pub(crate) fn record_audio() -> Option<(Vec<i16>, Duration)> {
|
||||
let recorder = detect_recorder()?;
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut child = Command::new(recorder.cmd)
|
||||
.args(recorder.pipe_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let stdout = child.stdout.take()?;
|
||||
let mut reader = std::io::BufReader::new(stdout);
|
||||
let mut all_samples: Vec<i16> = Vec::with_capacity(16000 * MAX_RECORD_SECS as usize);
|
||||
|
||||
// Read until timeout or silence
|
||||
let mut buf = [0u8; 320]; // 10ms of 16kHz S16_LE
|
||||
let max_duration = Duration::from_secs(MAX_RECORD_SECS);
|
||||
let mut silence_samples = 0u32;
|
||||
let mut had_speech = false;
|
||||
let speech_threshold: i16 = 500; // RMS-based speech detection threshold
|
||||
let silence_duration_samples = 16000u32; // 1 second of silence to stop
|
||||
|
||||
loop {
|
||||
use std::io::Read;
|
||||
match reader.read_exact(&mut buf) {
|
||||
Ok(()) => {
|
||||
let chunk: Vec<i16> = buf
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.copied()
|
||||
.map(i16::from_le_bytes)
|
||||
.collect();
|
||||
|
||||
// Simple RMS-based VAD
|
||||
let rms = (chunk.iter().map(|&s| (s as f64) * (s as f64)).sum::<f64>()
|
||||
/ chunk.len() as f64)
|
||||
.sqrt();
|
||||
let is_speech = rms > speech_threshold as f64;
|
||||
|
||||
if is_speech {
|
||||
had_speech = true;
|
||||
silence_samples = 0;
|
||||
} else if had_speech {
|
||||
silence_samples += chunk.len() as u32;
|
||||
}
|
||||
|
||||
if had_speech {
|
||||
all_samples.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if start.elapsed() > max_duration {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
if had_speech && silence_samples >= silence_duration_samples {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.wait();
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let min_samples = (MIN_SEGMENT_SECS * 16000.0) as usize;
|
||||
if all_samples.len() < min_samples {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((all_samples, elapsed))
|
||||
}
|
||||
|
||||
// --- Auto-send suffix ------------------------------------------------------
|
||||
|
||||
/// Trailing phrases that mean "submit this" — the human-readable form of
|
||||
/// `SEND_SUFFIX_RE`; keep in sync with the regex when either changes.
|
||||
pub const SEND_PHRASES: &[&str] = &["send it", "发送", "發送"];
|
||||
|
||||
/// Matches an explicit send instruction at the end of transcribed text:
|
||||
/// "send it" (any spacing/case) or 发送/發送, with trailing punctuation.
|
||||
static SEND_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)(?:^|[\s,,.。!!??]+)(?:send\s*it|发送|發送)[\s.。!!??]*$").unwrap()
|
||||
});
|
||||
|
||||
/// Split a transcript into the message remainder and whether it ended with an
|
||||
/// explicit send instruction. `"ship the fix, send it"` → `("ship the fix", true)`.
|
||||
pub(crate) fn split_send_suffix(text: &str) -> (&str, bool) {
|
||||
match SEND_SUFFIX_RE.find(text) {
|
||||
Some(found) => (text[..found.start()].trim(), true),
|
||||
None => (text.trim(), false),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Transcription ---------------------------------------------------------
|
||||
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(data)
|
||||
}
|
||||
|
||||
fn chat_completions_url(base_url: &str) -> String {
|
||||
format!("{}/chat/completions", base_url.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
async fn post_chat_completions(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
mut body: serde_json::Value,
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
crate::client::apply_openrouter_vendor(&mut body, openrouter_vendor);
|
||||
let _inference = crate::client::acquire_remote_control_inference_participant().await;
|
||||
let client = crate::tls::reqwest_client();
|
||||
let resp = client
|
||||
.post(chat_completions_url(base_url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("API returned status {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response: {e}"))
|
||||
}
|
||||
|
||||
/// Send audio to the provider's API for plain transcription.
|
||||
///
|
||||
/// Uses the chat completions endpoint with `input_audio` content blocks.
|
||||
pub(crate) async fn transcribe(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
audio_samples: &[i16],
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
transcribe_with_model(
|
||||
api_key,
|
||||
base_url,
|
||||
audio_samples,
|
||||
ASR_MODEL,
|
||||
openrouter_vendor,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn transcribe_with_model(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
audio_samples: &[i16],
|
||||
model: &str,
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let wav = encode_wav(audio_samples);
|
||||
let data_url = format!("data:audio/wav;base64,{}", base64_encode(&wav));
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": data_url
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"asr_options": {
|
||||
"language": "auto"
|
||||
}
|
||||
});
|
||||
|
||||
let data = post_chat_completions(api_key, base_url, body, openrouter_vendor).await?;
|
||||
data["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok_or_else(|| "no transcription in response".to_string())
|
||||
}
|
||||
|
||||
/// Process audio through the voice-control pipeline: AI-assisted dictation
|
||||
/// that sees the current composer text, mirroring MiMo Code's
|
||||
/// `processVoiceControl`. Used when `/voice-control` is enabled.
|
||||
pub(crate) async fn process_voice_control(
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
audio_samples: &[i16],
|
||||
current_text: &str,
|
||||
openrouter_vendor: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let wav = encode_wav(audio_samples);
|
||||
let data_url = format!("data:audio/wav;base64,{}", base64_encode(&wav));
|
||||
|
||||
let user_context = serde_json::json!({
|
||||
"current_text": current_text,
|
||||
"cursor": "end",
|
||||
});
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": VOICE_CONTROL_MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a voice input assistant. Transcribe the user's speech. Output JSON: {\"text\": \"transcribed text\"}."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": user_context.to_string() },
|
||||
{ "type": "input_audio", "input_audio": { "data": data_url } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"response_format": { "type": "json_object" }
|
||||
});
|
||||
|
||||
let data = post_chat_completions(api_key, base_url, body, openrouter_vendor).await?;
|
||||
let content = data["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "no response content".to_string())?;
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(content)
|
||||
.map_err(|e| format!("failed to parse voice control JSON: {e}"))?;
|
||||
|
||||
parsed["text"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "no text field in voice control response".to_string())
|
||||
}
|
||||
|
||||
/// Detect best free ASR for this host — local whisper > Groq free > provider fallback.
|
||||
/// Works on macOS (brew install whisper-cpp), Windows (whisper.cpp binary),
|
||||
/// Linux (apt), and HarmonyOS (falls back to cloud).
|
||||
fn detect_free_asr() -> &'static str {
|
||||
for bin in LOCAL_WHISPER_BINS {
|
||||
if Command::new(bin)
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
{
|
||||
return "local-whisper";
|
||||
}
|
||||
}
|
||||
if std::env::var("GROQ_API_KEY").is_ok_and(|v| !v.trim().is_empty()) {
|
||||
return "groq";
|
||||
}
|
||||
"provider"
|
||||
}
|
||||
|
||||
/// Transcribe via local whisper.cpp (free, offline, cross-platform).
|
||||
///
|
||||
/// The whole body is synchronous — temp-file I/O plus `Command::output()`,
|
||||
/// which blocks for the entire subprocess run — so it runs on the blocking
|
||||
/// pool rather than a Tokio worker (blocking-call convention, #6149).
|
||||
pub(crate) async fn transcribe_local_whisper(audio_samples: &[i16]) -> Result<String, String> {
|
||||
let wav = encode_wav(audio_samples);
|
||||
tokio::task::spawn_blocking(move || transcribe_local_whisper_blocking(&wav))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn transcribe_local_whisper_blocking(wav: &[u8]) -> Result<String, String> {
|
||||
let tmp = std::env::temp_dir().join(format!("cw-voice-{}.wav", std::process::id()));
|
||||
std::fs::write(&tmp, wav).map_err(|e| e.to_string())?;
|
||||
// Try each local binary until one succeeds; whisper.cpp outputs to stdout or file.
|
||||
for bin in LOCAL_WHISPER_BINS {
|
||||
let output = Command::new(bin)
|
||||
.arg(tmp.to_string_lossy().as_ref())
|
||||
.arg("--model")
|
||||
.arg("tiny")
|
||||
.arg("--language")
|
||||
.arg("auto")
|
||||
.arg("--output-txt")
|
||||
.output();
|
||||
if let Ok(out) = output
|
||||
&& out.status.success()
|
||||
{
|
||||
let txt = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if !txt.is_empty() {
|
||||
return Ok(txt);
|
||||
}
|
||||
// Some builds write to .txt sidecar
|
||||
let sidecar = tmp.with_extension("txt");
|
||||
if let Ok(s) = std::fs::read_to_string(&sidecar) {
|
||||
let _ = std::fs::remove_file(&sidecar);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
if !s.trim().is_empty() {
|
||||
return Ok(s.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err("local whisper not available".into())
|
||||
}
|
||||
|
||||
/// Transcribe via Groq Whisper large-v3-turbo (free tier, ~$0.04/hr, fast).
|
||||
/// Groq is NOT a full CodeWhale provider yet — this is a direct ASR call
|
||||
/// using `GROQ_API_KEY` only (no provider setup needed). Uses the same
|
||||
/// chat-completions `input_audio` path as Xiaomi so no `multipart` feature.
|
||||
pub(crate) async fn transcribe_groq(audio_samples: &[i16]) -> Result<String, String> {
|
||||
let api_key = std::env::var("GROQ_API_KEY").map_err(|_| "GROQ_API_KEY not set".to_string())?;
|
||||
let base_url = "https://api.groq.com/openai/v1";
|
||||
transcribe_with_model(&api_key, base_url, audio_samples, GROQ_ASR_MODEL, None).await
|
||||
}
|
||||
|
||||
/// Resolve ASR model/provider preference.
|
||||
/// Priority: explicit config `voice.asr_model` > env `CODEWHALE_ASR_MODEL` > auto-detect (local-whisper > groq > xiaomi).
|
||||
pub(crate) fn resolve_asr_choice(_config: &Config) -> (String, String) {
|
||||
// Check explicit env override first (free, cross-platform)
|
||||
if let Ok(m) = std::env::var("CODEWHALE_ASR_MODEL") {
|
||||
let m = m.trim().to_ascii_lowercase();
|
||||
if m.contains("groq") || m.contains("whisper") {
|
||||
return ("groq".into(), GROQ_ASR_MODEL.into());
|
||||
}
|
||||
if m.contains("local") || m.contains("whisper.cpp") {
|
||||
return ("local-whisper".into(), "tiny".into());
|
||||
}
|
||||
if m.contains("mimo") || m.contains("xiaomi") {
|
||||
return ("provider".into(), ASR_MODEL.into());
|
||||
}
|
||||
}
|
||||
// Auto-detect best free: local whisper (offline, no key) > Groq free tier > Xiaomi ASR (needs key)
|
||||
let free = detect_free_asr();
|
||||
match free {
|
||||
"local-whisper" => ("local-whisper".into(), "tiny".into()),
|
||||
"groq" => ("groq".into(), GROQ_ASR_MODEL.into()),
|
||||
_ => ("provider".into(), ASR_MODEL.into()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Headless capture (HTTP/native-client path) ----------------------------
|
||||
|
||||
/// What a headless dictation should do with the finished transcript.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DictateMode {
|
||||
/// Transcribe and return the text for insertion into the composer.
|
||||
Insert,
|
||||
/// Transcribe, then apply the "send it" / 发送 suffix contract. The
|
||||
/// outcome's `send` flag tells the client to submit; a bare send
|
||||
/// instruction yields empty `text` so the client submits its own draft.
|
||||
Send,
|
||||
/// AI-assisted dictation that sees the client's composer text — the
|
||||
/// `/voice-control` pipeline. Only provider ASR can see context; free
|
||||
/// ASR kinds degrade to plain transcription with `assisted: false`.
|
||||
Control(String),
|
||||
}
|
||||
|
||||
/// Machine-readable failure for the headless path so HTTP clients can
|
||||
/// localize by `reason` rather than parsing message text.
|
||||
#[derive(Debug)]
|
||||
pub enum DictateError {
|
||||
/// No supported recorder binary on this host.
|
||||
NoRecorder,
|
||||
/// Recording produced no usable speech segment.
|
||||
NoSpeech,
|
||||
/// The selected/fallback ASR needs a provider key that isn't configured.
|
||||
NoProviderAuth,
|
||||
/// ASR request or transcription failed.
|
||||
Transcription(String),
|
||||
}
|
||||
|
||||
impl DictateError {
|
||||
pub fn reason(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NoRecorder => "no_recorder",
|
||||
Self::NoSpeech => "no_speech",
|
||||
Self::NoProviderAuth => "no_provider_auth",
|
||||
Self::Transcription(_) => "transcription_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DictateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoRecorder => write!(f, "no supported voice recorder on this host"),
|
||||
Self::NoSpeech => write!(f, "no speech detected"),
|
||||
Self::NoProviderAuth => {
|
||||
write!(f, "provider ASR requires a configured API key")
|
||||
}
|
||||
Self::Transcription(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one headless record→transcribe cycle.
|
||||
#[derive(Debug)]
|
||||
pub struct DictationOutcome {
|
||||
/// Final transcript (send suffix already stripped for `Send` mode).
|
||||
pub text: String,
|
||||
/// `Send` mode only: the transcript ended with an explicit send phrase.
|
||||
pub send: bool,
|
||||
/// `Control` mode only: the composer context reached the model. False
|
||||
/// when a free ASR kind handled the audio and never saw the context.
|
||||
pub assisted: bool,
|
||||
/// Which ASR backend was selected for this capture.
|
||||
pub asr_kind: String,
|
||||
pub asr_model: String,
|
||||
}
|
||||
|
||||
/// Detected recorder binary name, if any (`"sox"`, `"arecord"`, `"rec"`).
|
||||
pub fn recorder_command() -> Option<&'static str> {
|
||||
detect_recorder().map(|r| r.cmd)
|
||||
}
|
||||
|
||||
/// Resolved ASR selection (`kind`, `model`) for capability reporting.
|
||||
pub fn asr_choice(config: &Config) -> (String, String) {
|
||||
resolve_asr_choice(config)
|
||||
}
|
||||
|
||||
/// One record→transcribe cycle with no UI surface: the HTTP/native-client
|
||||
/// equivalent of `commands::voice::capture_and_transcribe`. Recording runs on a blocking
|
||||
/// thread; transcription follows the same ASR dispatch as the TUI —
|
||||
/// explicit `CODEWHALE_ASR_MODEL` > local whisper > Groq > provider —
|
||||
/// but resolves the provider key lazily so free ASR kinds work without
|
||||
/// provider auth.
|
||||
pub async fn dictate_once(
|
||||
config: &Config,
|
||||
mode: DictateMode,
|
||||
) -> Result<DictationOutcome, DictateError> {
|
||||
if !is_available() {
|
||||
return Err(DictateError::NoRecorder);
|
||||
}
|
||||
let (samples, _duration) = tokio::task::spawn_blocking(record_audio)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or(DictateError::NoSpeech)?;
|
||||
|
||||
let (asr_kind, asr_model) = resolve_asr_choice(config);
|
||||
let base_url = config.active_route_base_url();
|
||||
let openrouter_vendor = config
|
||||
.openrouter_vendor()
|
||||
.map_err(|e| DictateError::Transcription(e.to_string()))?;
|
||||
let provider_key = || {
|
||||
config
|
||||
.active_route_api_key()
|
||||
.map_err(|_| DictateError::NoProviderAuth)
|
||||
};
|
||||
|
||||
let mut assisted = false;
|
||||
let text = match asr_kind.as_str() {
|
||||
"local-whisper" => match transcribe_local_whisper(&samples).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => transcribe(
|
||||
&provider_key()?,
|
||||
&base_url,
|
||||
&samples,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
},
|
||||
"groq" => match transcribe_groq(&samples).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => transcribe(
|
||||
&provider_key()?,
|
||||
&base_url,
|
||||
&samples,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
},
|
||||
_ => {
|
||||
let api_key = provider_key()?;
|
||||
match &mode {
|
||||
DictateMode::Control(composer) => {
|
||||
assisted = true;
|
||||
process_voice_control(
|
||||
&api_key,
|
||||
&base_url,
|
||||
&samples,
|
||||
composer,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?
|
||||
}
|
||||
_ => transcribe(&api_key, &base_url, &samples, openrouter_vendor.as_deref())
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let clean = text.trim().to_string();
|
||||
let (text, send) = match mode {
|
||||
DictateMode::Send => {
|
||||
let (remainder, wants_send) = split_send_suffix(&clean);
|
||||
(remainder.to_string(), wants_send)
|
||||
}
|
||||
_ => (clean, false),
|
||||
};
|
||||
Ok(DictationOutcome {
|
||||
text,
|
||||
send,
|
||||
assisted,
|
||||
asr_kind,
|
||||
asr_model,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_requests_preserve_openrouter_vendor_pin() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"choices": [{ "message": { "content": "{\"text\":\"hello\"}" } }]
|
||||
})))
|
||||
.expect(3)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let base_url = format!("{}/v1", server.uri());
|
||||
transcribe(
|
||||
"fixture-key",
|
||||
&base_url,
|
||||
&[0; 16],
|
||||
Some("chutes/region-fixture"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
process_voice_control(
|
||||
"fixture-key",
|
||||
&base_url,
|
||||
&[0; 16],
|
||||
"existing text",
|
||||
Some("chutes/region-fixture"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
transcribe_with_model("fixture-key", &base_url, &[0; 16], GROQ_ASR_MODEL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 3);
|
||||
for request in &requests[..2] {
|
||||
let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
||||
assert_eq!(
|
||||
body["provider"],
|
||||
serde_json::json!({"order": ["chutes/region-fixture"], "allow_fallbacks": false})
|
||||
);
|
||||
}
|
||||
let independent: serde_json::Value = serde_json::from_slice(&requests[2].body).unwrap();
|
||||
assert!(independent.get("provider").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_encoding_produces_valid_header() {
|
||||
let samples = vec![0i16; 16000]; // 1 second of silence
|
||||
let wav = encode_wav(&samples);
|
||||
assert_eq!(&wav[0..4], b"RIFF");
|
||||
assert_eq!(&wav[8..12], b"WAVE");
|
||||
assert_eq!(&wav[12..16], b"fmt ");
|
||||
// data size = 16000 * 2 = 32000
|
||||
assert_eq!(&wav[4..8], &(36 + 32000u32).to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_encoding_empty_is_minimal() {
|
||||
let wav = encode_wav(&[]);
|
||||
assert_eq!(wav.len(), 44);
|
||||
assert_eq!(&wav[4..8], &36u32.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_suffix_detected_and_stripped() {
|
||||
assert_eq!(split_send_suffix("send it"), ("", true));
|
||||
assert_eq!(split_send_suffix("Send It!"), ("", true));
|
||||
assert_eq!(split_send_suffix("发送"), ("", true));
|
||||
assert_eq!(split_send_suffix("發送。"), ("", true));
|
||||
assert_eq!(
|
||||
split_send_suffix("ship the fix, send it"),
|
||||
("ship the fix", true)
|
||||
);
|
||||
assert_eq!(
|
||||
split_send_suffix("修复这个问题,发送"),
|
||||
("修复这个问题", true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_suffix_leaves_plain_text_alone() {
|
||||
assert_eq!(split_send_suffix("send it now"), ("send it now", false));
|
||||
assert_eq!(
|
||||
split_send_suffix("帮我发送一封邮件"),
|
||||
("帮我发送一封邮件", false)
|
||||
);
|
||||
assert_eq!(split_send_suffix("发送邮件"), ("发送邮件", false));
|
||||
assert_eq!(
|
||||
split_send_suffix("resend it to the queue"),
|
||||
("resend it to the queue", false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorder_detection_does_not_crash() {
|
||||
// Just verify the function runs without panicking
|
||||
let _ = is_available();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ mod shell_dispatcher;
|
||||
// The legacy text tool-call parser lives in codewhale-core now; keep the
|
||||
// `crate::tool_parser` path the suites use.
|
||||
use codewhale_core::tool_parser;
|
||||
// `shell_dispatcher` reaches raw mode through the runtime's terminal port.
|
||||
use codewhale_runtime::host_terminal;
|
||||
|
||||
mod adaptive_evidence_acceptance;
|
||||
mod cache_guard;
|
||||
|
||||
+18
-2
@@ -6,6 +6,11 @@ Current boundary note (read the workspace version from `Cargo.toml`; this
|
||||
boundary has held since v0.9.1):
|
||||
- `crates/tui` is still the live end-user runtime for the TUI, runtime API, task manager, and tool execution loop.
|
||||
- Other workspace crates are being split out incrementally, but they are not yet the sole runtime source of truth.
|
||||
- The runtime is moving into `crates/runtime` (`codewhale-runtime`) in the
|
||||
order `docs/design/TUI_DECONSTRUCTION.md` records: engine, tools, config,
|
||||
client and stores move there together, never into `crates/core`, and the
|
||||
TUI stays the only crate that writes to the terminal. Until a module has
|
||||
moved, its path under `crates/tui/src` is still where it lives.
|
||||
- The LSP subsystem (`crates/tui/src/lsp/`) is fully wired into the engine's
|
||||
post-tool-execution path (`core/engine/lsp_hooks.rs`), providing inline
|
||||
diagnostics after `File` write, edit, and patch actions.
|
||||
@@ -128,11 +133,22 @@ boundary has held since v0.9.1):
|
||||
- **`crates/models`** - Provider request/response models and the offline model
|
||||
metadata catalog.
|
||||
- **`crates/palette`** - Colour tokens, themes, and contrast math for the
|
||||
terminal UI.
|
||||
terminal UI. Its `ratatui` feature (on by default) gates everything that
|
||||
renders; theme ids, setting normalizers and hex parsing compile without it,
|
||||
which is how the runtime links it.
|
||||
- **`crates/paths`** - User-scoped runtime path authority (`CODEWHALE_HOME`
|
||||
and platform home resolution).
|
||||
- **`crates/protocol`** - Request/response framing and protocol types.
|
||||
- **`crates/secrets`** - OS keyring integration for API key storage.
|
||||
- **`crates/runtime`** - `codewhale-runtime`, the headless runtime being
|
||||
split out of `crates/tui` (`docs/design/TUI_DECONSTRUCTION.md`). Today it
|
||||
holds the leaf modules that moved first (retry status, safe labels, sleep
|
||||
guard, session tree, ...) and `host_terminal`, the one port through which
|
||||
runtime code asks the terminal UI for a terminal effect. It never depends on
|
||||
the TUI, `ratatui` or `crossterm`; `scripts/check-command-crate-boundaries.py`
|
||||
enforces that and ratchets the runtime -> UI references still in `crates/tui`.
|
||||
- **`crates/secrets`** - OS keyring integration for API key storage, plus the
|
||||
shared output sanitizer (`sanitize`) and redaction (`redact`) that UI and
|
||||
runtime code both call.
|
||||
- **`crates/state`** - SQLite thread/session persistence layer.
|
||||
- **`crates/telemetry`** - Anonymous, user-disableable aggregate usage
|
||||
counting; the only crate allowed to build or send a telemetry payload
|
||||
|
||||
@@ -62,7 +62,7 @@ a0-incremental-lib-test-timing.html, a0-llvm-lines-top40.txt).
|
||||
| Receipt | Wall | Load (1 min) |
|
||||
| --- | --- | --- |
|
||||
| Cold `cargo test -p codewhale-tui --lib --locked --no-run --timings` | 127 s (user 329 s) | 8.9 |
|
||||
| `touch crates/tui/src/elapsed.rs` + same command | 21 s | 12.3 |
|
||||
| `touch crates/runtime/src/elapsed.rs` + same command | 21 s | 12.3 |
|
||||
| `touch` + `cargo test -p codewhale-tui --lib --locked elapsed::` (the everyday loop) | 20 s (4 tests run) | 11.2 |
|
||||
| Lib-test binary size | 357 MB (`codewhale_tui-<hash>`); links with the `__eh_frame section too large (max 16MB)` compact-unwind warning | — |
|
||||
| `cargo check -p codewhale-tui --lib --tests` incremental after `touch` (frontend only) | 14 s | 6.2 |
|
||||
@@ -151,7 +151,7 @@ Defaults never contain a machine-specific absolute path:
|
||||
export CODEWHALE_CACHE_ROOT=/path/to/cache/codewhale
|
||||
|
||||
scripts/dev-cache.sh --self-check
|
||||
scripts/dev-test.sh crates/tui/src/elapsed.rs
|
||||
scripts/dev-test.sh crates/runtime/src/elapsed.rs
|
||||
CARGO_INCREMENTAL=0 scripts/dev-cargo.sh test -p codewhale-config --lib --locked --no-run
|
||||
```
|
||||
|
||||
@@ -180,7 +180,7 @@ helper now stays isolated unless `CODEWHALE_DEV_CACHE=local` or `0`.
|
||||
| Warm isolated same command (after the stub-target fix) | 0.13 s | `Finished` in 0.07 s |
|
||||
| `touch crates/config/src/lib.rs` + isolated `--no-run` | 0.93 s | only `codewhale-config` rebuilt |
|
||||
| First isolated `codewhale-tui --lib --no-run` | **121.5 s** (user 305 s) | 600 units; 340 MB binary; A0 empty-target was 127 s / 329 s |
|
||||
| `touch crates/tui/src/elapsed.rs` + isolated `--no-run` | **18.15 s** | everyday compile loop; A0 was 21 s / 19 s |
|
||||
| `touch crates/runtime/src/elapsed.rs` + isolated `--no-run` | **18.15 s** | everyday compile loop; A0 was 21 s / 19 s |
|
||||
| `CODEWHALE_SCCACHE=1` config `--no-run` on the already-warm tree | 5.21 s then 0.14 s | wrapper and `SCCACHE_DIR=…/sccache/<rustc-commit>` set; 0 sccache hits because only workspace crates recompiled and the build-dir was not emptied |
|
||||
|
||||
**Test-runtime**:
|
||||
@@ -189,7 +189,7 @@ helper now stays isolated unless `CODEWHALE_DEV_CACHE=local` or `0`.
|
||||
| --- | ---: | --- |
|
||||
| `scripts/dev-test.sh config` (nextest, 557 tests) | run 0.479 s / real 2.40 s | includes a 0.85 s profile flip compile |
|
||||
| `CODEWHALE_DEV_NEXTEST=0 scripts/dev-test.sh config` (libtest) | body 0.11 s / real 0.27 s | 557 tiny tests: process-per-test is slower here |
|
||||
| `scripts/dev-test.sh crates/tui/src/elapsed.rs` | run 0.023 s / real 2.81 s | 4 passed, 10,516 skipped; nextest filter works |
|
||||
| `scripts/dev-test.sh crates/runtime/src/elapsed.rs` | run 0.023 s / real 2.81 s | 4 passed, 10,516 skipped; nextest filter works |
|
||||
|
||||
The 268 s → ~100 s nextest win remains the earlier tui-unit-suite receipt.
|
||||
Config is too small for that win; nextest is still the right default for
|
||||
|
||||
@@ -57,10 +57,10 @@ that actively migrates `~/.deepseek/` → `~/.codewhale/` on first run and a dep
|
||||
| `crates/tui/src/prompts.rs` (`LEGACY_HANDOFF_RELATIVE_PATH = ".deepseek/handoff.md"`) | no — explicit legacy const | keep — explicit legacy handoff fallback |
|
||||
| `crates/tui/src/workspace_trust.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
| `crates/tui/src/session_manager.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
| `crates/tui/src/skill_state.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
| `crates/runtime/src/skill_state.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
| `crates/tui/src/tools/skill.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
| `crates/tui/src/snapshot/mod.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
| `crates/tui/src/workspace_discovery.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
| `crates/runtime/src/workspace_discovery.rs` | no — hardcoded | keep-as-fallback; follow-up |
|
||||
|
||||
## Follow-up (separate, non-doc change — out of scope for #3068)
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ re-roots to `~/.codewhale/memory/`:
|
||||
```
|
||||
|
||||
The scope directory is `workspace` (singular) — `MemoryScope::directory`,
|
||||
`crates/tui/src/native_memory.rs:31-36`. The index filename is
|
||||
`crates/runtime/src/native_memory.rs:31-36`. The index filename is
|
||||
`index.sqlite3` (`native_memory.rs:175`).
|
||||
|
||||
Markdown is the durable source of truth; `index.sqlite3` is a disposable
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ no hosted runtime to sell.
|
||||
2026-09-15 in favor of the canonical family. Web copies live in
|
||||
`web/public/brand/`.
|
||||
- Palette, type, shell direction, and the anti-slop rules are recorded in
|
||||
`docs/design/DESIGN.md`; the colour tokens are owned by `crates/palette/src/tokens.rs`
|
||||
`docs/design/DESIGN.md`; the colour tokens are owned by `crates/palette/src/rgb.rs`
|
||||
and exported to `web/app/tokens.css`.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
+1
-1
@@ -274,7 +274,7 @@ The workhorse. Everything a session accumulated ships here, once.
|
||||
|
||||
**`cold_start_bucket`** — from `startup_trace::elapsed_ms()`, which reads `PROCESS_START` directly and is independent of the startup summary's buffer clear (`crates/tui/src/startup_trace.rs:39-45`). Boundaries: `lt_250`, `250_1000`, `1000_3000`, `gte_3000`. Absent on non-TUI surfaces.
|
||||
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/tui/src/lib.rs:10220`), or `PlannedTurnRoute.effective_provider_label` (`crates/tui/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id is ever sent, for any provider** — `crates/tui/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/tui/src/lib.rs:10220`), or `PlannedTurnRoute.effective_provider_label` (`crates/tui/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
|
||||
|
||||
**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ same command with `--check` to reject drift. A source digest identifies the
|
||||
snapshot. Builds require no sibling checkout or network lookup for tokens.
|
||||
|
||||
The terminal's Whale, Blue Stage and Shoreline presets remain owned by
|
||||
`crates/palette/src/tokens.rs`. They are distinct themes; their copied GPUI
|
||||
`crates/palette/src/rgb.rs`. They are distinct themes; their copied GPUI
|
||||
constants no longer supply the website's colors.
|
||||
|
||||
## Theme and components
|
||||
|
||||
@@ -46,6 +46,81 @@ The current dependencies explain the blockage:
|
||||
not detect `run_subagent`'s execution cycle. A passing name scan is therefore
|
||||
insufficient evidence of one execution implementation.
|
||||
|
||||
## Runtime split sequence (2026-09-25, supersedes the order below)
|
||||
|
||||
The build-graph split runs first; the protocol-client contract follows it.
|
||||
Nothing here is a claim that a step has landed: `git log` and the ratchet
|
||||
(`scripts/runtime-boundary-baseline.json`) are the record.
|
||||
|
||||
1. **Rails.** `scripts/split/module_graph.py` computes the runtime closure
|
||||
(everything reachable from `core`, `tools`, `runtime_api`,
|
||||
`runtime_threads`, `client`, `llm_client`, `config` and
|
||||
`session_manager` without passing through `tui`, `commands`,
|
||||
`remote_control`, `context_report`, `composer_*` or `lib.rs`) and counts
|
||||
every reference from it into UI code, UI libraries, modules that move
|
||||
later, and UI intra-doc links. `scripts/check-command-crate-boundaries.py`
|
||||
runs it in CI: counts may only go down (RS-0).
|
||||
2. **Create `crates/runtime` (`codewhale-runtime`) with live code.** The
|
||||
first batch is the leaf modules that reference nothing outside the batch
|
||||
in production or test code and touch no UI library. The crate is
|
||||
publishable, because the published `codewhale-tui` depends on it (RS-2).
|
||||
3. **Cut the upward edges, one blocker per slice**, each lowering the
|
||||
ratchet: palette's `ratatui` dependency becomes an optional default
|
||||
feature (RS-3); the one `host_terminal` port carries every terminal side
|
||||
effect the runtime needs, starting with raw-mode suspension around
|
||||
interactive children (RS-4); voice capture splits from its slash command
|
||||
(RS-5); the context-window formatter moves to `utils` (RS-6);
|
||||
notification payload and sound policy move down while delivery (OSC
|
||||
writes, taskbar, title) stays in the TUI behind the port (RS-7); then the
|
||||
engine's terminal-chrome calls, auto-review and risk policy into
|
||||
`core::authority`, the remaining engine leaks, the command catalog, the
|
||||
`lib.rs` helpers, and the test-only references.
|
||||
4. **Move the strongly connected core in one rename-only change** (about 64
|
||||
modules; a crate cannot hold half a cycle), after landing its visibility
|
||||
and rustdoc edits in place. Then `runtime_api` and the modules above the
|
||||
core, then the headless surfaces, then delete the path alias.
|
||||
5. **Then the client contract** (behavioral, tracked separately): one engine
|
||||
owner in `runtime_threads`, runtime-owned queue and steer, approvals as
|
||||
server requests, a `runtime-client` facade, one protocol method table,
|
||||
and loop convergence.
|
||||
|
||||
`crates/runtime` must never depend on `codewhale-tui`, `codewhale-cli`,
|
||||
ratatui, crossterm, `ansi-to-tui` or a terminal component kit. The TUI is the
|
||||
one terminal-output owner: the runtime reaches the terminal only through
|
||||
`host_terminal`, which the composition root installs for every host it
|
||||
launches today. Withholding it from stdio hosts (ACP, MCP server,
|
||||
app-server) is a later one-line change, not a code move.
|
||||
|
||||
### Where this sequence departs from the rest of this document
|
||||
|
||||
These three departures are deliberate; each is safe for the stated reason.
|
||||
|
||||
1. **The config hub does not have to leave first.** Once the upward edges are
|
||||
cut, `config` sits inside the runtime's strongly connected component, so
|
||||
it moves with it. Splitting config into `codewhale-config` (#6034, #6143)
|
||||
becomes internal runtime work afterwards instead of a precondition.
|
||||
2. **Converging the two loops is not a precondition for moving the engine.**
|
||||
That rule assumed the engine would move while `run_subagent` stayed in the
|
||||
TUI, leaving two loops in two crates. Here both move into the same crate,
|
||||
so convergence stays a client-contract step and the one-loop guard keeps
|
||||
scanning every crate.
|
||||
3. **The move uses one path alias**, a single root
|
||||
`use codewhale_runtime::{...};` block in the TUI `lib.rs`, instead of
|
||||
rewriting every caller in the moving change. A rename-only move lets
|
||||
in-flight branches rebase across it; a content rewrite of every caller
|
||||
conflicts with all of them. The alias is the only shim: no wrapper types,
|
||||
no per-item re-exports except the ones `crates/cli` needs until the
|
||||
headless surfaces move, and it is deleted by a published rewrite script
|
||||
once the moves are done.
|
||||
|
||||
The destination below still describes the intended layering, with one
|
||||
change: the engine, turn loop and tools move into `codewhale-runtime`
|
||||
together (there is no separate `codewhale-engine` crate), because the
|
||||
engine, tools, config and client form one dependency cycle today.
|
||||
`crates/core` stays the lower request-construction layer:
|
||||
`codewhale-command-contract` depends on it, and the runtime needs the
|
||||
contract, so putting the runtime into `core` would be a Cargo cycle.
|
||||
|
||||
## Intended ownership
|
||||
|
||||
This is the proposed destination, not a claim that the boundaries exist now.
|
||||
@@ -136,8 +211,9 @@ review or multiple candidate attempts only where measured failures and task
|
||||
stakes justify the extra compute. No model-evaluation runs, provider spend, or
|
||||
performance gains were established by this source audit.
|
||||
|
||||
## Implementation order
|
||||
## Implementation order (before 2026-09-25)
|
||||
|
||||
The runtime split sequence above replaces this order where they differ.
|
||||
Every packet names the predecessor, all consumers, changed dependency edges,
|
||||
and its verification. One owner handles shared manifests and integration.
|
||||
Keep unrelated active work intact; follow the current workspace authority.
|
||||
|
||||
@@ -35,7 +35,7 @@ Underwater's continuous water column is authored by `OceanRamp::for_theme` in
|
||||
`crates/tui/src/tui/ocean.rs`: dark navy at the top, deeper near the composer.
|
||||
Its existing motion policy preserves reduced/still modes and semantic surfaces.
|
||||
|
||||
For the charcoal alternative, the source of truth is `crates/palette/src/tokens.rs` and the
|
||||
For the charcoal alternative, the source of truth is `crates/palette/src/rgb.rs` and the
|
||||
`SHORELINE_UI_THEME` / `SHORELINE_LIGHT_UI_THEME` mappings in
|
||||
`crates/palette/src/themes.rs`. The dark palette is unchanged by the
|
||||
Workbench index pass:
|
||||
|
||||
@@ -45,7 +45,7 @@ invocation, and applies the isolated build-dir topology
|
||||
|
||||
```bash
|
||||
scripts/dev-test.sh --list
|
||||
scripts/dev-test.sh crates/tui/src/elapsed.rs # path → area + filter
|
||||
scripts/dev-test.sh crates/runtime/src/elapsed.rs # path → area + filter
|
||||
scripts/dev-test.sh tui tools:: # area + filter
|
||||
scripts/dev-test.sh config
|
||||
```
|
||||
|
||||
@@ -60,8 +60,8 @@ Stage 2 of the loop: [cw-orient](../cw-orient/SKILL.md) → **slice** →
|
||||
- `crates/tui/src/core/` is a module inside the TUI crate. `crates/core` is a
|
||||
different crate that runs no turns. Do not confuse them.
|
||||
- Repeatedly misidentified as dead, verify consumers before removing:
|
||||
`tui/src/context_budget.rs`, `tui/src/model_registry.rs`,
|
||||
`tui/src/prompt_zones.rs`, `tui/src/tools/remember.rs`, `config/src/route/`.
|
||||
`runtime/src/context_budget.rs`, `tui/src/model_registry.rs`,
|
||||
`runtime/src/prompt_zones.rs`, `tui/src/tools/remember.rs`, `config/src/route/`.
|
||||
|
||||
4. **Read the scoped guidance for the files you will touch.** `crates/tui/AGENTS.md`
|
||||
owns the UI contracts (one owner per fact, `codewhale_palette::grammar` semantics, typed
|
||||
|
||||
@@ -163,7 +163,7 @@ Codewhale 没有恢复出厂设置命令,因此本文档也不会声称有。
|
||||
|
||||
**`cold_start_bucket`** ——来自 `startup_trace::elapsed_ms()`,它直接读取 `PROCESS_START`,并且独立于启动摘要的缓冲区清空(`crates/tui/src/startup_trace.rs:39-45`)。边界:`lt_250`、`250_1000`、`1000_3000`、`gte_3000`。非 TUI surface 上缺席。
|
||||
|
||||
**`providers`** ——`ProviderKind::as_str()`(`crates/config/src/provider_kind.rs:295`,来自封闭枚举的 `&'static str`;`Custom` 产生字面量 `"custom"`)的排序、去重数组。**API 按值接收 `ProviderKind`,绝不接收 `&str`。** 不要调用 `ProviderKind::parse` 或 `parse_config_identity`(`:300`、`:330`)——那些用于配置表解析。**不要读取** `provider_identity_for_persistence()`(`crates/tui/src/tui/app.rs:5049`)、`provider_id_for_persistence()`(`:5058`)、`ExecStreamMeta.provider_id`(`crates/tui/src/lib.rs:10220`)或 `PlannedTurnRoute.effective_provider_label`(`crates/tui/src/turn_route_plan.rs:189-193`)——当路由是 Custom 时,这四个都会返回客户自己的 `[providers.<name>]` 表键。这是该功能最可能的泄露点:它离天然接缝只差一个字段,而且 `/status` 已经会打印它(`crates/tui/src/commands/groups/config/status.rs:24-28`)。**任何 provider 都绝不发送 model id**——`crates/tui/src/safe_label.rs:11-15` 记录了一个事实:model id 可以是路径、URL 或本身就是凭据的部署 id。
|
||||
**`providers`** ——`ProviderKind::as_str()`(`crates/config/src/provider_kind.rs:295`,来自封闭枚举的 `&'static str`;`Custom` 产生字面量 `"custom"`)的排序、去重数组。**API 按值接收 `ProviderKind`,绝不接收 `&str`。** 不要调用 `ProviderKind::parse` 或 `parse_config_identity`(`:300`、`:330`)——那些用于配置表解析。**不要读取** `provider_identity_for_persistence()`(`crates/tui/src/tui/app.rs:5049`)、`provider_id_for_persistence()`(`:5058`)、`ExecStreamMeta.provider_id`(`crates/tui/src/lib.rs:10220`)或 `PlannedTurnRoute.effective_provider_label`(`crates/tui/src/turn_route_plan.rs:189-193`)——当路由是 Custom 时,这四个都会返回客户自己的 `[providers.<name>]` 表键。这是该功能最可能的泄露点:它离天然接缝只差一个字段,而且 `/status` 已经会打印它(`crates/tui/src/commands/groups/config/status.rs:24-28`)。**任何 provider 都绝不发送 model id**——`crates/runtime/src/safe_label.rs:11-15` 记录了一个事实:model id 可以是路径、URL 或本身就是凭据的部署 id。
|
||||
|
||||
**`counters`** ——封闭字段集。每次递增都发生在**调用点**,绝不在条件进入的处理器内部:
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
"crates/release/src/launch.rs": {
|
||||
"std_fs": 2
|
||||
},
|
||||
"crates/runtime/src/media_originals.rs": {
|
||||
"path_canonicalize": 2,
|
||||
"std_fs": 6
|
||||
},
|
||||
"crates/secrets/src/file_lock.rs": {
|
||||
"path_canonicalize": 1
|
||||
},
|
||||
@@ -126,9 +130,6 @@
|
||||
"crates/tui/src/commands/groups/core/core.rs": {
|
||||
"path_canonicalize": 1
|
||||
},
|
||||
"crates/tui/src/commands/groups/core/voice.rs": {
|
||||
"std_fs": 6
|
||||
},
|
||||
"crates/tui/src/commands/groups/plugins/render.rs": {
|
||||
"path_canonicalize": 1
|
||||
},
|
||||
@@ -253,10 +254,6 @@
|
||||
"crates/tui/src/mcp_server.rs": {
|
||||
"std_fs": 1
|
||||
},
|
||||
"crates/tui/src/media_originals.rs": {
|
||||
"path_canonicalize": 2,
|
||||
"std_fs": 6
|
||||
},
|
||||
"crates/tui/src/models_dev_live.rs": {
|
||||
"std_fs": 1
|
||||
},
|
||||
@@ -559,6 +556,9 @@
|
||||
"crates/tui/src/vision/tools.rs": {
|
||||
"path_canonicalize": 2
|
||||
},
|
||||
"crates/tui/src/voice.rs": {
|
||||
"std_fs": 6
|
||||
},
|
||||
"crates/tui/src/working_set.rs": {
|
||||
"path_canonicalize": 1,
|
||||
"std_fs": 1
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic crate-boundary gate for the command extraction (FEAT-014).
|
||||
"""Deterministic crate-boundary gate (FEAT-014, runtime split RS-0).
|
||||
|
||||
The gate is table-driven: `BOUNDARY_RULES` names each guarded package, how its
|
||||
dependency graph is read (`metadata` or `tree`, see below), the packages it
|
||||
may never reach, and the source scan for its crate. The runtime/TUI split adds
|
||||
the runtime -> UI reference ratchet (`scripts/split/module_graph.py`).
|
||||
|
||||
Dependency modes:
|
||||
|
||||
* ``metadata`` reads `cargo metadata --no-deps` and walks normal edges between
|
||||
workspace packages. Cheap and exact for "never reach this workspace crate".
|
||||
* ``tree`` runs `cargo tree -p <package> -e normal,build --prefix none`, which
|
||||
resolves features for that package alone. `cargo metadata` unifies features
|
||||
across the workspace, so a rule like "the runtime never reaches ratatui"
|
||||
must use this mode once the TUI enables palette's `ratatui` feature.
|
||||
|
||||
Enforces the EPIC-006 boundary contract:
|
||||
|
||||
@@ -25,23 +39,55 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONTRACT_DIR = REPO_ROOT / "crates" / "command-contract" / "src"
|
||||
CONTRACT_PACKAGE = "codewhale-command-contract"
|
||||
FORBIDDEN_TUI_PACKAGE = "codewhale-tui"
|
||||
|
||||
# Workspace packages that must stay free of any (normal) path to the TUI. The
|
||||
# contract carries the portable shapes; `codewhale-secrets` owns the shared pure
|
||||
# sanitizer those shapes' handlers consume. Both are prerequisites for
|
||||
# `codewhale-commands` (FEAT-016/043), so a TUI edge here would silently drag
|
||||
# the whole TUI into the extracted command crate.
|
||||
TUI_FREE_PACKAGES = (CONTRACT_PACKAGE, "codewhale-secrets")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundaryRule:
|
||||
"""One guarded package: how to read its graph and what it may not reach."""
|
||||
|
||||
package: str
|
||||
mode: str # "metadata" or "tree"
|
||||
forbidden_packages: tuple[str, ...]
|
||||
reason: str
|
||||
source_dir: Path | None = None
|
||||
source_scan: Callable[[str, str], list] | None = None
|
||||
|
||||
|
||||
RUNTIME_PACKAGE = "codewhale-runtime"
|
||||
RUNTIME_DIR = REPO_ROOT / "crates" / "runtime" / "src"
|
||||
RUNTIME_FORBIDDEN_PACKAGES = (
|
||||
"codewhale-tui",
|
||||
"codewhale-cli",
|
||||
"ratatui",
|
||||
"ratatui-core",
|
||||
"ratatui-widgets",
|
||||
"crossterm",
|
||||
"ansi-to-tui",
|
||||
"codewhale-tui-kit",
|
||||
"codewhale-ratatui",
|
||||
)
|
||||
RUNTIME_FORBIDDEN_SOURCE = [
|
||||
(re.compile(r"\b(ratatui|crossterm|codewhale_tui)::"), "terminal UI path"),
|
||||
(re.compile(r"^\s*(pub\s+)?use\s+(ratatui|crossterm|codewhale_tui)\b"), "terminal UI import"),
|
||||
(re.compile(r"include_(str|bytes)!\(\s*\"[^\"]*\.\./tui/"), "include reaching into crates/tui"),
|
||||
]
|
||||
|
||||
# Filled in below once the scan functions exist.
|
||||
BOUNDARY_RULES: tuple[BoundaryRule, ...] = ()
|
||||
|
||||
# Import lines that must never appear in the contract (narrowly scoped: real
|
||||
# imports only, comments never match because they do not start with `use`).
|
||||
@@ -117,25 +163,39 @@ def dependency_graph(metadata: dict) -> dict[str, set[str]]:
|
||||
return graph
|
||||
|
||||
|
||||
def reaches_tui(package: str, graph: dict[str, set[str]]) -> bool:
|
||||
"""Whether `package` transitively reaches the forbidden TUI package."""
|
||||
def reaches(package: str, forbidden: set[str], graph: dict[str, set[str]]) -> str | None:
|
||||
"""The first forbidden package `package` transitively reaches, if any."""
|
||||
seen: set[str] = set()
|
||||
stack = list(graph.get(package, set()))
|
||||
while stack:
|
||||
name = stack.pop()
|
||||
if name == FORBIDDEN_TUI_PACKAGE:
|
||||
return True
|
||||
if name in forbidden:
|
||||
return name
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
stack.extend(graph.get(name, set()))
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def reaches_tui(package: str, graph: dict[str, set[str]]) -> bool:
|
||||
"""Whether `package` transitively reaches the forbidden TUI package."""
|
||||
return reaches(package, {FORBIDDEN_TUI_PACKAGE}, graph) is not None
|
||||
|
||||
|
||||
def metadata_rules() -> tuple[BoundaryRule, ...]:
|
||||
return tuple(rule for rule in BOUNDARY_RULES if rule.mode == "metadata")
|
||||
|
||||
|
||||
def tree_rules() -> tuple[BoundaryRule, ...]:
|
||||
return tuple(rule for rule in BOUNDARY_RULES if rule.mode == "tree")
|
||||
|
||||
|
||||
def check_dependency_graph(graph: dict[str, set[str]]) -> list[BoundaryViolation]:
|
||||
"""No TUI-free package may reach codewhale-tui through normal edges."""
|
||||
"""No metadata-mode package may reach a forbidden package through normal edges."""
|
||||
violations: list[BoundaryViolation] = []
|
||||
for package in TUI_FREE_PACKAGES:
|
||||
for rule in metadata_rules():
|
||||
package = rule.package
|
||||
if package not in graph:
|
||||
violations.append(
|
||||
BoundaryViolation(
|
||||
@@ -145,17 +205,56 @@ def check_dependency_graph(graph: dict[str, set[str]]) -> list[BoundaryViolation
|
||||
)
|
||||
)
|
||||
continue
|
||||
if reaches_tui(package, graph):
|
||||
hit = reaches(package, set(rule.forbidden_packages), graph)
|
||||
if hit:
|
||||
violations.append(
|
||||
BoundaryViolation(
|
||||
"dependency-graph",
|
||||
package,
|
||||
f"transitively depends on {FORBIDDEN_TUI_PACKAGE}",
|
||||
f"transitively depends on {hit} ({rule.reason})",
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def cargo_tree_packages(package: str) -> set[str]:
|
||||
"""Package names in `cargo tree -p <package> -e normal,build` (per-package features)."""
|
||||
result = subprocess.run(
|
||||
# `--target all`: a dependency behind `cfg(windows)` counts too, not
|
||||
# only the ones the host target resolves.
|
||||
[
|
||||
"cargo", "tree", "-p", package, "-e", "normal,build", "--target", "all",
|
||||
"--prefix", "none", "--locked",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return parse_cargo_tree(result.stdout)
|
||||
|
||||
|
||||
def parse_cargo_tree(text: str) -> set[str]:
|
||||
names: set[str] = set()
|
||||
for line in text.splitlines():
|
||||
parts = line.split()
|
||||
if parts:
|
||||
names.add(parts[0])
|
||||
return names
|
||||
|
||||
|
||||
def check_tree_packages(rule: BoundaryRule, names: set[str]) -> list[BoundaryViolation]:
|
||||
"""A tree-mode package's resolved dependency set must avoid its forbidden list."""
|
||||
return [
|
||||
BoundaryViolation(
|
||||
"dependency-tree",
|
||||
rule.package,
|
||||
f"cargo tree reaches {name} ({rule.reason})",
|
||||
)
|
||||
for name in sorted(names & set(rule.forbidden_packages))
|
||||
]
|
||||
|
||||
|
||||
def check_contract_source_text(text: str, display_path: str) -> list[BoundaryViolation]:
|
||||
"""Scan one source text for forbidden imports/symbols (hermetic test hook)."""
|
||||
violations: list[BoundaryViolation] = []
|
||||
@@ -189,35 +288,96 @@ def check_contract_source_text(text: str, display_path: str) -> list[BoundaryVio
|
||||
return violations
|
||||
|
||||
|
||||
def check_contract_source() -> list[BoundaryViolation]:
|
||||
"""Scan contract production source for forbidden imports and symbols."""
|
||||
violations: list[BoundaryViolation] = []
|
||||
if not CONTRACT_DIR.is_dir():
|
||||
def check_source_dir(rule: BoundaryRule) -> list[BoundaryViolation]:
|
||||
"""Scan one rule's production source for forbidden imports and symbols."""
|
||||
assert rule.source_dir is not None and rule.source_scan is not None
|
||||
if not rule.source_dir.is_dir():
|
||||
return [
|
||||
BoundaryViolation(
|
||||
"source-scan",
|
||||
str(CONTRACT_DIR),
|
||||
"command-contract src directory missing",
|
||||
str(rule.source_dir),
|
||||
f"{rule.package} src directory missing",
|
||||
)
|
||||
]
|
||||
for path in sorted(CONTRACT_DIR.rglob("*.rs")):
|
||||
violations: list[BoundaryViolation] = []
|
||||
for path in sorted(rule.source_dir.rglob("*.rs")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
violations.extend(check_contract_source_text(text, str(rel)))
|
||||
violations.extend(rule.source_scan(text, str(rel)))
|
||||
return violations
|
||||
|
||||
|
||||
def run_checks(metadata: dict | None = None) -> list[BoundaryViolation]:
|
||||
def check_runtime_source_text(text: str, display_path: str) -> list[BoundaryViolation]:
|
||||
"""Runtime source may not name a terminal UI crate (comments ignored)."""
|
||||
violations: list[BoundaryViolation] = []
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
code = line.split("//", 1)[0]
|
||||
if not code.strip():
|
||||
continue
|
||||
for pattern, label in RUNTIME_FORBIDDEN_SOURCE:
|
||||
if pattern.search(code):
|
||||
violations.append(
|
||||
BoundaryViolation(
|
||||
"source-scan",
|
||||
f"{display_path}:{line_no}",
|
||||
f"forbidden {label} in codewhale-runtime: {line.strip()}",
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_contract_source() -> list[BoundaryViolation]:
|
||||
"""Scan contract production source for forbidden imports and symbols."""
|
||||
return check_source_dir(next(r for r in BOUNDARY_RULES if r.package == CONTRACT_PACKAGE))
|
||||
|
||||
|
||||
def load_runtime_ratchet():
|
||||
"""Import scripts/split/module_graph.py (the runtime -> UI ratchet)."""
|
||||
path = REPO_ROOT / "scripts" / "split" / "module_graph.py"
|
||||
spec = importlib.util.spec_from_file_location("runtime_module_graph", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def check_runtime_ratchet(baseline_ref: str | None = None) -> list[BoundaryViolation]:
|
||||
"""Runtime -> UI references may only go down (docs/design/TUI_DECONSTRUCTION.md, runtime split)."""
|
||||
problems = load_runtime_ratchet().check(baseline_ref=baseline_ref)
|
||||
return [BoundaryViolation("runtime-ratchet", "scripts/runtime-boundary-baseline.json", p) for p in problems]
|
||||
|
||||
|
||||
def run_checks(
|
||||
metadata: dict | None = None,
|
||||
tree: Callable[[str], set[str]] | None = None,
|
||||
ratchet: bool = True,
|
||||
baseline_ref: str | None = None,
|
||||
) -> list[BoundaryViolation]:
|
||||
"""Run all boundary checks; return the collected violations."""
|
||||
graph = dependency_graph(metadata) if metadata is not None else dependency_graph(
|
||||
load_workspace_metadata()
|
||||
)
|
||||
return check_dependency_graph(graph) + check_contract_source()
|
||||
violations = check_dependency_graph(graph)
|
||||
tree = tree or cargo_tree_packages
|
||||
for rule in tree_rules():
|
||||
violations.extend(check_tree_packages(rule, tree(rule.package)))
|
||||
for rule in BOUNDARY_RULES:
|
||||
if rule.source_dir is not None:
|
||||
violations.extend(check_source_dir(rule))
|
||||
if ratchet:
|
||||
violations.extend(check_runtime_ratchet(baseline_ref))
|
||||
return violations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
del argv # reserved for future flags (e.g. --update); check is the default
|
||||
violations = run_checks()
|
||||
parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0])
|
||||
parser.add_argument(
|
||||
"--baseline-ref",
|
||||
help="git revision (CI: the PR base) whose runtime ratchet baseline may not be exceeded",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
violations = run_checks(baseline_ref=args.baseline_ref)
|
||||
if violations:
|
||||
print("[command-crate-boundaries] FAIL", file=sys.stderr)
|
||||
for violation in violations:
|
||||
@@ -225,11 +385,46 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 1
|
||||
print(
|
||||
f"[command-crate-boundaries] PASS: "
|
||||
f"{', '.join(TUI_FREE_PACKAGES)} have no {FORBIDDEN_TUI_PACKAGE} edge; "
|
||||
"no forbidden import, composite context, or boxed handler in the contract"
|
||||
f"{', '.join(rule.package for rule in BOUNDARY_RULES)} avoid their forbidden "
|
||||
"packages; no forbidden import, composite context, or boxed handler in the "
|
||||
"contract; runtime -> UI ratchet holds"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
BOUNDARY_RULES = (
|
||||
# The contract carries the portable command shapes (EPIC-006).
|
||||
BoundaryRule(
|
||||
CONTRACT_PACKAGE,
|
||||
"metadata",
|
||||
(FORBIDDEN_TUI_PACKAGE,),
|
||||
"the command contract must stay UI-free",
|
||||
CONTRACT_DIR,
|
||||
check_contract_source_text,
|
||||
),
|
||||
# `codewhale-secrets` owns the shared pure sanitizer the contract's
|
||||
# handlers consume (FEAT-025 D4); a TUI edge would drag the TUI into
|
||||
# `codewhale-commands` (FEAT-016/043).
|
||||
BoundaryRule(
|
||||
"codewhale-secrets",
|
||||
"metadata",
|
||||
(FORBIDDEN_TUI_PACKAGE,),
|
||||
"the shared sanitizer must stay UI-free",
|
||||
),
|
||||
# The headless runtime split out of the TUI (docs/design/TUI_DECONSTRUCTION.md):
|
||||
# never a terminal UI crate or library, checked with per-package feature
|
||||
# resolution because the TUI turns on palette's `ratatui` feature.
|
||||
BoundaryRule(
|
||||
RUNTIME_PACKAGE,
|
||||
"tree",
|
||||
RUNTIME_FORBIDDEN_PACKAGES,
|
||||
"the runtime must not link terminal UI code",
|
||||
RUNTIME_DIR,
|
||||
check_runtime_source_text,
|
||||
),
|
||||
)
|
||||
TUI_FREE_PACKAGES = tuple(rule.package for rule in metadata_rules())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -611,7 +611,7 @@ def check_fragment_caps() -> None:
|
||||
)
|
||||
|
||||
# TUI must be unified with the core boundary (shared crates/core module)
|
||||
tui_fragment = REPO_ROOT / "crates" / "tui" / "src" / "model_context" / "fragment.rs"
|
||||
tui_fragment = REPO_ROOT / "crates" / "runtime" / "src" / "model_context" / "fragment.rs"
|
||||
try:
|
||||
tui_text = tui_fragment.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as error:
|
||||
|
||||
+9
-1
@@ -15,7 +15,7 @@
|
||||
# Examples:
|
||||
# scripts/dev-test.sh config
|
||||
# scripts/dev-test.sh tui elapsed::
|
||||
# scripts/dev-test.sh crates/tui/src/elapsed.rs
|
||||
# scripts/dev-test.sh crates/runtime/src/elapsed.rs
|
||||
#
|
||||
# Environment:
|
||||
# CODEWHALE_DEV_NEXTEST auto|1|0 (default auto: use cargo-nextest when
|
||||
@@ -53,6 +53,7 @@ mcp cargo test -p codewhale-mcp --lib --locked
|
||||
paths cargo test -p codewhale-paths --lib --locked
|
||||
protocol cargo test -p codewhale-protocol --lib --locked
|
||||
release cargo test -p codewhale-release --lib --locked
|
||||
runtime cargo test -p codewhale-runtime --lib --locked
|
||||
secrets cargo test -p codewhale-secrets --lib --locked
|
||||
state cargo test -p codewhale-state --lib --locked
|
||||
telemetry cargo test -p codewhale-telemetry --lib --locked
|
||||
@@ -71,6 +72,7 @@ crates/tui/src/tools/ tui tools::
|
||||
crates/tui/src/core/ tui core::
|
||||
crates/tui/src/commands/ tui commands::
|
||||
crates/tui/src/<file>.rs tui <file>::
|
||||
crates/runtime/src/<file>.rs runtime <file>::
|
||||
crates/tui/tests/integration/ tui-integration <stem>
|
||||
crates/tui/tests/cucumber/ tui-cucumber <stem>
|
||||
crates/tui/tests/ tui-integration
|
||||
@@ -141,6 +143,11 @@ if [ -e "$area" ] || printf '%s' "$area" | grep -q /; then
|
||||
crates/tui/*|crates/tui)
|
||||
area=tui
|
||||
;;
|
||||
crates/runtime/src/*)
|
||||
area=runtime
|
||||
extra=$(printf '%s' "${rel#crates/runtime/src/}" | awk -F/ '{print $1}')
|
||||
extra=${extra%.rs}::
|
||||
;;
|
||||
crates/*)
|
||||
crate=$(printf '%s' "$rel" | awk -F/ '{print $2}')
|
||||
area=$crate
|
||||
@@ -175,6 +182,7 @@ case $area in
|
||||
paths) pkg=codewhale-paths ;;
|
||||
protocol) pkg=codewhale-protocol ;;
|
||||
release) pkg=codewhale-release ;;
|
||||
runtime) pkg=codewhale-runtime ;;
|
||||
secrets) pkg=codewhale-secrets ;;
|
||||
state) pkg=codewhale-state ;;
|
||||
telemetry) pkg=codewhale-telemetry ;;
|
||||
|
||||
@@ -7,7 +7,7 @@ this site's existing light/OS-dark/pinned-dark roles. The portable artifact's
|
||||
own generator checks its digest before exporting. Update the entire vendored
|
||||
folder from the app's design package; never edit its JSON here.
|
||||
|
||||
`crates/palette/src/tokens.rs` still owns the terminal's WHALE, LIGHT and
|
||||
`crates/palette/src/rgb.rs` still owns the terminal's WHALE, LIGHT and
|
||||
SHORELINE presets. They remain available to terminal-specific illustrations;
|
||||
the public site's GPUI aliases no longer copy the Rust palette's mirrors.
|
||||
|
||||
@@ -30,8 +30,8 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
TOKENS_RS = REPO / "crates/palette/src/tokens.rs"
|
||||
SOURCE_LABEL = "crates/palette/src/tokens.rs + vendor/codewhale-design/tokens.json"
|
||||
TOKENS_RS = REPO / "crates/palette/src/rgb.rs"
|
||||
SOURCE_LABEL = "crates/palette/src/rgb.rs + vendor/codewhale-design/tokens.json"
|
||||
GPUI_DIR = REPO / "vendor/codewhale-design"
|
||||
|
||||
CONST_RE = re.compile(
|
||||
|
||||
@@ -32,6 +32,10 @@ release_crates=(
|
||||
codewhale-palette
|
||||
# Scoped memory store; tui's native memory backend. No workspace deps.
|
||||
codewhale-memory
|
||||
# Headless runtime split out of the TUI (docs/design/TUI_DECONSTRUCTION.md).
|
||||
# Depends on config, core, memory and models; the published tui depends on
|
||||
# it, so it publishes after those and before tui.
|
||||
codewhale-runtime
|
||||
codewhale-tui
|
||||
codewhale-app-server
|
||||
codewhale-cli
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"_comment": "Runtime -> UI boundary ratchet (docs/design/TUI_DECONSTRUCTION.md, runtime split). Counts may only go down. Regenerate with python3 scripts/split/module_graph.py --update after removing references; never raise a count by hand.",
|
||||
"totals": {
|
||||
"prod": 75,
|
||||
"test": 89,
|
||||
"late": 2,
|
||||
"uilib": 1,
|
||||
"doc": 1
|
||||
},
|
||||
"counts": {
|
||||
"prod": {
|
||||
"config|tui": 23,
|
||||
"core|tui": 28,
|
||||
"fleet|lib.rs": 1,
|
||||
"local_ollama|tui": 1,
|
||||
"runtime_api|commands": 9,
|
||||
"runtime_api|tui": 1,
|
||||
"runtime_threads|lib.rs": 2,
|
||||
"tools|commands": 2,
|
||||
"tools|tui": 8
|
||||
},
|
||||
"test": {
|
||||
"client|tui": 3,
|
||||
"config|tui": 9,
|
||||
"core|tui": 40,
|
||||
"doctor|lib.rs": 5,
|
||||
"fleet|lib.rs": 4,
|
||||
"prompts|tui": 1,
|
||||
"runtime_api|commands": 2,
|
||||
"runtime_api|lib.rs": 1,
|
||||
"session_manager|tui": 3,
|
||||
"test_support|tui": 7,
|
||||
"tools|tui": 14
|
||||
},
|
||||
"late": {
|
||||
"config_persistence|route_preferences": 1,
|
||||
"core|exec_agent": 1
|
||||
},
|
||||
"uilib": {
|
||||
"tools|crossterm": 1
|
||||
},
|
||||
"doc": {
|
||||
"core|tui": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runtime -> UI boundary ratchet for the runtime/TUI crate split.
|
||||
|
||||
Builds the top-level module graph of `crates/tui/src` and `crates/runtime/src`
|
||||
with a lexer that masks comments and string literals and expands grouped
|
||||
`use crate::{...}` imports, then counts every reference from the *runtime
|
||||
closure* upward into UI code.
|
||||
|
||||
The runtime closure is every module reachable over production edges from the
|
||||
eight seed modules (`core`, `tools`, `runtime_api`, `runtime_threads`,
|
||||
`client`, `llm_client`, `config`, `session_manager`) without passing through a
|
||||
UI module, plus every module that already lives in `crates/runtime`. It is
|
||||
computed on every run; the member list is never written by hand.
|
||||
|
||||
Categories counted (keyed `from|to`):
|
||||
|
||||
* ``prod`` production references from the closure into UI modules
|
||||
(`tui`, `commands`, `remote_control`, `context_report`,
|
||||
`composer_*`) or into private crate-root (`lib.rs`) items;
|
||||
* ``test`` the same, from `#[cfg(test)]` code and test files;
|
||||
* ``late`` references (production or test) from the closure into modules
|
||||
outside it that are not UI either, i.e. modules that move after
|
||||
the strongly connected core (`exec_agent`, `route_preferences`,
|
||||
...). A test edge constrains a crate move exactly like a
|
||||
production edge;
|
||||
* ``uilib`` closure files that use a UI library (`ratatui`, `crossterm`,
|
||||
`codewhale_tui`), keyed `module|library`. `codewhale_palette` is
|
||||
not one since its `ratatui` feature became optional (RS-3); the
|
||||
runtime links it with `default-features = false`;
|
||||
* ``doc`` intra-doc links in closure doc comments that point at UI code
|
||||
(`crate::tui::...`, `crate::commands::...`). rustdoc checks them
|
||||
once the module is public in `codewhale-runtime`.
|
||||
|
||||
The baseline is `scripts/runtime-boundary-baseline.json`. `--check` (default)
|
||||
fails on any increased count or new key and prints `file:line` for it; it also
|
||||
fails when a count dropped and the baseline was not lowered in the same change,
|
||||
so the baseline stays honest. `--update` rewrites the baseline and refuses to
|
||||
raise any count. `--baseline-ref REV` (CI passes the PR base) also fails when
|
||||
the committed baseline holds any count above the baseline at REV, so a
|
||||
hand-edited JSON cannot raise the ratchet either.
|
||||
|
||||
`super::` chains that climb to the crate root count like `crate::` paths.
|
||||
|
||||
See docs/design/TUI_DECONSTRUCTION.md (runtime split, ratchet).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TUI_SRC = REPO_ROOT / "crates" / "tui" / "src"
|
||||
RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src"
|
||||
BASELINE_REPO_PATH = "scripts/runtime-boundary-baseline.json"
|
||||
BASELINE = REPO_ROOT / BASELINE_REPO_PATH
|
||||
|
||||
SEEDS = (
|
||||
"core",
|
||||
"tools",
|
||||
"runtime_api",
|
||||
"runtime_threads",
|
||||
"client",
|
||||
"llm_client",
|
||||
"config",
|
||||
"session_manager",
|
||||
)
|
||||
UI_MODULES = {"tui", "commands", "remote_control", "context_report"}
|
||||
UI_PREFIXES = ("composer_",)
|
||||
ROOT_ITEMS = "lib.rs"
|
||||
CATEGORIES = ("prod", "test", "late", "uilib", "doc")
|
||||
|
||||
HINTS = {
|
||||
"tui": "move the item down (e.g. into core::authority) or reach the UI "
|
||||
"through the host_terminal port",
|
||||
"commands": "read commands through the runtime CommandCatalog, or split "
|
||||
"the non-UI half of the command into a runtime module",
|
||||
ROOT_ITEMS: "move the crate-root helper into the runtime module that owns it",
|
||||
}
|
||||
|
||||
|
||||
def is_ui(module: str) -> bool:
|
||||
return (
|
||||
module in UI_MODULES
|
||||
or module.startswith(UI_PREFIXES)
|
||||
or module in (ROOT_ITEMS, "main.rs")
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lexer: blank comments, string and char literals; keep offsets and newlines.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_RAW_STR = re.compile(r'b?r(#*)"')
|
||||
_CHAR = re.compile(r"'(\\.[^']*|[^\\'])'")
|
||||
|
||||
|
||||
def lex_mask(src: str) -> str:
|
||||
out = list(src)
|
||||
i, n = 0, len(src)
|
||||
|
||||
def blank(a: int, b: int) -> None:
|
||||
for k in range(a, min(b, n)):
|
||||
if out[k] != "\n":
|
||||
out[k] = " "
|
||||
|
||||
while i < n:
|
||||
c = src[i]
|
||||
if src.startswith("//", i):
|
||||
j = src.find("\n", i)
|
||||
j = n if j < 0 else j
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
if src.startswith("/*", i):
|
||||
depth, j = 1, i + 2
|
||||
while j < n and depth:
|
||||
if src.startswith("/*", j):
|
||||
depth += 1
|
||||
j += 2
|
||||
elif src.startswith("*/", j):
|
||||
depth -= 1
|
||||
j += 2
|
||||
else:
|
||||
j += 1
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
prev_ident = i > 0 and (src[i - 1].isalnum() or src[i - 1] == "_")
|
||||
m = _RAW_STR.match(src, i, i + 10)
|
||||
if m and not prev_ident:
|
||||
end = '"' + m.group(1)
|
||||
j = src.find(end, i + len(m.group(0)))
|
||||
j = n if j < 0 else j + len(end)
|
||||
blank(i + 1, j - 1)
|
||||
i = j
|
||||
continue
|
||||
if c == '"' or (c == "b" and i + 1 < n and src[i + 1] == '"' and not prev_ident):
|
||||
j = i + (2 if c == "b" else 1)
|
||||
while j < n and src[j] != '"':
|
||||
j += 2 if src[j] == "\\" else 1
|
||||
blank(i + 1, j)
|
||||
i = j + 1
|
||||
continue
|
||||
if c == "'":
|
||||
m = _CHAR.match(src, i, i + 12)
|
||||
if m:
|
||||
blank(i + 1, i + len(m.group(0)) - 1)
|
||||
i += len(m.group(0))
|
||||
continue
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def match_brace(code: str, start: int) -> int:
|
||||
depth = 0
|
||||
for k in range(start, len(code)):
|
||||
if code[k] == "{":
|
||||
depth += 1
|
||||
elif code[k] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return k
|
||||
return len(code) - 1
|
||||
|
||||
|
||||
CFG_TEST = re.compile(
|
||||
r"#\[cfg\((?:test|all\(test[^\]]*\)|any\(test[^\]]*\)|feature\s*=\s*\"test-support\")\)\]"
|
||||
)
|
||||
|
||||
|
||||
def strip_cfg_test(code: str, test_mod_decls: list[str]) -> str:
|
||||
"""Blank items annotated `#[cfg(test)]`; collect `mod x;` declarations."""
|
||||
out = list(code)
|
||||
for m in CFG_TEST.finditer(code):
|
||||
j = m.end()
|
||||
while True:
|
||||
ws = re.match(r"\s*(#\[[^\]]*\])?", code[j:])
|
||||
if ws and ws.group(1):
|
||||
j += ws.end()
|
||||
continue
|
||||
j += len(code[j:]) - len(code[j:].lstrip())
|
||||
break
|
||||
md = re.match(r"(pub(\([^)]*\))?\s+)?mod\s+(\w+)\s*;", code[j:])
|
||||
if md:
|
||||
test_mod_decls.append(md.group(3))
|
||||
semi = code.find(";", j)
|
||||
brace = code.find("{", j)
|
||||
if brace >= 0 and (semi < 0 or brace < semi):
|
||||
end = match_brace(code, brace)
|
||||
else:
|
||||
end = semi if semi >= 0 else j
|
||||
for k in range(m.start(), end + 1):
|
||||
if out[k] != "\n":
|
||||
out[k] = " "
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def is_test_path(rel: str) -> bool:
|
||||
base = os.path.basename(rel)
|
||||
return (
|
||||
"/tests/" in "/" + rel
|
||||
or base in ("tests.rs", "test_support.rs", "test_env_lock.rs")
|
||||
or base.endswith(("_tests.rs", "_test.rs", "_acceptance.rs"))
|
||||
or base == "golden_harness.rs"
|
||||
or "goldens" in rel
|
||||
or "/fixtures/" in "/" + rel
|
||||
)
|
||||
|
||||
|
||||
def top_module(rel: str) -> str:
|
||||
parts = rel.split("/")
|
||||
if len(parts) == 1:
|
||||
stem = parts[0][:-3]
|
||||
return {"lib": ROOT_ITEMS, "main": "main.rs"}.get(stem, stem)
|
||||
return parts[0]
|
||||
|
||||
|
||||
def split_top(s: str) -> list[str]:
|
||||
parts, depth, cur = [], 0, ""
|
||||
for ch in s:
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
parts.append(cur)
|
||||
cur = ""
|
||||
else:
|
||||
cur += ch
|
||||
parts.append(cur)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def use_leaves(tree: str, prefix: tuple[str, ...] = ()) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Flatten a use tree into (bound name, full path) pairs."""
|
||||
tree = tree.strip()
|
||||
if not tree:
|
||||
return []
|
||||
brace = tree.find("{")
|
||||
if brace >= 0:
|
||||
head = [p for p in tree[:brace].split("::") if p.strip()]
|
||||
inner = tree[brace + 1 : tree.rindex("}")]
|
||||
out = []
|
||||
for part in split_top(inner):
|
||||
out.extend(use_leaves(part, prefix + tuple(h.strip() for h in head)))
|
||||
return out
|
||||
alias = None
|
||||
m = re.match(r"(.*?)\s+as\s+(\w+)$", tree)
|
||||
if m:
|
||||
tree, alias = m.group(1), m.group(2)
|
||||
path = prefix + tuple(p.strip() for p in tree.split("::") if p.strip())
|
||||
if not path:
|
||||
return []
|
||||
name = alias or path[-1]
|
||||
if name == "self":
|
||||
name = path[-2] if len(path) > 1 else name
|
||||
return [(name, path)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Graph construction
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ref:
|
||||
kind: str # "prod" or "test"
|
||||
src: str # module
|
||||
dst: str # module, or lib.rs
|
||||
file: str
|
||||
line: int
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Crate:
|
||||
name: str
|
||||
root: Path
|
||||
files: dict[str, tuple[str, str, str]] = field(default_factory=dict)
|
||||
modules: set[str] = field(default_factory=set)
|
||||
# names bound at the crate root by `use` -> resolving module (or "extern")
|
||||
root_names: dict[str, str] = field(default_factory=dict)
|
||||
# modules whose every file is test code (e.g. `#[cfg(test)] mod test_support;`)
|
||||
test_only: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
USE_RE = re.compile(r"\buse\s+crate::")
|
||||
PATH_RE = re.compile(r"(?<![\$\w])crate::(\w+)")
|
||||
# `super::super::tui::x` reaches the crate root just like `crate::tui::x`.
|
||||
SUPER_RE = re.compile(r"(?<![\$\w:])((?:super\s*::\s*)+)(\{|\w+)")
|
||||
INLINE_MOD_RE = re.compile(r"\bmod\s+\w+\s*\{")
|
||||
UILIB_RE = re.compile(r"(?<![\w:])(ratatui|crossterm|codewhale_tui)(?:::|\s*;|\s*\{)")
|
||||
DOC_LINK_RE = re.compile(r"\[`?crate::(tui|commands)\b")
|
||||
|
||||
|
||||
def load_crate(name: str, root: Path) -> Crate:
|
||||
crate = Crate(name, root)
|
||||
if not root.is_dir():
|
||||
return crate
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
for fname in filenames:
|
||||
if not fname.endswith(".rs"):
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, fname), root).replace(os.sep, "/")
|
||||
src = Path(dirpath, fname).read_text(encoding="utf-8", errors="replace")
|
||||
code = lex_mask(src)
|
||||
crate.files[rel] = (src, code, "")
|
||||
crate.modules = {top_module(r) for r in crate.files} - {ROOT_ITEMS, "main.rs"}
|
||||
return crate
|
||||
|
||||
|
||||
def resolve_root_names(crate: Crate, runtime_modules: set[str]) -> None:
|
||||
"""Map names the crate root imports with `use` to the module they come from."""
|
||||
entry = crate.files.get("lib.rs")
|
||||
if not entry:
|
||||
return
|
||||
code = entry[1]
|
||||
depth = 0
|
||||
i = 0
|
||||
# Only top-level `use` items (brace depth 0) bind crate-root names.
|
||||
for m in re.finditer(r"[{}]|\b(?:pub(?:\([^)]*\))?\s+)?use\s+", code):
|
||||
tok = m.group(0)
|
||||
if tok == "{":
|
||||
depth += 1
|
||||
continue
|
||||
if tok == "}":
|
||||
depth -= 1
|
||||
continue
|
||||
if depth != 0 or m.start() < i:
|
||||
continue
|
||||
end = code.find(";", m.end())
|
||||
i = end
|
||||
for bound, path in use_leaves(code[m.end() : end]):
|
||||
head = path[0]
|
||||
if head == "crate" and len(path) > 1:
|
||||
target = path[1]
|
||||
crate.root_names[bound] = target if target in crate.modules else ROOT_ITEMS
|
||||
elif head in ("self", "super"):
|
||||
continue
|
||||
elif head in crate.modules:
|
||||
crate.root_names[bound] = head
|
||||
elif head == "codewhale_runtime" and len(path) > 1:
|
||||
crate.root_names[bound] = path[1]
|
||||
else:
|
||||
crate.root_names[bound] = "extern"
|
||||
|
||||
|
||||
def test_file_set(crate: Crate) -> tuple[set[str], set[str]]:
|
||||
exact, prefixes = set(), set()
|
||||
for rel, (src, code, _) in list(crate.files.items()):
|
||||
decls: list[str] = []
|
||||
stripped = strip_cfg_test(code, decls)
|
||||
crate.files[rel] = (src, code, stripped)
|
||||
base_dir = os.path.dirname(rel)
|
||||
stem = os.path.basename(rel)[:-3]
|
||||
mod_dir = base_dir if stem in ("mod", "lib", "main") else os.path.join(base_dir, stem)
|
||||
for d in decls:
|
||||
exact.add(os.path.normpath(os.path.join(mod_dir, d + ".rs")))
|
||||
exact.add(os.path.normpath(os.path.join(mod_dir, d, "mod.rs")))
|
||||
prefixes.add(os.path.normpath(os.path.join(mod_dir, d)) + "/")
|
||||
return exact, prefixes
|
||||
|
||||
|
||||
def file_is_test(rel: str, exact: set[str], prefixes: set[str]) -> bool:
|
||||
return (
|
||||
is_test_path(rel)
|
||||
or rel in exact
|
||||
or any(rel.startswith(p) for p in prefixes)
|
||||
)
|
||||
|
||||
|
||||
def file_depth(rel: str) -> int:
|
||||
"""Module depth of a source file below the crate root (`lib.rs` is 0)."""
|
||||
parts = rel.split("/")
|
||||
if len(parts) == 1:
|
||||
return 0 if parts[0] in ("lib.rs", "main.rs") else 1
|
||||
return len(parts) - 1 if parts[-1] == "mod.rs" else len(parts)
|
||||
|
||||
|
||||
def inline_mod_spans(code: str) -> list[tuple[int, int]]:
|
||||
"""Byte ranges of inline `mod name { ... }` bodies (each adds one level)."""
|
||||
return [(m.end() - 1, match_brace(code, m.end() - 1)) for m in INLINE_MOD_RE.finditer(code)]
|
||||
|
||||
|
||||
def super_root_targets(code: str, rel: str) -> list[tuple[int, str]]:
|
||||
"""`(offset, first segment)` for every `super::` chain that climbs to the crate root.
|
||||
|
||||
A chain of k `super`s written at module depth d (file depth plus the
|
||||
enclosing inline `mod` blocks) names the crate root when k == d; a
|
||||
shorter chain stays inside the module and is not a cross-module edge.
|
||||
"""
|
||||
base = file_depth(rel)
|
||||
spans = inline_mod_spans(code)
|
||||
out: list[tuple[int, str]] = []
|
||||
for m in SUPER_RE.finditer(code):
|
||||
k = m.group(1).count("super")
|
||||
depth = base + sum(1 for a, b in spans if a < m.start() < b)
|
||||
if k != depth:
|
||||
continue
|
||||
if m.group(2) == "{":
|
||||
end = match_brace(code, m.end() - 1)
|
||||
for _, path in use_leaves(code[m.end() : end]):
|
||||
out.append((m.start(), path[0]))
|
||||
else:
|
||||
out.append((m.start(), m.group(2)))
|
||||
return out
|
||||
|
||||
|
||||
def collect_refs(crate: Crate, all_modules: set[str], prefix: str) -> list[Ref]:
|
||||
exact, prefixes = test_file_set(crate)
|
||||
refs: list[Ref] = []
|
||||
by_module: dict[str, list[bool]] = collections.defaultdict(list)
|
||||
for rel in crate.files:
|
||||
by_module[top_module(rel)].append(file_is_test(rel, exact, prefixes))
|
||||
crate.test_only = {m for m, flags in by_module.items() if all(flags)}
|
||||
|
||||
def target_of(name: str) -> str:
|
||||
if name in all_modules:
|
||||
return name
|
||||
return crate.root_names.get(name, ROOT_ITEMS)
|
||||
|
||||
for rel, (src, code, stripped) in crate.files.items():
|
||||
mod = top_module(rel)
|
||||
whole_test = file_is_test(rel, exact, prefixes)
|
||||
lines = src.split("\n")
|
||||
|
||||
def kind_at(pos: int) -> str:
|
||||
if whole_test:
|
||||
return "test"
|
||||
return "prod" if stripped[pos] == code[pos] and not stripped[pos].isspace() else "test"
|
||||
|
||||
covered: set[int] = set()
|
||||
for m in USE_RE.finditer(code):
|
||||
end = code.find(";", m.end())
|
||||
if end < 0:
|
||||
continue
|
||||
covered.update(range(m.start(), end))
|
||||
body = code[m.end() : end]
|
||||
line = code.count("\n", 0, m.start()) + 1
|
||||
for _, path in use_leaves(body):
|
||||
t = path[0]
|
||||
if t in ("self", "super"):
|
||||
continue
|
||||
refs.append(Ref(kind_at(m.start()), mod, target_of(t), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160]))
|
||||
for m in PATH_RE.finditer(code):
|
||||
if m.start() in covered:
|
||||
continue
|
||||
line = code.count("\n", 0, m.start()) + 1
|
||||
refs.append(Ref(kind_at(m.start()), mod, target_of(m.group(1)), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160]))
|
||||
for pos, name in super_root_targets(code, rel):
|
||||
# `pub(in super::super)` and glob imports name no item; only a
|
||||
# known module or crate-root binding is an edge.
|
||||
if name not in all_modules and name not in crate.root_names:
|
||||
continue
|
||||
line = code.count("\n", 0, pos) + 1
|
||||
refs.append(Ref(kind_at(pos), mod, target_of(name), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160]))
|
||||
return refs
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
closure: list[str]
|
||||
counts: dict[str, dict[str, int]]
|
||||
locations: dict[str, dict[str, list[str]]]
|
||||
|
||||
|
||||
def build_report(tui_src: Path = TUI_SRC, runtime_src: Path = RUNTIME_SRC) -> Report:
|
||||
tui = load_crate("codewhale-tui", tui_src)
|
||||
runtime = load_crate("codewhale-runtime", runtime_src)
|
||||
all_modules = tui.modules | runtime.modules
|
||||
resolve_root_names(tui, runtime.modules)
|
||||
resolve_root_names(runtime, runtime.modules)
|
||||
tui_refs = collect_refs(tui, all_modules, "crates/tui/src")
|
||||
rt_refs = collect_refs(runtime, runtime.modules, "crates/runtime/src")
|
||||
refs = tui_refs + rt_refs
|
||||
|
||||
prod_edges: dict[str, set[str]] = collections.defaultdict(set)
|
||||
for r in refs:
|
||||
if r.kind == "prod" and r.src != r.dst:
|
||||
prod_edges[r.src].add(r.dst)
|
||||
|
||||
# A test-only module that closure tests use (`test_support`) compiles in
|
||||
# the same crate as those tests, so it belongs to the closure too and its
|
||||
# own upward references count as test references.
|
||||
test_only = tui.test_only | runtime.test_only
|
||||
test_edges: dict[str, set[str]] = collections.defaultdict(set)
|
||||
for r in refs:
|
||||
if r.src != r.dst and r.dst in test_only:
|
||||
test_edges[r.src].add(r.dst)
|
||||
|
||||
seen: set[str] = set()
|
||||
stack = [s for s in SEEDS if s in all_modules] + sorted(runtime.modules)
|
||||
while stack:
|
||||
m = stack.pop()
|
||||
if m in seen or is_ui(m) or m == "extern":
|
||||
continue
|
||||
seen.add(m)
|
||||
stack.extend(prod_edges.get(m, ()))
|
||||
stack.extend(test_edges.get(m, ()))
|
||||
|
||||
counts = {c: collections.Counter() for c in CATEGORIES}
|
||||
where: dict[str, dict[str, list[str]]] = {c: collections.defaultdict(list) for c in CATEGORIES}
|
||||
for r in refs:
|
||||
if r.src not in seen or r.src == r.dst or r.dst == "extern":
|
||||
continue
|
||||
if is_ui(r.dst):
|
||||
cat = r.kind
|
||||
elif r.dst not in seen:
|
||||
cat = "late"
|
||||
else:
|
||||
continue
|
||||
key = f"{r.src}|{r.dst}"
|
||||
counts[cat][key] += 1
|
||||
where[cat][key].append(f"{r.file}:{r.line}: {r.text}")
|
||||
|
||||
for crate, prefix in ((tui, "crates/tui/src"), (runtime, "crates/runtime/src")):
|
||||
for rel, (src, code, _) in crate.files.items():
|
||||
mod = top_module(rel)
|
||||
if mod not in seen:
|
||||
continue
|
||||
lines = src.split("\n")
|
||||
for m in UILIB_RE.finditer(code):
|
||||
lib = m.group(1)
|
||||
line = code.count("\n", 0, m.start()) + 1
|
||||
key = f"{mod}|{lib}"
|
||||
counts["uilib"][key] += 1
|
||||
where["uilib"][key].append(f"{prefix}/{rel}:{line}: {lines[line - 1].strip()[:160]}")
|
||||
for line_no, raw in enumerate(lines, start=1):
|
||||
stripped = raw.lstrip()
|
||||
if not (stripped.startswith("///") or stripped.startswith("//!")):
|
||||
continue
|
||||
for m in DOC_LINK_RE.finditer(stripped):
|
||||
key = f"{mod}|{m.group(1)}"
|
||||
counts["doc"][key] += 1
|
||||
where["doc"][key].append(f"{prefix}/{rel}:{line_no}: {stripped[:160]}")
|
||||
|
||||
return Report(
|
||||
sorted(seen),
|
||||
{c: dict(sorted(counts[c].items())) for c in CATEGORIES},
|
||||
{c: dict(where[c]) for c in CATEGORIES},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ratchet
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def totals(counts: dict[str, dict[str, int]]) -> dict[str, int]:
|
||||
return {c: sum(counts.get(c, {}).values()) for c in CATEGORIES}
|
||||
|
||||
|
||||
def compare(baseline: dict, report: Report) -> tuple[list[str], list[str]]:
|
||||
"""Return (increases, unrecorded decreases) as human-readable lines."""
|
||||
rises: list[str] = []
|
||||
drops: list[str] = []
|
||||
base_counts = baseline.get("counts", {})
|
||||
for cat in CATEGORIES:
|
||||
base = base_counts.get(cat, {})
|
||||
cur = report.counts.get(cat, {})
|
||||
for key, n in cur.items():
|
||||
b = base.get(key, 0)
|
||||
if n > b:
|
||||
dst = key.split("|", 1)[1]
|
||||
hint = HINTS.get(dst, "see docs/design/TUI_DECONSTRUCTION.md (runtime split blockers)")
|
||||
head = f"{cat} {key}: {b} -> {n}" + (" (new pair)" if key not in base else "")
|
||||
rises.append(f"{head}; {hint}")
|
||||
for loc in report.locations.get(cat, {}).get(key, [])[:20]:
|
||||
rises.append(f" {loc}")
|
||||
for key, b in base.items():
|
||||
n = cur.get(key, 0)
|
||||
if n < b:
|
||||
drops.append(f"{cat} {key}: {b} -> {n}")
|
||||
return rises, drops
|
||||
|
||||
|
||||
def load_baseline(path: Path = BASELINE) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_baseline_at_ref(ref: str, root: Path = REPO_ROOT) -> dict | None:
|
||||
"""The baseline as committed at `ref`; None when it did not exist yet."""
|
||||
commit = subprocess.run(
|
||||
["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
|
||||
cwd=root, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if commit.returncode != 0:
|
||||
raise ValueError(f"baseline ref {ref!r} is unavailable: {commit.stderr.strip()}")
|
||||
shown = subprocess.run(
|
||||
["git", "show", f"{ref}:{BASELINE_REPO_PATH}"],
|
||||
cwd=root, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if shown.returncode != 0:
|
||||
return None
|
||||
return json.loads(shown.stdout)
|
||||
|
||||
|
||||
def baseline_raises(previous: dict, current: dict) -> list[str]:
|
||||
"""Counts the committed baseline holds above the baseline it replaces.
|
||||
|
||||
The local `--update` refuses to raise, but the JSON is an ordinary file:
|
||||
without this comparison a change could add a reference and bump the
|
||||
count by hand in the same commit, and `check` would pass.
|
||||
"""
|
||||
raised: list[str] = []
|
||||
prev_counts = previous.get("counts", {})
|
||||
for cat in CATEGORIES:
|
||||
prev = prev_counts.get(cat, {})
|
||||
for key, n in current.get("counts", {}).get(cat, {}).items():
|
||||
b = prev.get(key, 0)
|
||||
if n > b:
|
||||
raised.append(f"{cat} {key}: {b} -> {n}" + (" (new pair)" if key not in prev else ""))
|
||||
return raised
|
||||
|
||||
|
||||
def baseline_document(report: Report) -> dict:
|
||||
return {
|
||||
"_comment": (
|
||||
"Runtime -> UI boundary ratchet (docs/design/TUI_DECONSTRUCTION.md, runtime split). Counts may "
|
||||
"only go down. Regenerate with python3 scripts/split/module_graph.py "
|
||||
"--update after removing references; never raise a count by hand."
|
||||
),
|
||||
"totals": totals(report.counts),
|
||||
"counts": report.counts,
|
||||
}
|
||||
|
||||
|
||||
def check(
|
||||
path: Path = BASELINE,
|
||||
report: Report | None = None,
|
||||
baseline_ref: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return violation lines (empty when the ratchet holds).
|
||||
|
||||
With `baseline_ref` (CI passes the PR base), the committed baseline must
|
||||
also be no higher than the one at that revision.
|
||||
"""
|
||||
report = report or build_report()
|
||||
if not path.is_file():
|
||||
return [f"missing baseline {path.relative_to(REPO_ROOT)}; run with --update"]
|
||||
problems = []
|
||||
if baseline_ref:
|
||||
try:
|
||||
previous = load_baseline_at_ref(baseline_ref)
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
return [str(error)]
|
||||
if previous is not None:
|
||||
raised = baseline_raises(previous, load_baseline(path))
|
||||
if raised:
|
||||
problems.append(
|
||||
f"the baseline was raised relative to {baseline_ref} (counts only go down; "
|
||||
"remove the reference instead of editing the JSON):"
|
||||
)
|
||||
problems.extend(f" {r}" for r in raised)
|
||||
rises, drops = compare(load_baseline(path), report)
|
||||
if rises:
|
||||
problems.append("runtime -> UI references rose (the ratchet only goes down):")
|
||||
problems.extend(f" {r}" for r in rises)
|
||||
if drops:
|
||||
problems.append(
|
||||
"runtime -> UI references dropped but the baseline was not lowered; "
|
||||
"run python3 scripts/split/module_graph.py --update and commit it:"
|
||||
)
|
||||
problems.extend(f" {d}" for d in drops)
|
||||
return problems
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--check", action="store_true", help="enforce the ratchet (default)")
|
||||
mode.add_argument("--update", action="store_true", help="lower the baseline to the current counts")
|
||||
mode.add_argument("--report", action="store_true", help="print counts and closure as JSON")
|
||||
parser.add_argument(
|
||||
"--baseline-ref",
|
||||
help="git revision whose committed baseline the current one may not exceed (CI: the PR base)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = build_report()
|
||||
if args.report:
|
||||
json.dump(
|
||||
{"closure": report.closure, "totals": totals(report.counts), "counts": report.counts,
|
||||
"locations": report.locations},
|
||||
sys.stdout,
|
||||
indent=1,
|
||||
)
|
||||
print()
|
||||
return 0
|
||||
if args.update:
|
||||
if BASELINE.is_file():
|
||||
rises, _ = compare(load_baseline(), report)
|
||||
if rises:
|
||||
print("[runtime-boundary] refusing to raise the baseline:", file=sys.stderr)
|
||||
for r in rises:
|
||||
print(f" {r}", file=sys.stderr)
|
||||
return 1
|
||||
BASELINE.write_text(json.dumps(baseline_document(report), indent=2) + "\n", encoding="utf-8")
|
||||
print(f"[runtime-boundary] baseline written: {totals(report.counts)}")
|
||||
return 0
|
||||
problems = check(report=report, baseline_ref=args.baseline_ref)
|
||||
if problems:
|
||||
print("[runtime-boundary] FAIL", file=sys.stderr)
|
||||
for p in problems:
|
||||
print(p, file=sys.stderr)
|
||||
return 1
|
||||
print(f"[runtime-boundary] PASS: {totals(report.counts)} ({len(report.closure)} closure modules)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Move top-level TUI modules into crates/runtime (the runtime/TUI split).
|
||||
|
||||
For each named module this:
|
||||
|
||||
1. `git mv`s `crates/tui/src/<m>.rs` and/or `crates/tui/src/<m>/` into
|
||||
`crates/runtime/src/`;
|
||||
2. deletes its `mod <m>;` / `pub mod <m>;` line from the TUI `lib.rs` and adds
|
||||
`pub mod <m>;` to the runtime `lib.rs` (sorted);
|
||||
3. adds `<m>` to the TUI's single path-alias block
|
||||
`use codewhale_runtime::{...};`, so every `crate::<m>::...` path in the TUI
|
||||
keeps resolving without a content change (the alias is deleted at the end
|
||||
of the split by rewriting those paths);
|
||||
4. sweeps every string occurrence of the old path across the repository
|
||||
(`crates/tui/src/<m>` and `tui/src/<m>`), found with `git grep`.
|
||||
CHANGELOG files are history and are left alone.
|
||||
|
||||
It changes no item visibility; `scripts/split/widen.py` does that, driven by
|
||||
the compiler.
|
||||
|
||||
Usage:
|
||||
python3 scripts/split/move-modules.py <module> [<module> ...]
|
||||
python3 scripts/split/move-modules.py --dry-run <module> ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TUI_SRC = REPO_ROOT / "crates" / "tui" / "src"
|
||||
RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src"
|
||||
TUI_LIB = TUI_SRC / "lib.rs"
|
||||
RUNTIME_LIB = RUNTIME_SRC / "lib.rs"
|
||||
ALIAS_START = "use codewhale_runtime::{"
|
||||
ALIAS_MARKER = "// Runtime split path alias"
|
||||
|
||||
|
||||
def git(*args: str, check: bool = True) -> str:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, check=check
|
||||
).stdout
|
||||
|
||||
|
||||
def module_paths(module: str) -> list[Path]:
|
||||
paths = [p for p in (TUI_SRC / f"{module}.rs", TUI_SRC / module) if p.exists()]
|
||||
if not paths:
|
||||
raise SystemExit(f"move-modules: no crates/tui/src/{module}.rs or {module}/")
|
||||
return paths
|
||||
|
||||
|
||||
def remove_mod_line(text: str, module: str) -> tuple[str, bool]:
|
||||
pattern = re.compile(rf"^(?:pub(?:\([^)]*\))?\s+)?mod\s+{re.escape(module)}\s*;\n", re.M)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return text, False
|
||||
# Refuse to drop a mod line that carries attributes (cfg, path): that
|
||||
# module needs a human decision, not a mechanical move.
|
||||
before = text[: match.start()].rstrip("\n").rsplit("\n", 1)[-1].strip()
|
||||
if before.startswith("#["):
|
||||
raise SystemExit(f"move-modules: `mod {module};` carries an attribute ({before}); move it by hand")
|
||||
return text[: match.start()] + text[match.end() :], True
|
||||
|
||||
|
||||
def add_runtime_mod(text: str, module: str) -> str:
|
||||
line = f"pub mod {module};\n"
|
||||
if line in text:
|
||||
return text
|
||||
lines = text.splitlines(keepends=True)
|
||||
mods = [i for i, l in enumerate(lines) if re.match(r"pub mod \w+;\n", l)]
|
||||
if not mods:
|
||||
return text.rstrip("\n") + "\n\n" + line
|
||||
insert = mods[-1] + 1
|
||||
for i in mods:
|
||||
if lines[i] > line:
|
||||
insert = i
|
||||
break
|
||||
lines.insert(insert, line)
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def add_alias(text: str, module: str) -> str:
|
||||
start = text.find(ALIAS_START)
|
||||
if start < 0:
|
||||
raise SystemExit(
|
||||
"move-modules: the TUI lib.rs has no `use codewhale_runtime::{...};` alias block"
|
||||
)
|
||||
end = text.index("};", start)
|
||||
names = {n.strip() for n in text[start + len(ALIAS_START) : end].split(",") if n.strip()}
|
||||
names.add(module)
|
||||
body = "".join(f" {n},\n" for n in sorted(names))
|
||||
return text[:start] + ALIAS_START + "\n" + body + text[end:]
|
||||
|
||||
|
||||
def sweep_paths(module: str, dry_run: bool) -> list[str]:
|
||||
patterns = [
|
||||
(f"crates/tui/src/{module}", f"crates/runtime/src/{module}"),
|
||||
(f"tui/src/{module}", f"runtime/src/{module}"),
|
||||
]
|
||||
touched: list[str] = []
|
||||
for old, new in patterns:
|
||||
# The module as a whole path segment: `elapsed.rs`, `elapsed/`,
|
||||
# `elapsed::`, never `elapsed_time.rs`; `tui/src/x` only when it is not
|
||||
# already the tail of `crates/tui/src/x`.
|
||||
regex = re.compile(
|
||||
rf"(?<![A-Za-z0-9_/]){re.escape(old)}(?![A-Za-z0-9_])"
|
||||
if not old.startswith("crates/")
|
||||
else rf"{re.escape(old)}(?![A-Za-z0-9_])"
|
||||
)
|
||||
for rel in git("grep", "-l", "-F", old, check=False).split():
|
||||
if Path(rel).name.startswith("CHANGELOG"):
|
||||
continue
|
||||
path = REPO_ROOT / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = regex.sub(new, text)
|
||||
if updated != text:
|
||||
touched.append(rel)
|
||||
if not dry_run:
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
return sorted(set(touched))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("modules", nargs="+")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not RUNTIME_LIB.is_file():
|
||||
raise SystemExit("move-modules: crates/runtime/src/lib.rs does not exist yet")
|
||||
tui_lib = TUI_LIB.read_text(encoding="utf-8")
|
||||
runtime_lib = RUNTIME_LIB.read_text(encoding="utf-8")
|
||||
for module in args.modules:
|
||||
paths = module_paths(module)
|
||||
tui_lib, found = remove_mod_line(tui_lib, module)
|
||||
if not found:
|
||||
raise SystemExit(f"move-modules: no `mod {module};` line in crates/tui/src/lib.rs")
|
||||
tui_lib = add_alias(tui_lib, module)
|
||||
runtime_lib = add_runtime_mod(runtime_lib, module)
|
||||
if not args.dry_run:
|
||||
for path in paths:
|
||||
git("mv", str(path.relative_to(REPO_ROOT)), str((RUNTIME_SRC / path.name).relative_to(REPO_ROOT)))
|
||||
if not args.dry_run:
|
||||
TUI_LIB.write_text(tui_lib, encoding="utf-8")
|
||||
RUNTIME_LIB.write_text(runtime_lib, encoding="utf-8")
|
||||
swept: set[str] = set()
|
||||
for module in args.modules:
|
||||
swept.update(sweep_paths(module, args.dry_run))
|
||||
for rel in sorted(swept):
|
||||
print(f"path sweep: {rel}")
|
||||
print(f"moved {len(args.modules)} module(s){' (dry run)' if args.dry_run else ''}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Widen exactly the runtime items the compiler asks for (runtime/TUI split).
|
||||
|
||||
After `move-modules.py` moves code into `crates/runtime`, items that were
|
||||
`pub(crate)` inside the TUI are private to the new crate. This loop runs
|
||||
`cargo check --message-format=json` and rewrites only the definitions the
|
||||
compiler names:
|
||||
|
||||
* E0603 / E0624 / E0616 / E0451 (private item, method, field, field in a
|
||||
struct literal) whose definition is in `crates/runtime/src`: the
|
||||
definition's `pub(crate)` / `pub(super)` / missing visibility becomes `pub`;
|
||||
a private `mod x;` becomes `pub mod x;`;
|
||||
* a runtime `dead_code` warning on a `pub(crate)` item: the item is used only
|
||||
from the TUI, so it is widened the same way;
|
||||
* `unfulfilled_lint_expectations` in the runtime: an `#[expect(dead_code)]`
|
||||
(or `cfg_attr(.., expect(..))`) the export made unfulfilled is deleted, but
|
||||
only when the attribute is alone on its line.
|
||||
|
||||
It never bulk-rewrites `pub(crate)`. Every widened item is printed; items in
|
||||
security-relevant modules are flagged for human review.
|
||||
|
||||
While the loop runs, the runtime manifest's `[lints] workspace = true` is
|
||||
commented out (lints capped to warnings), because an item that is dead inside
|
||||
the runtime would otherwise fail the runtime build under `warnings = "deny"`
|
||||
before cargo ever reached the TUI's privacy errors. The stanza is restored
|
||||
when the loop ends, and a final check runs with it.
|
||||
|
||||
Usage:
|
||||
python3 scripts/split/widen.py [--cargo "scripts/dev-cargo.sh"] [--max-rounds N]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src"
|
||||
RUNTIME_MANIFEST = REPO_ROOT / "crates" / "runtime" / "Cargo.toml"
|
||||
LINTS_ON = "[lints]\nworkspace = true\n"
|
||||
LINTS_CAPPED = "# [lints] capped by scripts/split/widen.py\n# workspace = true\n"
|
||||
PRIVACY_CODES = {"E0603", "E0624", "E0616", "E0451"}
|
||||
SECURITY_MODULES = (
|
||||
"sandbox",
|
||||
"core/authority",
|
||||
"network_policy",
|
||||
"repo_law",
|
||||
"workspace_trust",
|
||||
"oauth",
|
||||
"credentials",
|
||||
"mcp",
|
||||
"runtime_api",
|
||||
"execpolicy",
|
||||
)
|
||||
ITEM_KW = r"(?:async\s+|unsafe\s+|const\s+|extern\s+\"[^\"]*\"\s+)*(?:fn|struct|enum|union|trait|type|const|static|mod|use)\b"
|
||||
|
||||
|
||||
def run_cargo(cargo: list[str], packages: list[str], extra: list[str]) -> list[dict]:
|
||||
cmd = [*cargo, "check", "--locked", "--message-format=json", *extra]
|
||||
for p in packages:
|
||||
cmd += ["-p", p]
|
||||
proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True)
|
||||
messages = []
|
||||
for line in proc.stdout.splitlines():
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if msg.get("reason") == "compiler-message":
|
||||
messages.append(msg["message"])
|
||||
if proc.returncode != 0 and not messages:
|
||||
sys.stderr.write(proc.stderr[-4000:])
|
||||
return messages
|
||||
|
||||
|
||||
def runtime_spans(message: dict) -> list[dict]:
|
||||
spans = list(message.get("spans", []))
|
||||
for child in message.get("children", []):
|
||||
spans.extend(child.get("spans", []))
|
||||
out = []
|
||||
for span in spans:
|
||||
path = (REPO_ROOT / span["file_name"]).resolve()
|
||||
if str(path).startswith(str(RUNTIME_SRC)):
|
||||
out.append(span)
|
||||
return out
|
||||
|
||||
|
||||
def widen_line(line: str, name: str | None) -> str | None:
|
||||
"""Rewrite one definition line to `pub`; None when it cannot be done safely."""
|
||||
m = re.match(r"^(\s*)pub\s*\((?:crate|super|in [^)]*)\)\s+", line)
|
||||
if m:
|
||||
return m.group(1) + "pub " + line[m.end() :]
|
||||
if re.match(r"^\s*pub\s", line):
|
||||
return None # already public: the error is about a parent module
|
||||
m = re.match(rf"^(\s*)(?={ITEM_KW})", line)
|
||||
if m:
|
||||
return m.group(1) + "pub " + line[m.end() :]
|
||||
if name:
|
||||
m = re.match(rf"^(\s*)(?={re.escape(name)}\s*:)", line)
|
||||
if m:
|
||||
return m.group(1) + "pub " + line[m.end() :]
|
||||
return None
|
||||
|
||||
|
||||
def find_field(struct: str, field: str) -> tuple[Path, int] | None:
|
||||
pat = re.compile(rf"\bstruct\s+{re.escape(struct)}\b")
|
||||
for path in RUNTIME_SRC.rglob("*.rs"):
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if pat.search(line):
|
||||
for j in range(i + 1, min(i + 400, len(lines))):
|
||||
if re.match(rf"^\s*(pub(\([^)]*\))?\s+)?{re.escape(field)}\s*:", lines[j]):
|
||||
return path, j + 1
|
||||
if lines[j].startswith("}"):
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def item_line(path: Path, line_no: int) -> int:
|
||||
"""Walk down from an attribute or doc line to the item line itself."""
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
i = line_no - 1
|
||||
while i < len(lines) and re.match(r"^\s*(#\[|///|//!)", lines[i]):
|
||||
i += 1
|
||||
return i + 1
|
||||
|
||||
|
||||
def apply(edits: dict[tuple[Path, int], str | None]) -> list[str]:
|
||||
"""Apply per-line edits: a string replaces the line, None deletes it."""
|
||||
by_file: dict[Path, list[tuple[int, str | None]]] = {}
|
||||
for (path, line_no), new in edits.items():
|
||||
by_file.setdefault(path, []).append((line_no, new))
|
||||
report = []
|
||||
for path, changes in by_file.items():
|
||||
lines = path.read_text(encoding="utf-8").split("\n")
|
||||
for line_no, new in sorted(changes, reverse=True):
|
||||
old = lines[line_no - 1]
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
flag = " [security review]" if any(f"/{m}" in f"/{rel}" for m in SECURITY_MODULES) else ""
|
||||
if new is None:
|
||||
del lines[line_no - 1]
|
||||
report.append(f"removed {rel}:{line_no}: {old.strip()}")
|
||||
else:
|
||||
lines[line_no - 1] = new
|
||||
report.append(f"widened {rel}:{line_no}: {new.strip()}{flag}")
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return report
|
||||
|
||||
|
||||
def plan_edits(messages: list[dict]) -> tuple[dict, list[str]]:
|
||||
edits: dict[tuple[Path, int], str | None] = {}
|
||||
unresolved: list[str] = []
|
||||
for msg in messages:
|
||||
code = (msg.get("code") or {}).get("code") or ""
|
||||
level = msg.get("level")
|
||||
text = msg.get("message", "")
|
||||
if code in PRIVACY_CODES:
|
||||
name_m = re.search(r"`([^`]+)`", text)
|
||||
name = name_m.group(1).split("::")[-1] if name_m else None
|
||||
targets = [s for s in runtime_spans(msg) if not s.get("is_primary") or code == "E0603"]
|
||||
done = False
|
||||
for span in targets:
|
||||
path = (REPO_ROOT / span["file_name"]).resolve()
|
||||
line_no = item_line(path, span["line_start"])
|
||||
line = path.read_text(encoding="utf-8").split("\n")[line_no - 1]
|
||||
new = widen_line(line, name)
|
||||
if new is not None:
|
||||
edits[(path, line_no)] = new
|
||||
done = True
|
||||
if not done and code == "E0616":
|
||||
m = re.search(r"field `(\w+)` of struct `(?:[\w:]+::)?(\w+)", text)
|
||||
hit = m and find_field(m.group(2), m.group(1))
|
||||
if hit:
|
||||
path, line_no = hit
|
||||
line = path.read_text(encoding="utf-8").split("\n")[line_no - 1]
|
||||
new = widen_line(line, m.group(1))
|
||||
if new is not None:
|
||||
edits[(path, line_no)] = new
|
||||
done = True
|
||||
if not done:
|
||||
unresolved.append(msg.get("rendered") or text)
|
||||
continue
|
||||
if code == "dead_code" and level in ("warning", "error"):
|
||||
for span in runtime_spans(msg):
|
||||
path = (REPO_ROOT / span["file_name"]).resolve()
|
||||
line_no = span["line_start"]
|
||||
line = path.read_text(encoding="utf-8").split("\n")[line_no - 1]
|
||||
if re.match(r"^\s*pub\s*\(", line):
|
||||
new = widen_line(line, None)
|
||||
if new is not None:
|
||||
edits[(path, line_no)] = new
|
||||
continue
|
||||
unresolved.append(msg.get("rendered") or text)
|
||||
continue
|
||||
if code == "unfulfilled_lint_expectations":
|
||||
for span in runtime_spans(msg):
|
||||
path = (REPO_ROOT / span["file_name"]).resolve()
|
||||
line_no = span["line_start"]
|
||||
line = path.read_text(encoding="utf-8").split("\n")[line_no - 1].strip()
|
||||
if line.startswith("#[") and line.endswith("]") and "expect" in line:
|
||||
edits[(path, line_no)] = None
|
||||
else:
|
||||
unresolved.append(msg.get("rendered") or text)
|
||||
continue
|
||||
if level == "error":
|
||||
unresolved.append(msg.get("rendered") or text)
|
||||
return edits, unresolved
|
||||
|
||||
|
||||
def set_lints(capped: bool) -> None:
|
||||
text = RUNTIME_MANIFEST.read_text(encoding="utf-8")
|
||||
old, new = (LINTS_ON, LINTS_CAPPED) if capped else (LINTS_CAPPED, LINTS_ON)
|
||||
if old in text:
|
||||
RUNTIME_MANIFEST.write_text(text.replace(old, new), encoding="utf-8")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--cargo", default="scripts/dev-cargo.sh")
|
||||
parser.add_argument("--max-rounds", type=int, default=12)
|
||||
parser.add_argument("--packages", default="codewhale-runtime,codewhale-tui")
|
||||
parser.add_argument("--extra", default="--lib --tests")
|
||||
args = parser.parse_args(argv)
|
||||
cargo = shlex.split(args.cargo)
|
||||
packages = args.packages.split(",")
|
||||
extra = shlex.split(args.extra)
|
||||
|
||||
log: list[str] = []
|
||||
set_lints(capped=True)
|
||||
try:
|
||||
for round_no in range(1, args.max_rounds + 1):
|
||||
messages = run_cargo(cargo, packages, extra)
|
||||
edits, unresolved = plan_edits(messages)
|
||||
if not edits:
|
||||
break
|
||||
changes = apply(edits)
|
||||
log.extend(changes)
|
||||
print(f"round {round_no}: {len(changes)} edit(s)", flush=True)
|
||||
finally:
|
||||
set_lints(capped=False)
|
||||
for line in log:
|
||||
print(line)
|
||||
messages = run_cargo(cargo, packages, extra)
|
||||
edits, unresolved = plan_edits(messages)
|
||||
remaining = [m for m in messages if m.get("level") == "error"]
|
||||
if edits or remaining:
|
||||
print(f"\n{len(remaining)} error(s) and {len(edits)} further edit(s) remain with lints on:")
|
||||
for m in remaining[:40]:
|
||||
print(m.get("rendered") or m.get("message"))
|
||||
for (path, line_no), new in list(edits.items())[:40]:
|
||||
print(f"pending {path.relative_to(REPO_ROOT)}:{line_no}: {new}")
|
||||
return 1
|
||||
print(f"clean: {len(log)} edit(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("CARGO_TERM_COLOR", "never")
|
||||
sys.exit(main())
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -110,5 +111,142 @@ class SourceTests(unittest.TestCase):
|
||||
self.assertEqual(mod.check_contract_source_text(source, "safe.rs"), [])
|
||||
|
||||
|
||||
class TreeModeTests(unittest.TestCase):
|
||||
RULE = mod.BoundaryRule(
|
||||
"codewhale-runtime",
|
||||
"tree",
|
||||
("codewhale-tui", "ratatui", "crossterm"),
|
||||
"the runtime must stay UI-free",
|
||||
)
|
||||
|
||||
def test_parse_cargo_tree_keeps_names(self) -> None:
|
||||
text = "codewhale-runtime v0.10.0 (/x)\nanyhow v1.0.100\nratatui v0.30.2 (*)\n"
|
||||
self.assertEqual(mod.parse_cargo_tree(text), {"codewhale-runtime", "anyhow", "ratatui"})
|
||||
|
||||
def test_clean_tree_passes(self) -> None:
|
||||
self.assertEqual(mod.check_tree_packages(self.RULE, {"anyhow", "serde"}), [])
|
||||
|
||||
def test_runtime_rule_is_tree_mode(self) -> None:
|
||||
rule = next(r for r in mod.BOUNDARY_RULES if r.package == "codewhale-runtime")
|
||||
self.assertEqual(rule.mode, "tree")
|
||||
self.assertIn("ratatui", rule.forbidden_packages)
|
||||
self.assertIn("crossterm", rule.forbidden_packages)
|
||||
|
||||
def test_runtime_source_scan(self) -> None:
|
||||
self.assertEqual(
|
||||
mod.check_runtime_source_text("// ratatui::Frame is not used here\nfn f() {}\n", "a.rs"),
|
||||
[],
|
||||
)
|
||||
for source in (
|
||||
"use crossterm::terminal;",
|
||||
"let _ = ratatui::style::Color::Reset;",
|
||||
'const X: &str = include_str!("../../tui/assets/x.json");',
|
||||
):
|
||||
with self.subTest(source=source):
|
||||
self.assertTrue(mod.check_runtime_source_text(source, "a.rs"))
|
||||
|
||||
def test_ui_library_in_tree_fails(self) -> None:
|
||||
violations = mod.check_tree_packages(self.RULE, {"anyhow", "ratatui", "crossterm"})
|
||||
self.assertEqual(len(violations), 2)
|
||||
self.assertIn("ratatui", str(violations[1]) + str(violations[0]))
|
||||
|
||||
|
||||
def write_tree(root: Path, files: dict[str, str]) -> None:
|
||||
for rel, text in files.items():
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
class RatchetTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.graph = mod.load_runtime_ratchet()
|
||||
|
||||
def report(self, tui: dict[str, str], runtime: dict[str, str] | None = None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
write_tree(Path(tmp, "tui"), tui)
|
||||
write_tree(Path(tmp, "runtime"), runtime or {})
|
||||
return self.graph.build_report(Path(tmp, "tui"), Path(tmp, "runtime"))
|
||||
|
||||
def test_counts_grouped_imports_and_masks_comments_and_strings(self) -> None:
|
||||
report = self.report({
|
||||
"lib.rs": "mod core; mod tui;\n",
|
||||
"core.rs": (
|
||||
"use crate::{tui::App, tui::views::{A, B}};\n"
|
||||
"// crate::tui::ignored\n"
|
||||
"const S: &str = \"crate::tui::ignored\";\n"
|
||||
"fn f() { crate::tui::draw(); }\n"
|
||||
"#[cfg(test)]\nmod tests { fn t() { crate::tui::fixture(); } }\n"
|
||||
),
|
||||
"tui.rs": "",
|
||||
})
|
||||
self.assertEqual(report.counts["prod"], {"core|tui": 4})
|
||||
self.assertEqual(report.counts["test"], {"core|tui": 1})
|
||||
|
||||
def test_ui_library_and_late_edges_are_counted(self) -> None:
|
||||
report = self.report({
|
||||
"lib.rs": "mod core; mod exec_agent;\n",
|
||||
"core.rs": "fn f() { crossterm::terminal::enable_raw_mode(); }\n"
|
||||
"#[cfg(test)]\nmod tests { fn t() { crate::exec_agent::run(); } }\n",
|
||||
"exec_agent.rs": "",
|
||||
})
|
||||
self.assertEqual(report.counts["uilib"], {"core|crossterm": 1})
|
||||
self.assertEqual(report.counts["late"], {"core|exec_agent": 1})
|
||||
|
||||
def test_runtime_crate_modules_join_the_closure(self) -> None:
|
||||
report = self.report(
|
||||
{"lib.rs": "mod core;\nuse codewhale_runtime::{elapsed};\n", "core.rs": ""},
|
||||
{"lib.rs": "pub mod elapsed;\n", "elapsed.rs": "fn f() { ratatui::x(); }\n"},
|
||||
)
|
||||
self.assertIn("elapsed", report.closure)
|
||||
self.assertEqual(report.counts["uilib"], {"elapsed|ratatui": 1})
|
||||
|
||||
def test_rise_and_unrecorded_drop_both_fail(self) -> None:
|
||||
report = self.report({
|
||||
"lib.rs": "mod core; mod tui;\n",
|
||||
"core.rs": "fn f() { crate::tui::a(); crate::tui::b(); }\n",
|
||||
"tui.rs": "",
|
||||
})
|
||||
rises, drops = self.graph.compare({"counts": {"prod": {"core|tui": 1}}}, report)
|
||||
self.assertTrue(rises and rises[0].startswith("prod core|tui: 1 -> 2"))
|
||||
self.assertEqual(drops, [])
|
||||
rises, drops = self.graph.compare({"counts": {"prod": {"core|tui": 3}}}, report)
|
||||
self.assertEqual((rises, drops), ([], ["prod core|tui: 3 -> 2"]))
|
||||
|
||||
def test_super_chains_that_reach_the_root_count(self) -> None:
|
||||
report = self.report({
|
||||
"lib.rs": "mod core; mod tui;\n",
|
||||
"core/mod.rs": (
|
||||
"mod inner;\n"
|
||||
"fn f() { super::tui::a(); }\n"
|
||||
"pub(in super::super) fn g() {}\n"
|
||||
"#[cfg(test)]\nmod tests { use super::super::{tui::B, x}; use super::*; }\n"
|
||||
),
|
||||
# Depth 2: two `super`s reach the root, one stays inside `core`.
|
||||
"core/inner.rs": "use super::super::tui as ui;\nfn f() { super::tui::local(); }\n",
|
||||
"tui.rs": "",
|
||||
})
|
||||
self.assertEqual(report.counts["prod"], {"core|tui": 2})
|
||||
self.assertEqual(report.counts["test"], {"core|tui": 1})
|
||||
|
||||
def test_hand_raised_baseline_fails_against_the_base(self) -> None:
|
||||
previous = {"counts": {"prod": {"tools|tui": 8}}}
|
||||
self.assertEqual(self.graph.baseline_raises(previous, previous), [])
|
||||
self.assertEqual(
|
||||
self.graph.baseline_raises(previous, {"counts": {"prod": {"tools|tui": 7}}}), []
|
||||
)
|
||||
self.assertEqual(
|
||||
self.graph.baseline_raises(previous, {"counts": {"prod": {"tools|tui": 9}}}),
|
||||
["prod tools|tui: 8 -> 9"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.graph.baseline_raises(previous, {"counts": {"test": {"core|tui": 1}}}),
|
||||
["test core|tui: 0 -> 1 (new pair)"],
|
||||
)
|
||||
|
||||
def test_checked_in_baseline_holds(self) -> None:
|
||||
self.assertEqual(self.graph.check(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* generated from crates/palette/src/tokens.rs + vendor/codewhale-design/tokens.json — do not edit */
|
||||
/* generated from crates/palette/src/rgb.rs + vendor/codewhale-design/tokens.json — do not edit */
|
||||
/* regenerate: scripts/export-design-tokens.py (in the codewhale repo) */
|
||||
:root {
|
||||
--whale-bg: #070c1d;
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface RepoFacts {
|
||||
}
|
||||
|
||||
export const FACTS: RepoFacts = {
|
||||
"generatedAt": "2026-09-24T07:58:50.292Z",
|
||||
"generatedAt": "2026-09-25T21:38:00.475Z",
|
||||
"sourceRevision": null,
|
||||
"sourceCommittedAt": null,
|
||||
"version": "0.10.0",
|
||||
@@ -61,6 +61,7 @@ export const FACTS: RepoFacts = {
|
||||
"paths",
|
||||
"protocol",
|
||||
"release",
|
||||
"runtime",
|
||||
"secrets",
|
||||
"state",
|
||||
"telemetry",
|
||||
|
||||
Reference in new issue
Block a user