fix(release): finish the v0.9.6 candidate
Move first-run usage disclosure into the native TUI, keep telemetry unarmed until the decision, and order all telemetry writes and delivery against persistent opt-out with fail-closed setup-state handling. Replace the narrow wide-terminal rail with a responsive full-screen ocean canvas, preserve readable prose measure, and remove per-call padding inside grouped tool activity. Verified with formatting, clippy, locale/version/budget gates, the 10,120-test TUI library suite, telemetry/config suites, focused UI/PTy coverage, and independent release/UI review. The sandbox-only loopback acceptance rerun remains explicitly environmental. Agent assistance: Claude and Codex were used for implementation analysis and verification.
This commit is contained in:
+13
-7
@@ -30,13 +30,15 @@ runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
|
||||
|
||||
### Changed
|
||||
|
||||
- Anonymous usage counting is on by default for fresh installs, clearly
|
||||
disclosed, and immediately opt-out. Prior declines remain off. Codewhale does
|
||||
not collect conversations, code, prompts, files, repo or branch names,
|
||||
credentials, model content, or per-turn activity timelines.
|
||||
- Wide terminals center the header, transcript, work strip, composer, and
|
||||
footer on one 112-column session rail. Compact terminals remain fluid and use
|
||||
their full available width.
|
||||
- Anonymous usage counting is on by default for fresh installs and disclosed in
|
||||
a native first-run Codewhale modal with an immediate opt-out. Prior declines
|
||||
remain off. Codewhale does not collect conversations, code, prompts, files,
|
||||
repo or branch names, credentials, model content, or per-turn activity
|
||||
timelines.
|
||||
- Wide terminals use a responsive, full-screen ocean canvas with modest
|
||||
gutters: prose keeps a readable measure while tools, diffs, work surfaces,
|
||||
the composer, and status chrome can use the available width. Turn and major
|
||||
activity seams breathe without padding every call inside a tool group.
|
||||
- Root CLI help describes product actions directly instead of exposing internal
|
||||
TUI/runtime layers.
|
||||
- `Bash action="wait"` now blocks by default when a wait is requested; callers
|
||||
@@ -58,6 +60,10 @@ runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
|
||||
|
||||
### Fixed
|
||||
|
||||
- First-run usage disclosure now opens as a native Codewhale modal instead of a
|
||||
shell questionnaire before application startup. Telemetry remains unarmed
|
||||
until the native choice is made, and an in-memory Disable choice governs the
|
||||
current session even when its preference cannot be saved.
|
||||
- `/compact` completion, failure, queued, duplicate, and mailbox outcomes are
|
||||
durable transcript receipts instead of short-lived toasts. A stray terminal
|
||||
event can no longer leave every later compaction stuck as already running.
|
||||
|
||||
+36
-5
@@ -2050,11 +2050,13 @@ fn start_cli_telemetry(
|
||||
config_path: Option<PathBuf>,
|
||||
surface: Surface,
|
||||
) -> Option<CliTelemetrySession> {
|
||||
let setup = SetupState::load().ok().flatten().unwrap_or_default();
|
||||
let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
|
||||
return None;
|
||||
};
|
||||
telemetry::init(consent.with_config_path(config_path));
|
||||
let consent = resolve_cli_telemetry_consent(
|
||||
resolved,
|
||||
config_path,
|
||||
surface,
|
||||
telemetry::load_setup_state_for_decision(),
|
||||
)?;
|
||||
telemetry::init(consent);
|
||||
telemetry::record(Event::SessionStart {
|
||||
source: SessionSource::Unknown,
|
||||
});
|
||||
@@ -2063,6 +2065,19 @@ fn start_cli_telemetry(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_cli_telemetry_consent(
|
||||
resolved: &ResolvedRuntimeOptions,
|
||||
config_path: Option<PathBuf>,
|
||||
surface: Surface,
|
||||
setup: Option<SetupState>,
|
||||
) -> Option<telemetry::TelemetryConsent> {
|
||||
let setup = setup?;
|
||||
let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
|
||||
return None;
|
||||
};
|
||||
Some(consent.with_config_path(config_path))
|
||||
}
|
||||
|
||||
/// Close the session opened by [`start_cli_telemetry`] and flush, bounded.
|
||||
///
|
||||
/// The exit class comes from what actually happened, never from an exit code:
|
||||
@@ -8135,4 +8150,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_telemetry_start_fails_closed_on_a_corrupt_setup_state() {
|
||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let setup_path = dir.path().join("setup_state.json");
|
||||
std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
|
||||
let setup = telemetry::load_setup_state_for_decision_at(&setup_path);
|
||||
assert!(setup.is_none(), "corrupt privacy state must not default on");
|
||||
|
||||
let resolved =
|
||||
ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
assert!(
|
||||
resolve_cli_telemetry_consent(&resolved, None, Surface::Cli, setup).is_none(),
|
||||
"CLI startup must not obtain permission from an unreadable privacy record"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +40,11 @@ pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
|
||||
///
|
||||
/// The notice is owed whenever
|
||||
/// [`SetupState::telemetry_notice_decided_for`] does not match this string.
|
||||
/// Bumping it re-asks everyone, so it is bumped only when the collection policy,
|
||||
/// schema, or disclosure materially changes. Keying it to the app version would
|
||||
/// re-prompt every release, which is nagging with extra steps.
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "2";
|
||||
/// Bumping it re-asks prior acceptors and unanswered users, so it is bumped only
|
||||
/// when the collection policy, schema, or disclosure materially changes. Prior
|
||||
/// declines remain off. Keying it to the app version would re-prompt every
|
||||
/// release, which is nagging with extra steps.
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "3";
|
||||
|
||||
/// Canonical setup step ids. The ordering matches the first-run spine so a
|
||||
/// `BTreeMap<SetupStep, _>` renders in wizard order.
|
||||
|
||||
+132
-87
@@ -12,20 +12,18 @@
|
||||
//! | `install_id.json` | the random install id |
|
||||
//! | `disabled` | the tombstone: present ⇒ nothing is appended, drained, or sent |
|
||||
//!
|
||||
//! **Appends never take a lock.** One `O_APPEND` `write(2)` under `PIPE_BUF` is
|
||||
//! atomic on every filesystem this ships to, and taking `fd_lock` here would be
|
||||
//! a *blocking* acquisition on the panic hook and the SIGINT path. `flock` is
|
||||
//! per-fd within a process, so an actor panic while holding the compaction lock
|
||||
//! would self-deadlock the hook — `catch_unwind` runs *after* the hook, so it
|
||||
//! cannot save this — and a second Codewhale process sharing `CODEWHALE_HOME`
|
||||
//! would hang Ctrl-C, breaking the second-signal contract in `main.rs`.
|
||||
//! Appends, compaction, delivery, identity/state writes, startup arming, and
|
||||
//! wipe share one sibling lock. Runtime and exit paths take it with
|
||||
//! `try_write()`: on contention the event or batch is dropped. Only startup
|
||||
//! arming and the user-requested wipe may wait. That keeps the panic hook and
|
||||
//! SIGINT path non-blocking while also making opt-out an ordering boundary —
|
||||
//! after wipe returns, no pre-wipe writer or sender can still publish data.
|
||||
//!
|
||||
//! Compaction is the only lock holder and uses `try_write()`: on contention it
|
||||
//! skips this cycle. Appenders re-open per append, so a compaction rewrite
|
||||
//! cannot leave anyone writing to a stale inode.
|
||||
//! Appenders re-open per append, so a compaction rewrite cannot leave anyone
|
||||
//! writing to a stale inode.
|
||||
|
||||
use std::fs::{self, DirBuilder, File, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -37,6 +35,16 @@ pub const MAX_BYTES: u64 = 256 * 1024;
|
||||
/// A single append must fit in one atomic `write(2)`.
|
||||
pub const MAX_LINE_BYTES: usize = 4096;
|
||||
|
||||
/// Exact contents of the opt-out tombstone observed by a consent decision.
|
||||
///
|
||||
/// A fresh nonce is written when a machine transitions into an opted-out
|
||||
/// period. Arming may clear only the exact generation its decision observed,
|
||||
/// so an older consent token cannot erase a newer opt-out.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TombstoneGeneration(Vec<u8>);
|
||||
|
||||
const MAX_TOMBSTONE_BYTES: u64 = 128;
|
||||
|
||||
/// Below this size a sink cannot possibly hold [`MAX_EVENTS`] lines, so an
|
||||
/// append skips the count probe entirely. The shortest serializable event line
|
||||
/// is well over 8 bytes, and `512 * 9 > 4096`, so this bound is safe by
|
||||
@@ -94,6 +102,29 @@ pub fn tombstone_present(root: &Path) -> bool {
|
||||
tombstone_path(root).exists()
|
||||
}
|
||||
|
||||
/// Read the exact tombstone generation, or `None` when collection has never
|
||||
/// been disabled in this home.
|
||||
pub(crate) fn tombstone_generation(root: &Path) -> Result<Option<TombstoneGeneration>> {
|
||||
let path = tombstone_path(root);
|
||||
let file = match File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(
|
||||
anyhow::Error::new(error).context(format!("failed to open {}", path.display()))
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_TOMBSTONE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if bytes.len() as u64 > MAX_TOMBSTONE_BYTES {
|
||||
anyhow::bail!("{} exceeds the tombstone size limit", path.display());
|
||||
}
|
||||
Ok(Some(TombstoneGeneration(bytes)))
|
||||
}
|
||||
|
||||
/// Create the telemetry directory `0700`, if it is missing.
|
||||
pub fn ensure_dir(root: &Path) -> Result<()> {
|
||||
if root.is_dir() {
|
||||
@@ -125,38 +156,12 @@ fn secure(_file: &File) -> Result<()> {
|
||||
|
||||
/// Append one serialized event or batch to `path`.
|
||||
///
|
||||
/// Returns `None` — never an error — when the tombstone is present, when the
|
||||
/// line would not fit in one atomic write, or when any filesystem step fails.
|
||||
/// Telemetry is fail-open by construction: it never returns an error to a
|
||||
/// caller and never blocks a turn, a tool, or process exit.
|
||||
/// Returns `None` — never an error — when the tombstone is present, the privacy
|
||||
/// lock is held, the line would not fit in one atomic write, or any filesystem
|
||||
/// step fails. The lock acquisition is non-blocking, including on the panic and
|
||||
/// signal paths.
|
||||
pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
|
||||
if tombstone_present(root) {
|
||||
return None;
|
||||
}
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.is_empty() || bytes.len() + 1 > MAX_LINE_BYTES {
|
||||
return None;
|
||||
}
|
||||
ensure_dir(root).ok()?;
|
||||
|
||||
let mut buf = Vec::with_capacity(bytes.len() + 1);
|
||||
buf.extend_from_slice(bytes);
|
||||
buf.push(b'\n');
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.ok()?;
|
||||
secure(&file).ok()?;
|
||||
// One `write(2)`, not `write_fmt` and not two calls: a split write is what
|
||||
// a concurrent appender would interleave with.
|
||||
(&file).write_all(&buf).ok()?;
|
||||
file.sync_data().ok()?;
|
||||
drop(file);
|
||||
|
||||
enforce_ring(root, path);
|
||||
Some(())
|
||||
append_with_limit(root, path, line, MAX_LINE_BYTES)
|
||||
}
|
||||
|
||||
/// Append a line that is too large for one atomic `write(2)`, serialising
|
||||
@@ -167,47 +172,52 @@ pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
|
||||
/// handler, so a **non-blocking** `try_write` is safe there. On contention the
|
||||
/// batch is dropped, which is the same fail-open behavior as a failed POST.
|
||||
pub fn append_locked(root: &Path, path: &Path, line: &str) -> Option<()> {
|
||||
if tombstone_present(root) {
|
||||
return None;
|
||||
}
|
||||
append_with_limit(root, path, line, MAX_BYTES as usize)
|
||||
}
|
||||
|
||||
fn append_with_limit(root: &Path, path: &Path, line: &str, limit: usize) -> Option<()> {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.is_empty() || bytes.len() as u64 + 1 > MAX_BYTES {
|
||||
if bytes.is_empty() || bytes.len() + 1 > limit {
|
||||
return None;
|
||||
}
|
||||
ensure_dir(root).ok()?;
|
||||
|
||||
let mut buf = Vec::with_capacity(bytes.len() + 1);
|
||||
buf.extend_from_slice(bytes);
|
||||
buf.push(b'\n');
|
||||
|
||||
let wrote = try_with_lock(root, || {
|
||||
if tombstone_present(root) {
|
||||
return Ok(false);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("failed to open {}", path.display()))?;
|
||||
secure(&file)?;
|
||||
(&file)
|
||||
.write_all(&buf)
|
||||
.with_context(|| format!("failed to append to {}", path.display()))?;
|
||||
file.sync_data()
|
||||
.with_context(|| format!("failed to sync {}", path.display()))?;
|
||||
Ok(true)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
|
||||
let wrote = try_with_lock(root, || append_under_lock(root, path, &buf))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
if !wrote {
|
||||
return None;
|
||||
}
|
||||
|
||||
enforce_ring(root, path);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Append bytes while the caller holds the sibling privacy lock.
|
||||
fn append_under_lock(root: &Path, path: &Path, buf: &[u8]) -> Result<bool> {
|
||||
if tombstone_present(root) {
|
||||
return Ok(false);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("failed to open {}", path.display()))?;
|
||||
secure(&file)?;
|
||||
// One `write(2)`, not `write_fmt` and not two calls: a split write is what
|
||||
// a concurrent appender would interleave with.
|
||||
(&file)
|
||||
.write_all(buf)
|
||||
.with_context(|| format!("failed to append to {}", path.display()))?;
|
||||
file.sync_data()
|
||||
.with_context(|| format!("failed to sync {}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Keep the newest [`MAX_EVENTS`] lines and at most [`MAX_BYTES`], under the
|
||||
/// compaction lock. On lock contention this cycle is skipped: the next append
|
||||
/// tries again, and the cap is a ceiling on disk footprint, not an invariant
|
||||
@@ -220,15 +230,25 @@ fn enforce_ring(root: &Path, path: &Path) {
|
||||
if len < PROBE_BYTES {
|
||||
return;
|
||||
}
|
||||
let Ok(contents) = fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = try_with_lock(root, || {
|
||||
// Re-read under the same lock as wipe. Reusing a snapshot captured
|
||||
// before a concurrent wipe would resurrect the records it truncated.
|
||||
if tombstone_present(root) {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(meta) = fs::metadata(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let len = meta.len();
|
||||
if len < PROBE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let mut kept: Vec<&str> = lines
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -383,14 +403,26 @@ pub fn truncate(path: &Path) -> Result<()> {
|
||||
pub fn wipe(root: &Path) -> Result<()> {
|
||||
with_lock(root, || {
|
||||
let tombstone = tombstone_path(root);
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&tombstone)
|
||||
.with_context(|| format!("failed to write {}", tombstone.display()))?;
|
||||
secure(&file)?;
|
||||
drop(file);
|
||||
// A tombstone generation identifies one durable opted-out period. Keep
|
||||
// it stable across later launches that re-observe the same persistent
|
||||
// choice; re-enable removes the file, so the next real opt-out creates
|
||||
// a naturally distinct generation. An unreadable or oversized file is
|
||||
// repaired in the fail-closed direction by replacing it here; legacy
|
||||
// empty tombstones remain valid stable generations.
|
||||
if tombstone_generation(root).ok().flatten().is_none() {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&tombstone)
|
||||
.with_context(|| format!("failed to write {}", tombstone.display()))?;
|
||||
secure(&file)?;
|
||||
file.write_all(uuid::Uuid::new_v4().to_string().as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tombstone.display()))?;
|
||||
file.sync_data()
|
||||
.with_context(|| format!("failed to sync {}", tombstone.display()))?;
|
||||
drop(file);
|
||||
}
|
||||
|
||||
let mut failure: Option<anyhow::Error> = None;
|
||||
for path in [buffer_path(root), dryrun_path(root)] {
|
||||
@@ -418,11 +450,24 @@ pub fn wipe(root: &Path) -> Result<()> {
|
||||
/// Clear the tombstone and drop anything buffered before this process was
|
||||
/// permitted.
|
||||
///
|
||||
/// Called by `init` on every arming. A stale buffer left by an earlier run or by
|
||||
/// a bug cannot enter the new process's batch.
|
||||
pub fn arm(root: &Path) -> Result<()> {
|
||||
/// Called by `init` on every arming. Both the exact tombstone generation and a
|
||||
/// fresh durable permission check must still match while the wipe lock is held.
|
||||
/// A stale buffer left by an earlier run or by a bug cannot enter the new
|
||||
/// process's batch.
|
||||
pub(crate) fn arm(
|
||||
root: &Path,
|
||||
observed_generation: Option<&TombstoneGeneration>,
|
||||
permission_still_enabled: impl FnOnce() -> bool,
|
||||
) -> Result<()> {
|
||||
ensure_dir(root)?;
|
||||
with_lock(root, || {
|
||||
let current_generation = tombstone_generation(root)?;
|
||||
if current_generation.as_ref() != observed_generation {
|
||||
anyhow::bail!("telemetry permission changed before arming");
|
||||
}
|
||||
if !permission_still_enabled() {
|
||||
anyhow::bail!("telemetry permission is no longer enabled");
|
||||
}
|
||||
let tombstone = tombstone_path(root);
|
||||
if tombstone.exists() {
|
||||
fs::remove_file(&tombstone)
|
||||
|
||||
@@ -35,12 +35,20 @@ pub enum SendOutcome {
|
||||
|
||||
/// Serialize and deliver one batch.
|
||||
///
|
||||
/// The tombstone is re-checked immediately before delivery, so a wipe that
|
||||
/// landed while the batch was being assembled still stops it.
|
||||
/// Network delivery holds the same non-blocking privacy lock as appends and
|
||||
/// wipe. A wipe waits for an already-started POST to finish; a POST that races
|
||||
/// a held or completed wipe is dropped before reaching the wire. Therefore no
|
||||
/// delivery can remain in flight after persistent opt-out returns.
|
||||
pub fn send(root: &Path, endpoint: Option<&str>, batch: &Batch) -> SendOutcome {
|
||||
if buffer::tombstone_present(root) {
|
||||
return SendOutcome::Dropped;
|
||||
}
|
||||
send_with_transport(root, endpoint, batch, post)
|
||||
}
|
||||
|
||||
pub(crate) fn send_with_transport(
|
||||
root: &Path,
|
||||
endpoint: Option<&str>,
|
||||
batch: &Batch,
|
||||
transport: impl FnOnce(&str, &str, String) -> SendOutcome,
|
||||
) -> SendOutcome {
|
||||
let Ok(body) = serde_json::to_string(batch) else {
|
||||
return SendOutcome::Dropped;
|
||||
};
|
||||
@@ -52,7 +60,15 @@ pub fn send(root: &Path, endpoint: Option<&str>, batch: &Batch) -> SendOutcome {
|
||||
None => SendOutcome::Dropped,
|
||||
}
|
||||
}
|
||||
Some(endpoint) => post(endpoint, &batch.app_version, body),
|
||||
Some(endpoint) => buffer::try_with_lock(root, || {
|
||||
if buffer::tombstone_present(root) {
|
||||
return Ok(SendOutcome::Dropped);
|
||||
}
|
||||
Ok(transport(endpoint, &batch.app_version, body))
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(SendOutcome::Dropped),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,10 @@ pub const TELEMETRY_DIR: &str = "telemetry";
|
||||
pub enum TelemetryDecision {
|
||||
/// Anonymous usage counting is enabled and nothing forces it off.
|
||||
Enabled(TelemetryConsent),
|
||||
/// A human said no — `--telemetry false`, `CODEWHALE_TELEMETRY=0`,
|
||||
/// `telemetry = false`, or declining the notice. **The only variant that
|
||||
/// touches disk**: it wipes and leaves a tombstone.
|
||||
/// A human persistently said no — `telemetry = false` in durable config or
|
||||
/// declining the notice. **The only variant that touches disk**: it wipes
|
||||
/// and leaves a tombstone. CLI and environment false values are run-scoped
|
||||
/// kill switches and produce [`Self::ForcedOff`] instead.
|
||||
OptedOut,
|
||||
/// Off for a run-scoped or environmental reason: an unparseable env value,
|
||||
/// an unresolvable home, or a rejected endpoint. Touches nothing, ever.
|
||||
@@ -69,6 +70,7 @@ pub struct TelemetryConsent {
|
||||
endpoint: Option<String>,
|
||||
surface: Surface,
|
||||
config_path: Option<PathBuf>,
|
||||
tombstone_generation: Option<buffer::TombstoneGeneration>,
|
||||
}
|
||||
|
||||
impl TelemetryConsent {
|
||||
@@ -107,6 +109,21 @@ impl TelemetryConsent {
|
||||
pub fn surface(&self) -> Surface {
|
||||
self.surface
|
||||
}
|
||||
|
||||
/// Exact opt-out generation this decision observed.
|
||||
pub(crate) fn tombstone_generation(&self) -> Option<&buffer::TombstoneGeneration> {
|
||||
self.tombstone_generation.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
enum TelemetryEvaluation {
|
||||
Enabled {
|
||||
root: PathBuf,
|
||||
endpoint: Option<String>,
|
||||
tombstone_generation: Option<buffer::TombstoneGeneration>,
|
||||
},
|
||||
OptedOut(Option<PathBuf>),
|
||||
ForcedOff,
|
||||
}
|
||||
|
||||
/// Why an endpoint was refused.
|
||||
@@ -192,6 +209,29 @@ pub fn decide(
|
||||
decide_in_home(home.as_deref(), resolved, setup, surface)
|
||||
}
|
||||
|
||||
/// Load the privacy-bearing setup record for a telemetry decision.
|
||||
///
|
||||
/// A genuinely missing record is a fresh installation and therefore uses the
|
||||
/// documented default. An existing record that cannot be read or parsed may
|
||||
/// contain a durable decline, so it fails closed instead of being replaced by
|
||||
/// a default-on value.
|
||||
#[must_use]
|
||||
pub fn load_setup_state_for_decision() -> Option<SetupState> {
|
||||
let path = SetupState::path().ok()?;
|
||||
load_setup_state_for_decision_at(&path)
|
||||
}
|
||||
|
||||
/// Injectable form of [`load_setup_state_for_decision`] used by every surface
|
||||
/// and by regression tests.
|
||||
#[must_use]
|
||||
pub fn load_setup_state_for_decision_at(path: &Path) -> Option<SetupState> {
|
||||
match path.try_exists() {
|
||||
Ok(false) => Some(SetupState::default()),
|
||||
Ok(true) => SetupState::load_from(path),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the emit predicate against an explicit Codewhale home.
|
||||
///
|
||||
/// The predicate, in order:
|
||||
@@ -204,35 +244,62 @@ pub fn decide(
|
||||
/// 4. Endpoint configured but refused by [`validate_endpoint`] → `ForcedOff`.
|
||||
/// 5. Otherwise `Enabled`.
|
||||
///
|
||||
/// The notice is only ever *rendered* on a TTY. The interactive notice explains
|
||||
/// the default before the TUI enters raw mode;
|
||||
/// headless surfaces use the same documented default and kill switches.
|
||||
/// The notice is only ever *rendered* on a TTY. The interactive TUI explains
|
||||
/// the default in a native startup modal before telemetry is armed; headless
|
||||
/// surfaces use the same documented default and kill switches.
|
||||
pub fn decide_in_home(
|
||||
home: Option<&Path>,
|
||||
resolved: &ResolvedRuntimeOptions,
|
||||
setup: &SetupState,
|
||||
surface: Surface,
|
||||
) -> TelemetryDecision {
|
||||
match evaluate_in_home(home, resolved, setup) {
|
||||
TelemetryEvaluation::Enabled {
|
||||
root,
|
||||
endpoint,
|
||||
tombstone_generation,
|
||||
} => TelemetryDecision::Enabled(TelemetryConsent {
|
||||
root,
|
||||
endpoint,
|
||||
surface,
|
||||
config_path: None,
|
||||
tombstone_generation,
|
||||
}),
|
||||
TelemetryEvaluation::OptedOut(root) => opted_out(root.as_deref()),
|
||||
TelemetryEvaluation::ForcedOff => TelemetryDecision::ForcedOff,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the permission predicate without performing the opt-out wipe.
|
||||
///
|
||||
/// Keeping the classification pure lets `init` re-check it while holding the
|
||||
/// privacy lock. The public decision path maps `OptedOut` to the destructive
|
||||
/// wipe exactly once, outside that already-held lock.
|
||||
fn evaluate_in_home(
|
||||
home: Option<&Path>,
|
||||
resolved: &ResolvedRuntimeOptions,
|
||||
setup: &SetupState,
|
||||
) -> TelemetryEvaluation {
|
||||
let root = home.map(|home| home.join(TELEMETRY_DIR));
|
||||
|
||||
// 1. An explicit persistent "off" is an opt-out and wipes. Run-scoped or
|
||||
// invalid-value false is only a kill switch and leaves disk alone.
|
||||
if !resolved.telemetry {
|
||||
if resolved.telemetry_explicit_off {
|
||||
return opted_out(root.as_deref());
|
||||
return TelemetryEvaluation::OptedOut(root);
|
||||
}
|
||||
return TelemetryDecision::ForcedOff;
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
}
|
||||
|
||||
// 2. A historical or current decline remains a durable opt-out. Notice
|
||||
// version bumps may update disclosure, never reverse a user's "no".
|
||||
if setup.telemetry_opted_out() {
|
||||
return opted_out(root.as_deref());
|
||||
return TelemetryEvaluation::OptedOut(root);
|
||||
}
|
||||
|
||||
// 3. Nowhere to keep an install id or a buffer.
|
||||
let Some(root) = root else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
};
|
||||
|
||||
// 4. A refused endpoint is a configuration error, not a user answer.
|
||||
@@ -244,18 +311,54 @@ pub fn decide_in_home(
|
||||
"telemetry endpoint refused ({}); telemetry is off for this run",
|
||||
error.label()
|
||||
);
|
||||
return TelemetryDecision::ForcedOff;
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
|
||||
TelemetryDecision::Enabled(TelemetryConsent {
|
||||
let Ok(tombstone_generation) = buffer::tombstone_generation(&root) else {
|
||||
return TelemetryEvaluation::ForcedOff;
|
||||
};
|
||||
TelemetryEvaluation::Enabled {
|
||||
root,
|
||||
endpoint,
|
||||
surface,
|
||||
config_path: None,
|
||||
})
|
||||
tombstone_generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-check the current durable permission without wiping or clearing state.
|
||||
///
|
||||
/// Called only while `init` holds the telemetry privacy lock. A stale consent
|
||||
/// token may arm only when the config, setup-state answer, home, and endpoint
|
||||
/// still classify as enabled.
|
||||
pub(crate) fn permission_still_enabled(config_path: Option<&Path>, expected_root: &Path) -> bool {
|
||||
let Ok(setup_path) = SetupState::path() else {
|
||||
return false;
|
||||
};
|
||||
let home = codewhale_paths::codewhale_home().ok().flatten();
|
||||
permission_still_enabled_in_home(config_path, &setup_path, home.as_deref(), expected_root)
|
||||
}
|
||||
|
||||
pub(crate) fn permission_still_enabled_in_home(
|
||||
config_path: Option<&Path>,
|
||||
setup_path: &Path,
|
||||
home: Option<&Path>,
|
||||
expected_root: &Path,
|
||||
) -> bool {
|
||||
let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
|
||||
return false;
|
||||
};
|
||||
let resolved = store
|
||||
.config
|
||||
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
|
||||
let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
evaluate_in_home(home, &resolved, &setup),
|
||||
TelemetryEvaluation::Enabled { root, .. } if root == expected_root
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-run the predicate from the filesystem, for the flush path.
|
||||
@@ -266,13 +369,26 @@ pub fn decide_in_home(
|
||||
/// load fails: a flush is never the right place to guess.
|
||||
#[must_use]
|
||||
pub fn re_decide(config_path: Option<&Path>, surface: Surface) -> TelemetryDecision {
|
||||
let Ok(setup_path) = SetupState::path() else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
};
|
||||
re_decide_with_setup_path(config_path, &setup_path, surface)
|
||||
}
|
||||
|
||||
pub(crate) fn re_decide_with_setup_path(
|
||||
config_path: Option<&Path>,
|
||||
setup_path: &Path,
|
||||
surface: Surface,
|
||||
) -> TelemetryDecision {
|
||||
let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
};
|
||||
let resolved = store
|
||||
.config
|
||||
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
|
||||
let setup = SetupState::load().ok().flatten().unwrap_or_default();
|
||||
let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
|
||||
return TelemetryDecision::ForcedOff;
|
||||
};
|
||||
decide(&resolved, &setup, surface)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,24 +59,29 @@ pub struct TelemetryState {
|
||||
/// always the safe direction — the cost is one rotation, and the docs already
|
||||
/// say no count derived from `install_id` is a user count.
|
||||
pub fn read_or_create_install_id(root: &Path) -> Result<InstallId> {
|
||||
let path = buffer::install_id_path(root);
|
||||
let existing = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|body| serde_json::from_str::<InstallId>(&body).ok())
|
||||
.filter(|record| uuid::Uuid::parse_str(record.install_id.trim()).is_ok())
|
||||
.filter(|record| !is_expired(&record.rotated_at));
|
||||
if let Some(record) = existing {
|
||||
return Ok(record);
|
||||
}
|
||||
let record = InstallId {
|
||||
schema_version: 1,
|
||||
install_id: uuid::Uuid::new_v4().to_string(),
|
||||
rotated_at: now_rfc3339(),
|
||||
};
|
||||
buffer::ensure_dir(root)?;
|
||||
codewhale_config::persistence::atomic_write_json(&path, &record)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(record)
|
||||
buffer::try_with_lock(root, || {
|
||||
if buffer::tombstone_present(root) {
|
||||
anyhow::bail!("telemetry is disabled");
|
||||
}
|
||||
let path = buffer::install_id_path(root);
|
||||
let existing = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|body| serde_json::from_str::<InstallId>(&body).ok())
|
||||
.filter(|record| uuid::Uuid::parse_str(record.install_id.trim()).is_ok())
|
||||
.filter(|record| !is_expired(&record.rotated_at));
|
||||
if let Some(record) = existing {
|
||||
return Ok(record);
|
||||
}
|
||||
let record = InstallId {
|
||||
schema_version: 1,
|
||||
install_id: uuid::Uuid::new_v4().to_string(),
|
||||
rotated_at: now_rfc3339(),
|
||||
};
|
||||
codewhale_config::persistence::atomic_write_json(&path, &record)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(record)
|
||||
})?
|
||||
.ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held"))
|
||||
}
|
||||
|
||||
fn is_expired(rotated_at: &str) -> bool {
|
||||
@@ -100,10 +105,15 @@ pub fn read_state(root: &Path) -> TelemetryState {
|
||||
|
||||
/// Write `state.json`.
|
||||
pub fn write_state(root: &Path, state: &TelemetryState) -> Result<()> {
|
||||
buffer::ensure_dir(root)?;
|
||||
let path = buffer::state_path(root);
|
||||
codewhale_config::persistence::atomic_write_json(&path, state)
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
buffer::try_with_lock(root, || {
|
||||
if buffer::tombstone_present(root) {
|
||||
anyhow::bail!("telemetry is disabled");
|
||||
}
|
||||
let path = buffer::state_path(root);
|
||||
codewhale_config::persistence::atomic_write_json(&path, state)
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
})?
|
||||
.ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held"))
|
||||
}
|
||||
|
||||
/// RFC3339 UTC at second precision. The only timestamp this crate produces, and
|
||||
|
||||
+16
-10
@@ -53,7 +53,7 @@ pub use actor::{BATCH_MAX_BYTES, BATCH_MAX_EVENTS, FlushOutcome};
|
||||
pub use counters::{Counter, ErrorCounter, SessionCounters};
|
||||
pub use decision::{
|
||||
EndpointError, TELEMETRY_DIR, TelemetryConsent, TelemetryDecision, decide, decide_in_home,
|
||||
re_decide, validate_endpoint,
|
||||
load_setup_state_for_decision, load_setup_state_for_decision_at, re_decide, validate_endpoint,
|
||||
};
|
||||
pub use envelope::reduce_panic_site;
|
||||
pub use event::{
|
||||
@@ -90,10 +90,16 @@ pub fn init(consent: TelemetryConsent) {
|
||||
return;
|
||||
}
|
||||
let root = consent.root().to_path_buf();
|
||||
let observed_generation = consent.tombstone_generation().cloned();
|
||||
let config_path = consent.config_path().map(std::path::Path::to_path_buf);
|
||||
|
||||
// Clear a tombstone only after the permission decision has allowed this
|
||||
// process, and drop anything stale from before that decision.
|
||||
if let Err(error) = buffer::arm(&root) {
|
||||
// Re-check durable permission under the same ordering lock as wipe. The
|
||||
// generation match prevents consent resolved before a newer opt-out from
|
||||
// clearing that opt-out; the fresh predicate preserves intentional
|
||||
// `config set telemetry true` re-enablement.
|
||||
if let Err(error) = buffer::arm(&root, observed_generation.as_ref(), || {
|
||||
decision::permission_still_enabled(config_path.as_deref(), &root)
|
||||
}) {
|
||||
tracing::debug!("telemetry could not prepare its buffer: {error}");
|
||||
return;
|
||||
}
|
||||
@@ -210,18 +216,18 @@ pub fn record(event: Event) {
|
||||
armed.handle.record(event);
|
||||
}
|
||||
|
||||
/// Write an event synchronously, without the writer thread and **without any
|
||||
/// lock**.
|
||||
/// Write an event synchronously, without the writer thread.
|
||||
///
|
||||
/// The synchronous escape hatch for the three paths where the async world is
|
||||
/// gone or going: the panic hook, `record_caught_panic`, and the signal task
|
||||
/// immediately before `std::process::exit`. One `O_APPEND` `write(2)` under
|
||||
/// `PIPE_BUF`, a `sync_data`, and return — microseconds.
|
||||
///
|
||||
/// Taking the compaction lock here would be a *blocking* acquisition on both of
|
||||
/// those paths. `flock` is per-fd within a process, so an actor panic while
|
||||
/// holding that lock would self-deadlock the hook, and a second Codewhale
|
||||
/// process sharing `CODEWHALE_HOME` would hang Ctrl-C.
|
||||
/// The append takes the shared privacy lock with `try_write()`, never a blocking
|
||||
/// acquisition. If the actor, a wipe, or another Codewhale process sharing
|
||||
/// `CODEWHALE_HOME` holds it, the event is dropped immediately. This preserves
|
||||
/// the panic/SIGINT liveness contract without allowing a write to race past a
|
||||
/// completed opt-out.
|
||||
///
|
||||
/// A no-op when unarmed, which is what makes a disabled user's panic write
|
||||
/// nothing and create no directory.
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
//!
|
||||
//! Two properties of the wording are deliberate and load-bearing:
|
||||
//!
|
||||
//! 1. **The default is stated plainly and the opt-out is immediate.** Enter
|
||||
//! continues with anonymous usage counting on; `n`, `no`, `off`, or
|
||||
//! `disable` turns it off durably.
|
||||
//! 1. **The default is stated plainly and the opt-out is immediate.** The
|
||||
//! native TUI starts on Keep on and makes Disable equally reachable.
|
||||
//! 2. **The red lines are stated as "not collected", not as "anonymized".**
|
||||
//! Sampling and hashing are not the same promise, and a notice that implies
|
||||
//! them when neither is true is worse than no notice.
|
||||
@@ -19,8 +18,8 @@ pub const NOTICE_HEADLINE: &str = "Anonymous usage counting";
|
||||
|
||||
/// The notice itself.
|
||||
///
|
||||
/// Wrapped at 72 columns so it renders unchanged in a modal, in a pipe, and in
|
||||
/// an 80-column terminal.
|
||||
/// Wrapped at 72 columns so it renders unchanged in the native responsive
|
||||
/// modal and remains readable in an 80-column terminal.
|
||||
pub const NOTICE_BODY: &str = "\
|
||||
Codewhale sends anonymous product usage counts by default: which version
|
||||
you run, OS and CPU family, session duration and outcome, and aggregate
|
||||
@@ -34,28 +33,5 @@ You are identified only by a random ID stored on this machine. It is
|
||||
deleted the moment you turn this off, and it is replaced every 90 days.
|
||||
|
||||
Full schema, field by field: docs/TELEMETRY.md
|
||||
Disable it any time in settings:
|
||||
codewhale config set telemetry false
|
||||
or CODEWHALE_TELEMETRY=0";
|
||||
|
||||
/// The question, with the declining answer capitalised as the default.
|
||||
pub const NOTICE_PROMPT: &str = "Keep anonymous usage counting on? [Y/n]";
|
||||
|
||||
/// The line printed once a decision is recorded, so the user has a receipt.
|
||||
#[must_use]
|
||||
pub fn decision_receipt(enabled: bool) -> &'static str {
|
||||
if enabled {
|
||||
"Anonymous usage counting is on. Disable it any time with `codewhale config set telemetry false`."
|
||||
} else {
|
||||
"Anonymous usage counting is off. You will not be asked again."
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a typed answer keeps the default enabled.
|
||||
#[must_use]
|
||||
pub fn answer_keeps_enabled(input: &str) -> bool {
|
||||
!matches!(
|
||||
input.trim().to_ascii_lowercase().as_str(),
|
||||
"n" | "no" | "off" | "disable" | "disabled"
|
||||
)
|
||||
}
|
||||
Disable it any time:
|
||||
codewhale config set telemetry false";
|
||||
|
||||
+225
-48
@@ -17,7 +17,10 @@ use codewhale_config::{
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::buffer;
|
||||
use crate::decision::{EndpointError, TelemetryDecision, decide_in_home, validate_endpoint};
|
||||
use crate::decision::{
|
||||
EndpointError, TelemetryDecision, decide_in_home, load_setup_state_for_decision_at,
|
||||
permission_still_enabled_in_home, re_decide_with_setup_path, validate_endpoint,
|
||||
};
|
||||
use crate::envelope;
|
||||
use crate::event::*;
|
||||
|
||||
@@ -59,6 +62,46 @@ fn stale_setup() -> SetupState {
|
||||
setup
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_state_loader_defaults_only_when_the_privacy_record_is_absent() {
|
||||
let home = temp_home();
|
||||
let path = home.path().join("setup_state.json");
|
||||
|
||||
assert!(
|
||||
load_setup_state_for_decision_at(&path).is_some(),
|
||||
"a genuinely fresh install uses the documented default"
|
||||
);
|
||||
|
||||
std::fs::write(&path, b"{not-json").expect("write corrupt setup state");
|
||||
assert!(
|
||||
load_setup_state_for_decision_at(&path).is_none(),
|
||||
"an existing unreadable privacy record must fail closed"
|
||||
);
|
||||
|
||||
accepted_setup()
|
||||
.save_to(&path)
|
||||
.expect("write valid setup state");
|
||||
assert!(
|
||||
load_setup_state_for_decision_at(&path)
|
||||
.is_some_and(|setup| setup.telemetry_accepted(TELEMETRY_NOTICE_VERSION)),
|
||||
"a valid setup state remains usable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_redecision_fails_closed_on_a_corrupt_setup_state() {
|
||||
let home = temp_home();
|
||||
let config_path = home.path().join("config.toml");
|
||||
let setup_path = home.path().join("setup_state.json");
|
||||
std::fs::write(&config_path, "telemetry = true\n").expect("write config");
|
||||
std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
|
||||
|
||||
assert!(matches!(
|
||||
re_decide_with_setup_path(Some(&config_path), &setup_path, Surface::Exec),
|
||||
TelemetryDecision::ForcedOff
|
||||
));
|
||||
}
|
||||
|
||||
/// One instance of every event variant, populated with the most adversarial
|
||||
/// values the schema permits.
|
||||
///
|
||||
@@ -759,21 +802,23 @@ fn the_tombstone_outlives_every_run_the_opt_out_covers() {
|
||||
!buffer::install_id_path(&root).exists(),
|
||||
"{surface:?} minted a new identity for an opted-out machine"
|
||||
);
|
||||
assert!(!buffer::state_path(&root).exists());
|
||||
assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
|
||||
assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
|
||||
assert_eq!(snapshot(&root), after_wipe, "{surface:?} touched disk");
|
||||
}
|
||||
|
||||
// Only writing the setting back turns collection on again, and that is the
|
||||
// one path allowed to clear the tombstone.
|
||||
assert!(
|
||||
decide_in_home(
|
||||
Some(home.path()),
|
||||
&resolved(true, false, None),
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
)
|
||||
.is_enabled()
|
||||
);
|
||||
buffer::arm(&root).expect("re-consent arms");
|
||||
let TelemetryDecision::Enabled(consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&resolved(true, false, None),
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("an explicit re-enable must produce consent");
|
||||
};
|
||||
buffer::arm(&root, consent.tombstone_generation(), || true).expect("re-consent arms");
|
||||
assert!(!buffer::tombstone_present(&root));
|
||||
}
|
||||
|
||||
@@ -1078,7 +1123,7 @@ fn drain_skips_a_torn_trailing_line() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_never_blocks_on_a_held_lock() {
|
||||
fn append_drops_without_blocking_on_a_held_privacy_lock() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create root");
|
||||
@@ -1099,16 +1144,20 @@ fn append_never_blocks_on_a_held_lock() {
|
||||
|
||||
held.wait();
|
||||
let started = Instant::now();
|
||||
buffer::append(&root, &path, &line(7)).expect("append under a held lock");
|
||||
let outcome = buffer::append(&root, &path, &line(7));
|
||||
let elapsed = started.elapsed();
|
||||
release.wait();
|
||||
holder.join().expect("holder thread").expect("holder lock");
|
||||
|
||||
assert!(outcome.is_none(), "a contended append must be dropped");
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(250),
|
||||
"an append waited {elapsed:?} on a lock it must never take"
|
||||
"an append waited {elapsed:?} on the privacy lock"
|
||||
);
|
||||
assert!(
|
||||
buffer::read_lines(&path).is_empty(),
|
||||
"the panic-safe path bypassed the privacy lock"
|
||||
);
|
||||
assert_eq!(buffer::read_lines(&path).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1132,11 +1181,107 @@ fn arming_truncates_a_pre_consent_buffer() {
|
||||
buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
|
||||
buffer::arm(&root).expect("arm");
|
||||
let generation = buffer::tombstone_generation(&root).expect("read wipe generation");
|
||||
buffer::arm(&root, generation.as_ref(), || true).expect("arm");
|
||||
assert!(!buffer::tombstone_present(&root));
|
||||
assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_consent_cannot_clear_a_newer_opt_out_but_fresh_reenable_can() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let config_path = home.path().join("config.toml");
|
||||
let setup_path = home.path().join("setup_state.json");
|
||||
accepted_setup()
|
||||
.save_to(&setup_path)
|
||||
.expect("write accepted setup state");
|
||||
|
||||
// This process resolved the old enabled config before another process
|
||||
// persisted an opt-out and completed its wipe.
|
||||
let stale_resolved = resolved(true, false, None);
|
||||
let TelemetryDecision::Enabled(pre_wipe_consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&stale_resolved,
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("pre-wipe enabled facts must produce consent");
|
||||
};
|
||||
std::fs::write(&config_path, "telemetry = false\n").expect("persist opt-out");
|
||||
buffer::ensure_dir(&root).expect("create telemetry root");
|
||||
buffer::wipe(&root).expect("complete newer wipe");
|
||||
assert!(
|
||||
buffer::arm(&root, pre_wipe_consent.tombstone_generation(), || true).is_err(),
|
||||
"an old consent token cleared a newer tombstone generation"
|
||||
);
|
||||
assert!(buffer::tombstone_present(&root));
|
||||
|
||||
// Even the difficult ordering — stale config facts combined with the new
|
||||
// tombstone generation — cannot arm, because arm re-reads the durable
|
||||
// predicate while holding the wipe lock.
|
||||
let TelemetryDecision::Enabled(stale_consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&stale_resolved,
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("fixture must carry stale enabled facts");
|
||||
};
|
||||
assert!(
|
||||
buffer::arm(&root, stale_consent.tombstone_generation(), || {
|
||||
permission_still_enabled_in_home(
|
||||
Some(&config_path),
|
||||
&setup_path,
|
||||
Some(home.path()),
|
||||
&root,
|
||||
)
|
||||
})
|
||||
.is_err(),
|
||||
"stale consent cleared a completed opt-out"
|
||||
);
|
||||
assert!(buffer::tombstone_present(&root));
|
||||
|
||||
// The documented explicit re-enable updates the durable register first. A
|
||||
// fresh consent observes both that value and the current generation, so it
|
||||
// may clear exactly that tombstone.
|
||||
std::fs::write(&config_path, "telemetry = true\n").expect("persist re-enable");
|
||||
let TelemetryDecision::Enabled(fresh_consent) = decide_in_home(
|
||||
Some(home.path()),
|
||||
&resolved(true, false, None),
|
||||
&accepted_setup(),
|
||||
Surface::Tui,
|
||||
) else {
|
||||
panic!("fresh re-enable must produce consent");
|
||||
};
|
||||
buffer::arm(&root, fresh_consent.tombstone_generation(), || {
|
||||
permission_still_enabled_in_home(Some(&config_path), &setup_path, Some(home.path()), &root)
|
||||
})
|
||||
.expect("fresh re-enable arms");
|
||||
assert!(!buffer::tombstone_present(&root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_wipe_blocks_identity_and_state_recreation() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry root");
|
||||
envelope::read_or_create_install_id(&root).expect("seed install id");
|
||||
envelope::write_state(&root, &envelope::TelemetryState::default()).expect("seed state");
|
||||
buffer::wipe(&root).expect("wipe telemetry home");
|
||||
|
||||
assert!(
|
||||
envelope::read_or_create_install_id(&root).is_err(),
|
||||
"an in-flight flush recreated the deleted install id"
|
||||
);
|
||||
assert!(
|
||||
envelope::write_state(&root, &envelope::TelemetryState::default()).is_err(),
|
||||
"an in-flight flush recreated state after opt-out"
|
||||
);
|
||||
assert!(!buffer::install_id_path(&root).exists());
|
||||
assert!(!buffer::state_path(&root).exists());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ unarmed gate --
|
||||
|
||||
#[test]
|
||||
@@ -1196,6 +1341,62 @@ fn a_tombstoned_home_sends_nothing_even_with_an_endpoint() {
|
||||
assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipe_and_delivery_share_one_ordering_boundary() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
let entered = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let release = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
|
||||
let send_root = root.clone();
|
||||
let send_entered = entered.clone();
|
||||
let send_release = release.clone();
|
||||
let send = std::thread::spawn(move || {
|
||||
crate::client::send_with_transport(
|
||||
&send_root,
|
||||
Some("https://telemetry.codewhale.ai/v1/batch"),
|
||||
&every_field_batch(),
|
||||
move |_, _, _| {
|
||||
send_entered.wait();
|
||||
send_release.wait();
|
||||
crate::client::SendOutcome::Accepted
|
||||
},
|
||||
)
|
||||
});
|
||||
entered.wait();
|
||||
|
||||
// The real send path is paused inside its transport callback. A
|
||||
// non-blocking probe must observe the same lock that wipe takes; this
|
||||
// deterministically pins the entire delivery inside the boundary without
|
||||
// depending on loopback networking in a restricted test sandbox.
|
||||
assert!(
|
||||
buffer::try_with_lock(&root, || Ok(()))
|
||||
.expect("probe privacy lock")
|
||||
.is_none(),
|
||||
"network delivery did not hold the wipe lock"
|
||||
);
|
||||
|
||||
// Start the real blocking wipe while the POST is still in flight. It can
|
||||
// only complete after the response releases the sender's privacy guard.
|
||||
let wipe_root = root.clone();
|
||||
let wipe = std::thread::spawn(move || buffer::wipe(&wipe_root));
|
||||
release.wait();
|
||||
assert_eq!(
|
||||
send.join().expect("send thread"),
|
||||
crate::client::SendOutcome::Accepted
|
||||
);
|
||||
|
||||
wipe.join()
|
||||
.expect("wipe thread")
|
||||
.expect("wipe after delivery");
|
||||
assert!(buffer::tombstone_present(&root));
|
||||
assert_eq!(
|
||||
crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()),
|
||||
crate::client::SendOutcome::Dropped,
|
||||
"a send crossed the completed wipe boundary"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- buckets --
|
||||
|
||||
#[test]
|
||||
@@ -1589,13 +1790,14 @@ fn an_install_or_upgrade_is_reported_once_per_version() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_promises_exactly_what_the_schema_collects() {
|
||||
fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
|
||||
use crate::notice;
|
||||
|
||||
let body = notice::NOTICE_BODY;
|
||||
|
||||
// Everything the envelope carries has to be described. `install_id` is
|
||||
// "a random ID stored on this machine"; the rest are named directly.
|
||||
// The modal names the useful product categories and links the exact
|
||||
// field-by-field schema. `install_id` is "a random ID stored on this
|
||||
// machine"; transport metadata remains in the linked document.
|
||||
for claim in [
|
||||
"version",
|
||||
"OS and CPU family",
|
||||
@@ -1630,35 +1832,10 @@ fn the_notice_promises_exactly_what_the_schema_collects() {
|
||||
}
|
||||
assert!(!body.to_ascii_lowercase().contains("anonymized"));
|
||||
|
||||
// The two documented ways out, both of which are real.
|
||||
// The modal names the persistent opt-out because that is the switch that
|
||||
// also fulfils its deletion promise. Run-only kill switches stay in the
|
||||
// linked schema document, which explains that they erase nothing.
|
||||
assert!(body.contains("codewhale config set telemetry false"));
|
||||
assert!(body.contains("CODEWHALE_TELEMETRY=0"));
|
||||
assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
|
||||
assert!(body.contains("docs/TELEMETRY.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_explicit_negative_answer_disables_the_default() {
|
||||
use crate::notice::answer_keeps_enabled;
|
||||
|
||||
assert!(answer_keeps_enabled(""));
|
||||
assert!(answer_keeps_enabled("\n"));
|
||||
assert!(answer_keeps_enabled("y"));
|
||||
assert!(answer_keeps_enabled("Y\n"));
|
||||
assert!(answer_keeps_enabled(" yes \n"));
|
||||
assert!(answer_keeps_enabled("ye"));
|
||||
assert!(answer_keeps_enabled("1"));
|
||||
assert!(answer_keeps_enabled("true"));
|
||||
assert!(!answer_keeps_enabled("n"));
|
||||
assert!(!answer_keeps_enabled(" no \n"));
|
||||
assert!(!answer_keeps_enabled("off"));
|
||||
assert!(!answer_keeps_enabled("disable"));
|
||||
assert!(!answer_keeps_enabled("disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_prompt_capitalises_the_enabled_default() {
|
||||
// `[Y/n]`, not `[y/N]` and not `[y/n]`. The shape of the prompt is the
|
||||
// first thing a user reads about which way Enter goes.
|
||||
assert!(crate::notice::NOTICE_PROMPT.contains("[Y/n]"));
|
||||
assert!(!crate::notice::NOTICE_PROMPT.contains("[y/N]"));
|
||||
}
|
||||
|
||||
+13
-7
@@ -30,13 +30,15 @@ runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
|
||||
|
||||
### Changed
|
||||
|
||||
- Anonymous usage counting is on by default for fresh installs, clearly
|
||||
disclosed, and immediately opt-out. Prior declines remain off. Codewhale does
|
||||
not collect conversations, code, prompts, files, repo or branch names,
|
||||
credentials, model content, or per-turn activity timelines.
|
||||
- Wide terminals center the header, transcript, work strip, composer, and
|
||||
footer on one 112-column session rail. Compact terminals remain fluid and use
|
||||
their full available width.
|
||||
- Anonymous usage counting is on by default for fresh installs and disclosed in
|
||||
a native first-run Codewhale modal with an immediate opt-out. Prior declines
|
||||
remain off. Codewhale does not collect conversations, code, prompts, files,
|
||||
repo or branch names, credentials, model content, or per-turn activity
|
||||
timelines.
|
||||
- Wide terminals use a responsive, full-screen ocean canvas with modest
|
||||
gutters: prose keeps a readable measure while tools, diffs, work surfaces,
|
||||
the composer, and status chrome can use the available width. Turn and major
|
||||
activity seams breathe without padding every call inside a tool group.
|
||||
- Root CLI help describes product actions directly instead of exposing internal
|
||||
TUI/runtime layers.
|
||||
- `Bash action="wait"` now blocks by default when a wait is requested; callers
|
||||
@@ -58,6 +60,10 @@ runs against Pi 0.8.41 and by dogfooding repeated manual compaction.
|
||||
|
||||
### Fixed
|
||||
|
||||
- First-run usage disclosure now opens as a native Codewhale modal instead of a
|
||||
shell questionnaire before application startup. Telemetry remains unarmed
|
||||
until the native choice is made, and an in-memory Disable choice governs the
|
||||
current session even when its preference cannot be saved.
|
||||
- `/compact` completion, failure, queued, duplicate, and mailbox outcomes are
|
||||
durable transcript receipts instead of short-lived toasts. A stray terminal
|
||||
event can no longer leave every later compaction stuck as already running.
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc restaurar",
|
||||
"HistoryNoMatches": " Cap coincidència",
|
||||
"TranscriptReasoningExpand": "amplia",
|
||||
"TelemetryNoticeHeadline": "Recompte anònim d'ús",
|
||||
"TelemetryNoticeBody": "Per defecte, Codewhale envia recomptes anònims d'ús del producte: la versió\nque executes, el sistema operatiu i la família de CPU, la durada i el resultat\nde la sessió, i recomptes agregats de funcions i errors.\n\nCodewhale no recopila les teves converses, codi, prompts, fitxers, noms de\nfitxers, repositoris o branques, contingut del model ni credencials. Tampoc\nenvia una cronologia de l'activitat de l'agent per torn o eina.\n\nNomés t'identifica un identificador aleatori desat en aquesta màquina. S'elimina\ntan bon punt ho desactives i se substitueix cada 90 dies.\n\nEsquema complet, camp per camp: docs/TELEMETRY.md\nDesactiva-ho en qualsevol moment:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Compta: versió, SO/CPU, temps/resultat, funcions/errors.\nID local rota/90d.\nMai: xats/codi/prompts/fitxers/noms; contingut del model/credencials; torns/eines.\nVegeu: docs/TELEMETRY.md\nDesact.: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Mantenir actiu el recompte anònim",
|
||||
"TelemetryNoticeChoiceDisable": "Desactivar el recompte anònim",
|
||||
"TelemetryNoticeActionChoose": "triar",
|
||||
"TelemetryNoticeActionConfirm": "confirmar",
|
||||
"TelemetryNoticeActionExit": "sortir",
|
||||
"TelemetryNoticeReceiptEnabled": "S'ha desat l'elecció de mantenir-ho activat.",
|
||||
"TelemetryNoticeReceiptDisabled": "El recompte anònim d'ús està desactivat. No t'ho tornarem a preguntar.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "S'ha seleccionat mantenir-ho activat per a aquesta sessió. Codewhale no ha pogut desar l'elecció i tornarà a preguntar al pròxim inici.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "El recompte anònim d'ús està desactivat en aquesta sessió. Codewhale no ha pogut desar l'elecció i tornarà a preguntar al pròxim inici.",
|
||||
"StatusPickerTitle": " Línia d'estat ",
|
||||
"StatusPickerInstruction": "Tria els elements que vols al peu:",
|
||||
"StatusPickerActionToggle": "commutar ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc wiederherstellen",
|
||||
"HistoryNoMatches": " Keine Treffer",
|
||||
"TranscriptReasoningExpand": "erweitern",
|
||||
"TelemetryNoticeHeadline": "Anonyme Nutzungszählung",
|
||||
"TelemetryNoticeBody": "Codewhale sendet standardmäßig anonyme Produktnutzungszahlen: die ausgeführte\nVersion, Betriebssystem und Prozessorfamilie, Sitzungsdauer und -ergebnis sowie\nzusammengefasste Funktions- und Fehlerzähler.\n\nCodewhale erfasst weder Unterhaltungen, Code, Prompts, Dateien, Datei-, Repository-\noder Branch-Namen, Modellinhalte noch Zugangsdaten. Es sendet auch keine Aktivitäts-\nchronik des Agenten pro Runde oder Werkzeug.\n\nZur Identifikation dient nur eine zufällige, auf diesem Gerät gespeicherte Kennung.\nSie wird beim Abschalten sofort gelöscht und alle 90 Tage ersetzt.\n\nVollständiges Schema, Feld für Feld: docs/TELEMETRY.md\nJederzeit abschalten:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Zählt: Version, OS/CPU, Dauer/Ergebnis, Funktionen/Fehler.\nLokale Zufalls-ID wechselt/90T.\nNie: Chat/Code/Prompts/Dateien/Namen; Modellinhalt/Zugangsdaten; Runden/Werkzeuge.\nSchema: docs/TELEMETRY.md\nAus: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Anonyme Zählung eingeschaltet lassen",
|
||||
"TelemetryNoticeChoiceDisable": "Anonyme Zählung abschalten",
|
||||
"TelemetryNoticeActionChoose": "wählen",
|
||||
"TelemetryNoticeActionConfirm": "bestätigen",
|
||||
"TelemetryNoticeActionExit": "beenden",
|
||||
"TelemetryNoticeReceiptEnabled": "Die Auswahl ‚Aktiviert lassen‘ wurde gespeichert.",
|
||||
"TelemetryNoticeReceiptDisabled": "Die anonyme Nutzungszählung ist ausgeschaltet. Sie werden nicht erneut gefragt.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Für diese Sitzung wurde ‚Aktiviert lassen‘ ausgewählt. Codewhale konnte die Auswahl nicht speichern und fragt beim nächsten Start erneut.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Die anonyme Nutzungszählung ist für diese Sitzung ausgeschaltet. Codewhale konnte die Auswahl nicht speichern und fragt beim nächsten Start erneut.",
|
||||
"StatusPickerTitle": " Statuszeile ",
|
||||
"StatusPickerInstruction": "Wähle die Chips für die Fußzeile:",
|
||||
"StatusPickerActionToggle": "umschalten ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc restore",
|
||||
"HistoryNoMatches": " No matches",
|
||||
"TranscriptReasoningExpand": "expand",
|
||||
"TelemetryNoticeHeadline": "Anonymous usage counting",
|
||||
"TelemetryNoticeBody": "Codewhale sends anonymous product usage counts by default: which version\nyou run, OS and CPU family, session duration and outcome, and aggregate\nfeature and error counters.\n\nCodewhale does not collect your conversations, code, prompts, files,\nfile, repo, or branch names, model content, or credentials. It does not\nsend a per-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine. It is\ndeleted the moment you turn this off, and it is replaced every 90 days.\n\nFull schema, field by field: docs/TELEMETRY.md\nDisable it any time:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse",
|
||||
"TelemetryNoticeChoiceKeep": "Keep anonymous usage on",
|
||||
"TelemetryNoticeChoiceDisable": "Disable anonymous usage",
|
||||
"TelemetryNoticeActionChoose": "choose",
|
||||
"TelemetryNoticeActionConfirm": "confirm",
|
||||
"TelemetryNoticeActionExit": "exit",
|
||||
"TelemetryNoticeReceiptEnabled": "Keep-on choice saved.",
|
||||
"TelemetryNoticeReceiptDisabled": "Anonymous usage counting is off. You will not be asked again.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Keep-on was selected for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Anonymous usage counting is off for this session. Codewhale could not save the choice, so it will ask again next launch.",
|
||||
"StatusPickerTitle": " Status line ",
|
||||
"StatusPickerInstruction": "Pick the chips you want in the footer:",
|
||||
"StatusPickerActionToggle": "toggle ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc restaurar",
|
||||
"HistoryNoMatches": " Sin resultados",
|
||||
"TranscriptReasoningExpand": "expandir",
|
||||
"TelemetryNoticeHeadline": "Conteo anónimo de uso",
|
||||
"TelemetryNoticeBody": "De forma predeterminada, Codewhale envía conteos anónimos de uso del producto:\nla versión que ejecutas, el sistema operativo y la familia de CPU, la duración y\nel resultado de la sesión, y conteos agregados de funciones y errores.\n\nCodewhale no recopila tus conversaciones, código, prompts, archivos, nombres de\narchivos, repositorios o ramas, contenido del modelo ni credenciales. Tampoco\nenvía una cronología de la actividad del agente por turno o herramienta.\n\nSolo te identifica un ID aleatorio almacenado en esta máquina. Se elimina en\ncuanto desactivas esta opción y se reemplaza cada 90 días.\n\nEsquema completo, campo por campo: docs/TELEMETRY.md\nDesactívalo en cualquier momento:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Cuenta: versión, SO/CPU, tiempo/resultado, funciones/errores.\nID local rota/90d.\nNunca: chats/código/prompts/archivos/nombres; datos del modelo/credenciales; turnos/herramientas.\nVer: docs/TELEMETRY.md\nApag.: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Mantener activo el conteo anónimo",
|
||||
"TelemetryNoticeChoiceDisable": "Desactivar el conteo anónimo",
|
||||
"TelemetryNoticeActionChoose": "elegir",
|
||||
"TelemetryNoticeActionConfirm": "confirmar",
|
||||
"TelemetryNoticeActionExit": "salir",
|
||||
"TelemetryNoticeReceiptEnabled": "Se guardó la opción de mantenerlo activado.",
|
||||
"TelemetryNoticeReceiptDisabled": "El conteo anónimo de uso está desactivado. No volveremos a preguntarte.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Se eligió mantenerlo activado para esta sesión. Codewhale no pudo guardar la opción y volverá a preguntar en el próximo inicio.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "El conteo anónimo de uso está desactivado en esta sesión. Codewhale no pudo guardar la opción, así que volverá a preguntar en el próximo inicio.",
|
||||
"StatusPickerTitle": " Línea de estado ",
|
||||
"StatusPickerInstruction": "Elige los elementos que quieres en el pie:",
|
||||
"StatusPickerActionToggle": "alternar ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc restaurer",
|
||||
"HistoryNoMatches": " Aucun résultat",
|
||||
"TranscriptReasoningExpand": "développer",
|
||||
"TelemetryNoticeHeadline": "Comptage anonyme de l'utilisation",
|
||||
"TelemetryNoticeBody": "Par défaut, Codewhale envoie des statistiques anonymes d'utilisation du produit :\nla version exécutée, le système d'exploitation et la famille du processeur, la\ndurée et le résultat de la session, ainsi que les totaux de fonctions et d'erreurs.\n\nCodewhale ne collecte ni vos conversations, ni votre code, vos prompts, fichiers,\nnoms de fichiers, de dépôts ou de branches, contenus du modèle ou identifiants.\nIl n'envoie pas non plus de chronologie de l'activité par tour ou par outil.\n\nVous êtes identifié uniquement par un identifiant aléatoire stocké sur cette\nmachine. Il est supprimé dès la désactivation et remplacé tous les 90 jours.\n\nSchéma complet, champ par champ : docs/TELEMETRY.md\nDésactivez à tout moment :\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Compte: version, OS/CPU, durée/résultat, fonctions/erreurs.\nID local aléatoire change/90j.\nJamais: chats/code/prompts/fichiers/noms; contenu modèle/identifiants; tours/outils.\nVoir: docs/TELEMETRY.md\nArrêt: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Garder le comptage anonyme actif",
|
||||
"TelemetryNoticeChoiceDisable": "Désactiver le comptage anonyme",
|
||||
"TelemetryNoticeActionChoose": "choisir",
|
||||
"TelemetryNoticeActionConfirm": "confirmer",
|
||||
"TelemetryNoticeActionExit": "quitter",
|
||||
"TelemetryNoticeReceiptEnabled": "Le choix de conserver l’activation a été enregistré.",
|
||||
"TelemetryNoticeReceiptDisabled": "Le comptage anonyme de l'utilisation est désactivé. La question ne sera plus posée.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Le maintien du comptage a été sélectionné pour cette session. Codewhale n'a pas pu enregistrer ce choix et reposera la question au prochain lancement.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Le comptage anonyme est désactivé pour cette session. Codewhale n'a pas pu enregistrer le choix et reposera la question au prochain lancement.",
|
||||
"StatusPickerTitle": " Barre d'état ",
|
||||
"StatusPickerInstruction": "Choisissez les éléments à afficher dans le pied de page :",
|
||||
"StatusPickerActionToggle": "basculer ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc पुनर्स्थापित",
|
||||
"HistoryNoMatches": " कोई मिलान नहीं",
|
||||
"TranscriptReasoningExpand": "विस्तार",
|
||||
"TelemetryNoticeHeadline": "अनाम उपयोग गणना",
|
||||
"TelemetryNoticeBody": "Codewhale तय रूप से उत्पाद के अनाम उपयोग की गणना भेजता है: चल रहा\nसंस्करण, ऑपरेटिंग सिस्टम व प्रोसेसर परिवार, सत्र की अवधि व परिणाम, और\nसुविधाओं व त्रुटियों की कुल गणना।\n\nCodewhale आपकी बातचीत, कोड, प्रॉम्प्ट, फ़ाइलें, फ़ाइल, भंडार या शाखा के नाम,\nमॉडल की सामग्री या परिचय प्रमाण एकत्र नहीं करता। यह प्रत्येक चरण या औज़ार की\nएजेंट गतिविधि का क्रम भी नहीं भेजता।\n\nपहचान के लिए केवल इस मशीन पर रखी एक आकस्मिक पहचान संख्या उपयोग होती है।\nबंद करते ही यह मिट जाती है और हर 90 दिन में बदल दी जाती है।\n\nहर क्षेत्र सहित पूरा प्रारूप: docs/TELEMETRY.md\nकभी भी बंद करें:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "गणना: संस्करण, सिस्टम/प्रोसेसर, सत्र अवधि/परिणाम और सुविधा/त्रुटि योग।\nआकस्मिक स्थानीय पहचान हर 90 दिन में बदलती है।\nकभी नहीं: बातचीत/कोड/प्रॉम्प्ट/फ़ाइल/नाम, मॉडल सामग्री/परिचय प्रमाण, चरण/औज़ार।\nप्रारूप: docs/TELEMETRY.md\nबंद: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "अनाम गणना चालू रखें",
|
||||
"TelemetryNoticeChoiceDisable": "अनाम गणना बंद करें",
|
||||
"TelemetryNoticeActionChoose": "चुनें",
|
||||
"TelemetryNoticeActionConfirm": "पुष्टि",
|
||||
"TelemetryNoticeActionExit": "बाहर",
|
||||
"TelemetryNoticeReceiptEnabled": "चालू रखने का चयन सहेजा गया।",
|
||||
"TelemetryNoticeReceiptDisabled": "अनाम उपयोग गणना बंद है। दोबारा नहीं पूछा जाएगा।",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "इस सत्र के लिए चालू रखने का विकल्प चुना गया है। Codewhale चयन सहेज नहीं सका, इसलिए अगली बार शुरू होने पर फिर पूछेगा।",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "इस सत्र में अनाम उपयोग गणना बंद है। Codewhale चयन सहेज नहीं सका, इसलिए अगली बार शुरू होने पर फिर पूछेगा।",
|
||||
"StatusPickerTitle": " स्टेटस लाइन ",
|
||||
"StatusPickerInstruction": "फ़ुटर में चाहिए वाले चिप चुनें:",
|
||||
"StatusPickerActionToggle": "टॉगल ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc pulihkan",
|
||||
"HistoryNoMatches": " Tidak ada hasil",
|
||||
"TranscriptReasoningExpand": "perluas",
|
||||
"TelemetryNoticeHeadline": "Penghitungan penggunaan anonim",
|
||||
"TelemetryNoticeBody": "Secara default, Codewhale mengirim hitungan penggunaan produk secara anonim:\nversi yang dijalankan, sistem operasi dan keluarga CPU, durasi dan hasil sesi,\nserta hitungan agregat fitur dan galat.\n\nCodewhale tidak mengumpulkan percakapan, kode, prompt, berkas, nama berkas,\nrepositori atau cabang, konten model, maupun kredensial Anda. Codewhale juga\ntidak mengirim linimasa aktivitas agen per giliran atau per alat.\n\nAnda hanya dikenali melalui ID acak yang disimpan di mesin ini. ID tersebut\ndihapus saat fitur dimatikan dan diganti setiap 90 hari.\n\nSkema lengkap, per bidang: docs/TELEMETRY.md\nNonaktifkan kapan saja:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Hitungan: versi, sistem/CPU, durasi/hasil, serta total fitur/galat.\nID lokal acak diganti setiap 90 hari.\nTidak pernah: percakapan/kode/prompt/berkas/nama, konten model/kredensial, giliran/alat.\nSkema: docs/TELEMETRY.md\nNonaktifkan: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Biarkan penghitungan anonim aktif",
|
||||
"TelemetryNoticeChoiceDisable": "Nonaktifkan penghitungan anonim",
|
||||
"TelemetryNoticeActionChoose": "pilih",
|
||||
"TelemetryNoticeActionConfirm": "konfirmasi",
|
||||
"TelemetryNoticeActionExit": "keluar",
|
||||
"TelemetryNoticeReceiptEnabled": "Pilihan untuk tetap mengaktifkannya telah disimpan.",
|
||||
"TelemetryNoticeReceiptDisabled": "Penghitungan penggunaan anonim dinonaktifkan. Anda tidak akan ditanya lagi.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Tetap aktif dipilih untuk sesi ini. Codewhale tidak dapat menyimpan pilihan, sehingga akan bertanya lagi saat peluncuran berikutnya.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Penghitungan penggunaan anonim dinonaktifkan untuk sesi ini. Codewhale tidak dapat menyimpan pilihan, sehingga akan bertanya lagi saat peluncuran berikutnya.",
|
||||
"StatusPickerTitle": " Baris status ",
|
||||
"StatusPickerInstruction": "Pilih chip yang ingin ditampilkan di bilah bawah:",
|
||||
"StatusPickerActionToggle": "alihkan ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc 復元",
|
||||
"HistoryNoMatches": " 一致なし",
|
||||
"TranscriptReasoningExpand": "展開",
|
||||
"TelemetryNoticeHeadline": "匿名の利用状況集計",
|
||||
"TelemetryNoticeBody": "Codewhale は既定で匿名の製品利用状況を送信します。対象は、使用中の\nバージョン、OS と CPU の種類、セッション時間と結果、機能およびエラーの\n集計カウンターです。\n\nCodewhale は会話、コード、プロンプト、ファイル、ファイル名、リポジトリ名、\nブランチ名、モデルの内容、認証情報を収集しません。また、ターンごと、\nツールごとのエージェント活動の履歴も送信しません。\n\n識別には、このマシンに保存されたランダム ID のみを使用します。この ID は\n無効にすると直ちに削除され、90 日ごとに置き換えられます。\n\n全フィールドのスキーマ: docs/TELEMETRY.md\nいつでも無効にできます:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "集計: 版、OS/CPU、時間/結果、機能/エラー。\nローカルのランダムIDは90日ごとに更新。\n収集なし: 会話/コード/プロンプト/ファイル/名前、モデル内容/認証情報、ターン/ツール。\nスキーマ: docs/TELEMETRY.md\n無効: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "匿名の利用状況集計をオンのままにする",
|
||||
"TelemetryNoticeChoiceDisable": "匿名の利用状況集計を無効にする",
|
||||
"TelemetryNoticeActionChoose": "選択",
|
||||
"TelemetryNoticeActionConfirm": "確定",
|
||||
"TelemetryNoticeActionExit": "終了",
|
||||
"TelemetryNoticeReceiptEnabled": "オンのままにする選択を保存しました。",
|
||||
"TelemetryNoticeReceiptDisabled": "匿名の利用状況集計はオフです。今後は確認しません。",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "このセッションではオンのままにすることが選択されました。Codewhale は選択を保存できなかったため、次回の起動時に再度確認します。",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "このセッションでは匿名の利用状況集計がオフです。Codewhale は選択を保存できなかったため、次回の起動時に再度確認します。",
|
||||
"StatusPickerTitle": " ステータス行 ",
|
||||
"StatusPickerInstruction": "フッターに表示する項目を選択:",
|
||||
"StatusPickerActionToggle": "切替 ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc 복원",
|
||||
"HistoryNoMatches": " 일치 항목 없음",
|
||||
"TranscriptReasoningExpand": "펼치기",
|
||||
"TelemetryNoticeHeadline": "익명 사용량 집계",
|
||||
"TelemetryNoticeBody": "Codewhale은 기본적으로 익명 제품 사용량 집계를 전송합니다. 실행 중인 버전,\n운영 체제와 CPU 계열, 세션 시간과 결과, 기능 및 오류의 집계 수치가\n포함됩니다.\n\nCodewhale은 대화, 코드, 프롬프트, 파일, 파일 이름, 저장소 또는 브랜치 이름,\n모델 내용이나 자격 증명을 수집하지 않습니다. 턴별 또는 도구별 에이전트 활동\n기록도 전송하지 않습니다.\n\n이 컴퓨터에 저장된 임의의 ID만으로 식별됩니다. 이 기능을 끄는 즉시 ID가\n삭제되며 90일마다 새 ID로 교체됩니다.\n\n필드별 전체 스키마: docs/TELEMETRY.md\n언제든지 끄기:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "집계: 버전, 시스템/CPU, 세션 시간/결과, 기능/오류 합계.\n임의의 로컬 식별자는 90일마다 교체.\n수집 안 함: 대화/코드/프롬프트/파일/이름, 모델 내용/자격 증명, 턴/도구 활동.\n스키마: docs/TELEMETRY.md\n끄기: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "익명 사용량 집계 켜기 유지",
|
||||
"TelemetryNoticeChoiceDisable": "익명 사용량 집계 끄기",
|
||||
"TelemetryNoticeActionChoose": "선택",
|
||||
"TelemetryNoticeActionConfirm": "확인",
|
||||
"TelemetryNoticeActionExit": "종료",
|
||||
"TelemetryNoticeReceiptEnabled": "계속 켜기 선택을 저장했습니다.",
|
||||
"TelemetryNoticeReceiptDisabled": "익명 사용량 집계가 꺼져 있습니다. 다시 묻지 않습니다.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "이 세션에서 계속 켜기가 선택되었습니다. Codewhale이 선택을 저장하지 못했으므로 다음 실행 때 다시 묻습니다.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "이 세션에서는 익명 사용량 집계가 꺼져 있습니다. Codewhale이 선택을 저장하지 못했으므로 다음 실행 때 다시 묻습니다.",
|
||||
"StatusPickerTitle": " 상태 표시줄 ",
|
||||
"StatusPickerInstruction": "하단 표시줄에 표시할 항목을 선택하세요:",
|
||||
"StatusPickerActionToggle": "전환 ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc restaura",
|
||||
"HistoryNoMatches": " Sem resultados",
|
||||
"TranscriptReasoningExpand": "expandir",
|
||||
"TelemetryNoticeHeadline": "Contagem anônima de uso",
|
||||
"TelemetryNoticeBody": "Por padrão, o Codewhale envia contagens anônimas de uso do produto: a versão\nem execução, a família do sistema operacional e da CPU, a duração e o resultado\nda sessão e contagens agregadas de recursos e erros.\n\nO Codewhale não coleta suas conversas, código, prompts, arquivos, nomes de\narquivos, repositórios ou branches, conteúdo do modelo nem credenciais. Também\nnão envia uma linha do tempo da atividade do agente por turno ou ferramenta.\n\nVocê é identificado apenas por um ID aleatório armazenado nesta máquina. Ele é\nexcluído assim que você desativa esta opção e substituído a cada 90 dias.\n\nEsquema completo, campo a campo: docs/TELEMETRY.md\nDesative a qualquer momento:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Conta: versão, OS/CPU, tempo/resultado, funções/erros.\nID local: rotação 90d.\nNunca: chat/código/prompts/arquivos/nomes; conteúdo do modelo/credenciais; turnos/ferramentas.\nEsquema: docs/TELEMETRY.md\nDesl.: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Manter contagem anônima ligada",
|
||||
"TelemetryNoticeChoiceDisable": "Desativar contagem anônima",
|
||||
"TelemetryNoticeActionChoose": "escolher",
|
||||
"TelemetryNoticeActionConfirm": "confirmar",
|
||||
"TelemetryNoticeActionExit": "sair",
|
||||
"TelemetryNoticeReceiptEnabled": "A opção de manter ativado foi salva.",
|
||||
"TelemetryNoticeReceiptDisabled": "A contagem anônima de uso está desligada. Não perguntaremos novamente.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Manter ativado foi selecionado para esta sessão. O Codewhale não conseguiu salvar a escolha e perguntará novamente na próxima inicialização.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "A contagem anônima de uso está desligada nesta sessão. O Codewhale não conseguiu salvar a escolha e perguntará novamente na próxima inicialização.",
|
||||
"StatusPickerTitle": " Linha de status ",
|
||||
"StatusPickerInstruction": "Escolha os itens que deseja no rodapé:",
|
||||
"StatusPickerActionToggle": "alternar ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc вернуть",
|
||||
"HistoryNoMatches": " Нет совпадений",
|
||||
"TranscriptReasoningExpand": "развернуть",
|
||||
"TelemetryNoticeHeadline": "Анонимный подсчёт использования",
|
||||
"TelemetryNoticeBody": "По умолчанию Codewhale отправляет анонимные показатели использования продукта:\nверсию, операционную систему и семейство процессора, длительность и результат\nсеанса, а также сводные счётчики функций и ошибок.\n\nCodewhale не собирает ваши разговоры, код, запросы, файлы, имена файлов,\nрепозиториев или веток, содержимое модели и учётные данные. Также не отправляется\nхронология действий агента по отдельным ходам или инструментам.\n\nДля идентификации служит только случайный идентификатор на этом устройстве.\nОн удаляется сразу после отключения и заменяется каждые 90 дней.\n\nПолная схема по полям: docs/TELEMETRY.md\nОтключить можно в любое время:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Счёт: версия, ОС/CPU, время/итог сеанса, функции/ошибки. Локальный случайный ID меняется каждые 90 дней. Никогда: чаты/код/запросы/файлы/имена, данные модели/учётные данные, ходы/инструменты. Схема: docs/TELEMETRY.md Выкл.: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Оставить анонимный подсчёт",
|
||||
"TelemetryNoticeChoiceDisable": "Отключить анонимный подсчёт",
|
||||
"TelemetryNoticeActionChoose": "выбрать",
|
||||
"TelemetryNoticeActionConfirm": "подтвердить",
|
||||
"TelemetryNoticeActionExit": "выйти",
|
||||
"TelemetryNoticeReceiptEnabled": "Выбор «Оставить включённым» сохранён.",
|
||||
"TelemetryNoticeReceiptDisabled": "Анонимный подсчёт использования отключён. Повторного вопроса не будет.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Для этого сеанса выбран вариант «Оставить включённым». Codewhale не удалось сохранить выбор, поэтому при следующем запуске вопрос повторится.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Анонимный подсчёт использования отключён для этого сеанса. Codewhale не удалось сохранить выбор, поэтому при следующем запуске вопрос повторится.",
|
||||
"StatusPickerTitle": " Строка статуса ",
|
||||
"StatusPickerInstruction": "Выберите элементы для нижней панели:",
|
||||
"StatusPickerActionToggle": "перекл. ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc — відновити",
|
||||
"HistoryNoMatches": " Збігів немає",
|
||||
"TranscriptReasoningExpand": "розгорнути",
|
||||
"TelemetryNoticeHeadline": "Анонімний підрахунок використання",
|
||||
"TelemetryNoticeBody": "Типово Codewhale надсилає анонімні показники використання продукту: версію,\nопераційну систему й сімейство процесора, тривалість і результат сеансу, а також\nзведені лічильники функцій і помилок.\n\nCodewhale не збирає ваші розмови, код, запити, файли, назви файлів, сховищ або\nгілок, вміст моделі чи облікові дані. Також не надсилається хронологія дій агента\nза окремими кроками або інструментами.\n\nДля розпізнавання використовується лише випадковий ідентифікатор на цьому пристрої.\nЙого видаляють одразу після вимкнення та замінюють кожні 90 днів.\n\nПовна схема за полями: docs/TELEMETRY.md\nВимкнути можна будь-коли:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Лічба: версія, ОС/CPU, час/результат сеансу, функції/помилки. Випадковий локальний ID змінюється кожні 90 днів. Ніколи: чати/код/запити/файли/назви, дані моделі/облікові дані, кроки/інструменти. Схема: docs/TELEMETRY.md Вимк.: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Лишити анонімний підрахунок",
|
||||
"TelemetryNoticeChoiceDisable": "Вимкнути анонімний підрахунок",
|
||||
"TelemetryNoticeActionChoose": "вибрати",
|
||||
"TelemetryNoticeActionConfirm": "підтвердити",
|
||||
"TelemetryNoticeActionExit": "вийти",
|
||||
"TelemetryNoticeReceiptEnabled": "Вибір «Залишити ввімкненим» збережено.",
|
||||
"TelemetryNoticeReceiptDisabled": "Анонімний підрахунок використання вимкнено. Повторного запитання не буде.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Для цього сеансу вибрано варіант «Залишити ввімкненим». Codewhale не вдалося зберегти вибір, тому під час наступного запуску запитання повториться.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Анонімний підрахунок використання вимкнено для цього сеансу. Codewhale не вдалося зберегти вибір, тому під час наступного запуску запитання повториться.",
|
||||
"StatusPickerTitle": " Рядок стану ",
|
||||
"StatusPickerInstruction": "Виберіть елементи, які показувати в нижньому рядку:",
|
||||
"StatusPickerActionToggle": "перемкнути ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc để khôi phục",
|
||||
"HistoryNoMatches": " Không tìm thấy kết quả",
|
||||
"TranscriptReasoningExpand": "mở rộng",
|
||||
"TelemetryNoticeHeadline": "Đếm lượt sử dụng ẩn danh",
|
||||
"TelemetryNoticeBody": "Theo mặc định, Codewhale gửi số liệu sử dụng sản phẩm ẩn danh: phiên bản\nbạn chạy, hệ điều hành và họ CPU, thời lượng và kết quả phiên, cùng số liệu\ntổng hợp về tính năng và lỗi.\n\nCodewhale không thu thập cuộc trò chuyện, mã nguồn, prompt, tệp, tên tệp,\nkho mã hoặc nhánh, nội dung mô hình hay thông tin đăng nhập của bạn. Hệ thống\ncũng không gửi dòng thời gian hoạt động của tác nhân theo lượt hoặc công cụ.\n\nBạn chỉ được nhận diện bằng một ID ngẫu nhiên lưu trên máy này. ID đó sẽ bị\nxóa ngay khi bạn tắt tính năng này và được thay mới sau mỗi 90 ngày.\n\nLược đồ đầy đủ theo từng trường: docs/TELEMETRY.md\nTắt bất cứ lúc nào:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "Số liệu: phiên bản, hệ thống/CPU, thời lượng/kết quả và tổng tính năng/lỗi.\nID cục bộ ngẫu nhiên đổi mỗi 90 ngày.\nKhông bao giờ: trò chuyện/mã/prompt/tệp/tên, nội dung mô hình/thông tin đăng nhập, lượt/công cụ.\nLược đồ: docs/TELEMETRY.md\nTắt: codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "Tiếp tục bật đếm ẩn danh",
|
||||
"TelemetryNoticeChoiceDisable": "Tắt đếm lượt sử dụng ẩn danh",
|
||||
"TelemetryNoticeActionChoose": "chọn",
|
||||
"TelemetryNoticeActionConfirm": "xác nhận",
|
||||
"TelemetryNoticeActionExit": "thoát",
|
||||
"TelemetryNoticeReceiptEnabled": "Đã lưu lựa chọn tiếp tục bật.",
|
||||
"TelemetryNoticeReceiptDisabled": "Đếm lượt sử dụng ẩn danh đã tắt. Hệ thống sẽ không hỏi lại.",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "Đã chọn tiếp tục bật cho phiên này. Codewhale không thể lưu lựa chọn nên sẽ hỏi lại vào lần khởi động tới.",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "Đếm lượt sử dụng ẩn danh đang tắt cho phiên này. Codewhale không thể lưu lựa chọn nên sẽ hỏi lại vào lần khởi động tới.",
|
||||
"StatusPickerTitle": " Dòng trạng thái ",
|
||||
"StatusPickerInstruction": "Chọn các thành phần bạn muốn hiển thị ở cuối màn hình:",
|
||||
"StatusPickerActionToggle": "bật/tắt ",
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
"HistoryHintRestore": "Esc 还原",
|
||||
"HistoryNoMatches": " 无匹配",
|
||||
"TranscriptReasoningExpand": "展开",
|
||||
"TelemetryNoticeHeadline": "匿名使用统计",
|
||||
"TelemetryNoticeBody": "Codewhale 默认发送匿名产品使用统计:您运行的版本、操作系统和\nCPU 系列、会话时长和结果,以及汇总的功能和错误计数。\n\nCodewhale 不会收集您的对话、代码、提示词、文件、文件名、仓库名或\n分支名、模型内容或凭据,也不会发送按轮次或按工具记录的代理活动。\n\n系统只使用存储在本机上的随机 ID 来识别您。关闭此功能时,该 ID 会\n立即删除,并且每 90 天更换一次。\n\n逐字段完整架构:docs/TELEMETRY.md\n可随时关闭:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "统计:版本、系统/处理器、会话时长/结果、功能/错误总数。\n随机本地标识每 90 天更换。\n绝不收集:对话/代码/提示词/文件/名称、模型内容/凭据、按轮次/工具记录的活动。\n架构:docs/TELEMETRY.md\n关闭:codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "保持匿名使用统计开启",
|
||||
"TelemetryNoticeChoiceDisable": "关闭匿名使用统计",
|
||||
"TelemetryNoticeActionChoose": "选择",
|
||||
"TelemetryNoticeActionConfirm": "确认",
|
||||
"TelemetryNoticeActionExit": "退出",
|
||||
"TelemetryNoticeReceiptEnabled": "已保存“保持开启”的选择。",
|
||||
"TelemetryNoticeReceiptDisabled": "匿名使用统计已关闭。以后不会再次询问。",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "本次会话已选择保持开启。Codewhale 无法保存此选择,因此下次启动时会再次询问。",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "本次会话已关闭匿名使用统计。Codewhale 无法保存此选择,因此下次启动时会再次询问。",
|
||||
"StatusPickerTitle": " 状态行 ",
|
||||
"StatusPickerInstruction": "选择要在底部显示的项目:",
|
||||
"StatusPickerActionToggle": "切换 ",
|
||||
|
||||
@@ -628,6 +628,18 @@
|
||||
"HistoryHintRestore": "Esc 還原",
|
||||
"HistoryNoMatches": " 無相符",
|
||||
"TranscriptReasoningExpand": "展開",
|
||||
"TelemetryNoticeHeadline": "匿名使用統計",
|
||||
"TelemetryNoticeBody": "Codewhale 預設傳送匿名產品使用統計:您執行的版本、作業系統和\nCPU 系列、工作階段時間和結果,以及彙總的功能和錯誤計數。\n\nCodewhale 不會收集您的對話、程式碼、提示詞、檔案、檔名、儲存庫名稱或\n分支名稱、模型內容或憑證,也不會傳送逐回合或逐工具記錄的代理活動。\n\n系統只使用儲存在此機器上的隨機 ID 來識別您。關閉此功能時,該 ID 會\n立即刪除,並且每 90 天更換一次。\n\n逐欄位完整結構:docs/TELEMETRY.md\n可隨時關閉:\n codewhale config set telemetry false",
|
||||
"TelemetryNoticeCompactBody": "統計:版本、系統/處理器、工作階段時間/結果、功能/錯誤總數。\n隨機本機識別碼每 90 天更換。\n絕不收集:對話/程式碼/提示詞/檔案/名稱、模型內容/憑證、逐回合/工具記錄的活動。\n結構:docs/TELEMETRY.md\n關閉:codewhale config set telemetry false",
|
||||
"TelemetryNoticeChoiceKeep": "保持匿名使用統計開啟",
|
||||
"TelemetryNoticeChoiceDisable": "關閉匿名使用統計",
|
||||
"TelemetryNoticeActionChoose": "選擇",
|
||||
"TelemetryNoticeActionConfirm": "確認",
|
||||
"TelemetryNoticeActionExit": "離開",
|
||||
"TelemetryNoticeReceiptEnabled": "已儲存「保持開啟」的選擇。",
|
||||
"TelemetryNoticeReceiptDisabled": "匿名使用統計已關閉。之後不會再次詢問。",
|
||||
"TelemetryNoticeReceiptEnabledUnsaved": "此工作階段已選擇保持開啟。Codewhale 無法儲存此選擇,因此下次啟動時會再次詢問。",
|
||||
"TelemetryNoticeReceiptDisabledUnsaved": "此工作階段已關閉匿名使用統計。Codewhale 無法儲存此選擇,因此下次啟動時會再次詢問。",
|
||||
"HistorySearchPlaceholder": "搜尋提示歷史...",
|
||||
"HistorySearchTitle": "歷史搜尋",
|
||||
"HomeAgentModeReviewTip": " 用 /mode plan 來先調查搜尋與生成計畫",
|
||||
|
||||
+93
-30
@@ -1631,7 +1631,9 @@ pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
|
||||
fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
|
||||
use codewhale_telemetry::Surface;
|
||||
match command {
|
||||
None | Some(Commands::Resume { .. } | Commands::Fork { .. }) => Surface::Tui,
|
||||
None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
|
||||
Surface::Tui
|
||||
}
|
||||
Some(Commands::Exec(_)) => Surface::Exec,
|
||||
Some(Commands::Serve(args)) => {
|
||||
if args.mcp {
|
||||
@@ -1648,7 +1650,7 @@ fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface
|
||||
fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
|
||||
use codewhale_telemetry::SessionSource;
|
||||
match command {
|
||||
None => SessionSource::Interactive,
|
||||
None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
|
||||
Some(Commands::Resume { .. }) => SessionSource::Resume,
|
||||
Some(Commands::Fork { .. }) => SessionSource::Fork,
|
||||
Some(Commands::Serve(_)) => SessionSource::Api,
|
||||
@@ -1656,11 +1658,11 @@ fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only diagnostics must not create telemetry state as a side effect.
|
||||
/// Read-only commands must not create telemetry state as a side effect.
|
||||
fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_))
|
||||
Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
|
||||
) || matches!(command, Some(Commands::Setup(args)) if args.status)
|
||||
}
|
||||
|
||||
@@ -1677,21 +1679,28 @@ fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
|
||||
/// re-reading `CODEWHALE_TELEMETRY` inside the telemetry crate would fork
|
||||
/// `parse_bool`, the `DEEPSEEK_TELEMETRY` alias, and the floor into a second
|
||||
/// source of truth.
|
||||
fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
|
||||
if telemetry_command_is_read_only(command) {
|
||||
return;
|
||||
}
|
||||
let surface = telemetry_surface(command);
|
||||
let Ok(store) = codewhale_config::ConfigStore::load(cli.config.clone()) else {
|
||||
fn arm_telemetry_with_setup(
|
||||
config_path: Option<PathBuf>,
|
||||
surface: codewhale_telemetry::Surface,
|
||||
source: codewhale_telemetry::SessionSource,
|
||||
setup_override: Option<&codewhale_config::SetupState>,
|
||||
) {
|
||||
let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
|
||||
return;
|
||||
};
|
||||
let resolved = store
|
||||
.config
|
||||
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
|
||||
let setup = codewhale_config::SetupState::load()
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let setup = if let Some(setup) = setup_override {
|
||||
setup.clone()
|
||||
} else {
|
||||
let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
|
||||
// An existing unreadable privacy record may contain a decline.
|
||||
// Failing closed is safer than replacing it with default-on.
|
||||
return;
|
||||
};
|
||||
setup
|
||||
};
|
||||
let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
|
||||
codewhale_telemetry::decide(&resolved, &setup, surface)
|
||||
else {
|
||||
@@ -1699,9 +1708,37 @@ fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
|
||||
};
|
||||
codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
|
||||
let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart {
|
||||
source: telemetry_session_source(command),
|
||||
});
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
|
||||
}
|
||||
|
||||
fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
|
||||
if telemetry_command_is_read_only(command) {
|
||||
return;
|
||||
}
|
||||
arm_telemetry_with_setup(
|
||||
cli.config.clone(),
|
||||
telemetry_surface(command),
|
||||
telemetry_session_source(command),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Apply the choice made in the native TUI disclosure.
|
||||
///
|
||||
/// The in-memory setup state is authoritative for this process. In particular,
|
||||
/// a Disable choice reaches `decide` as an opt-out even when neither durable
|
||||
/// write landed, so the current launch cannot arm and any existing buffer is
|
||||
/// wiped whenever the telemetry home remains reachable.
|
||||
pub(crate) fn apply_tui_telemetry_decision(
|
||||
pending: &crate::telemetry_notice::PendingTelemetryNotice,
|
||||
setup: &codewhale_config::SetupState,
|
||||
) {
|
||||
arm_telemetry_with_setup(
|
||||
pending.config_path.clone(),
|
||||
codewhale_telemetry::Surface::Tui,
|
||||
pending.session_source,
|
||||
Some(setup),
|
||||
);
|
||||
}
|
||||
|
||||
/// Close the armed session and flush, bounded.
|
||||
@@ -1758,20 +1795,34 @@ async fn run_async_main_inner(
|
||||
// ahead of it collects nothing.
|
||||
spawn_signal_cleanup_task();
|
||||
|
||||
// Arming is what makes the panic hook installed back in `main` — and every
|
||||
// other write path — stop being a no-op. Nothing before this line can
|
||||
// record anything, which is precisely how a disabled user's panic writes
|
||||
// nothing and creates no directory.
|
||||
// The notice runs before arming, and only on the interactive surface: it
|
||||
// is the one surface that owns a terminal it can ask on, and asking before
|
||||
// `arm_telemetry` is what lets an immediate Disable choice stop this very
|
||||
// session. Non-TTY surfaces follow the disclosed default without writing a
|
||||
// fictional notice decision.
|
||||
if telemetry_surface(command.as_ref()) == codewhale_telemetry::Surface::Tui {
|
||||
crate::telemetry_notice::prompt_if_due(cli.skip_onboarding, cli.config.clone());
|
||||
// A due interactive disclosure belongs to the first native TUI frame. In
|
||||
// that one case arming is deferred until its decision event; every other
|
||||
// surface keeps the ordinary pre-dispatch predicate. This is what lets an
|
||||
// immediate Disable choice stop this very session without printing or
|
||||
// blocking on a shell questionnaire first.
|
||||
let surface = telemetry_surface(command.as_ref());
|
||||
let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
|
||||
crate::telemetry_notice::plan_if_due(
|
||||
cli.config.clone(),
|
||||
telemetry_session_source(command.as_ref()),
|
||||
)
|
||||
} else {
|
||||
crate::telemetry_notice::TelemetryNoticePlan::NotDue
|
||||
};
|
||||
let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
|
||||
|| telemetry_notice_plan.should_arm_before_tui();
|
||||
let pending_telemetry_notice = telemetry_notice_plan.into_pending();
|
||||
if should_arm_before_dispatch {
|
||||
arm_telemetry(&cli, command.as_ref());
|
||||
}
|
||||
arm_telemetry(&cli, command.as_ref());
|
||||
let outcome = run_async_main_dispatch(cli, command, plugin_discovery, plugin_registry).await;
|
||||
let outcome = run_async_main_dispatch(
|
||||
cli,
|
||||
command,
|
||||
plugin_discovery,
|
||||
plugin_registry,
|
||||
pending_telemetry_notice,
|
||||
)
|
||||
.await;
|
||||
finish_telemetry(&outcome).await;
|
||||
outcome
|
||||
}
|
||||
@@ -1781,6 +1832,7 @@ async fn run_async_main_dispatch(
|
||||
command: Option<Commands>,
|
||||
plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
|
||||
plugin_registry: Arc<crate::plugins::PluginRegistry>,
|
||||
mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
|
||||
) -> Result<()> {
|
||||
logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
|
||||
|
||||
@@ -2029,6 +2081,7 @@ async fn run_async_main_dispatch(
|
||||
number,
|
||||
repo.as_deref(),
|
||||
checkout,
|
||||
pending_telemetry_notice.take(),
|
||||
Arc::clone(&plugin_registry),
|
||||
)
|
||||
.await
|
||||
@@ -2108,6 +2161,7 @@ async fn run_async_main_dispatch(
|
||||
&config,
|
||||
Some(resume_id),
|
||||
None,
|
||||
pending_telemetry_notice.take(),
|
||||
std::sync::Arc::clone(&plugin_registry),
|
||||
)
|
||||
.await
|
||||
@@ -2121,6 +2175,7 @@ async fn run_async_main_dispatch(
|
||||
&config,
|
||||
Some(new_session_id),
|
||||
None,
|
||||
pending_telemetry_notice.take(),
|
||||
std::sync::Arc::clone(&plugin_registry),
|
||||
)
|
||||
.await
|
||||
@@ -2138,6 +2193,7 @@ async fn run_async_main_dispatch(
|
||||
&config,
|
||||
None,
|
||||
Some(initial_input),
|
||||
pending_telemetry_notice.take(),
|
||||
std::sync::Arc::clone(&plugin_registry),
|
||||
)
|
||||
.await;
|
||||
@@ -2175,6 +2231,7 @@ async fn run_async_main_dispatch(
|
||||
resume_session_id,
|
||||
None,
|
||||
startup_notice,
|
||||
pending_telemetry_notice.take(),
|
||||
plugin_registry,
|
||||
)
|
||||
.await
|
||||
@@ -7799,6 +7856,7 @@ async fn run_pr(
|
||||
number: u32,
|
||||
repo: Option<&str>,
|
||||
checkout: bool,
|
||||
pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
|
||||
plugin_registry: Arc<crate::plugins::PluginRegistry>,
|
||||
) -> Result<()> {
|
||||
if !is_command_available("gh") {
|
||||
@@ -7833,6 +7891,7 @@ async fn run_pr(
|
||||
config,
|
||||
resume_session_id,
|
||||
Some(tui::InitialInput::Prefill(prompt)),
|
||||
pending_telemetry_notice,
|
||||
plugin_registry,
|
||||
)
|
||||
.await
|
||||
@@ -9360,6 +9419,7 @@ async fn run_interactive(
|
||||
config: &Config,
|
||||
resume_session_id: Option<String>,
|
||||
initial_input: Option<tui::InitialInput>,
|
||||
pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
|
||||
plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
|
||||
) -> Result<()> {
|
||||
run_interactive_with_notice(
|
||||
@@ -9368,6 +9428,7 @@ async fn run_interactive(
|
||||
resume_session_id,
|
||||
initial_input,
|
||||
None,
|
||||
pending_telemetry_notice,
|
||||
plugin_registry,
|
||||
)
|
||||
.await
|
||||
@@ -9382,6 +9443,7 @@ async fn run_interactive_with_notice(
|
||||
resume_session_id: Option<String>,
|
||||
initial_input: Option<tui::InitialInput>,
|
||||
startup_notice: Option<String>,
|
||||
pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
|
||||
plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
|
||||
) -> Result<()> {
|
||||
let initial_input = if cli.remote_control {
|
||||
@@ -9534,6 +9596,7 @@ async fn run_interactive_with_notice(
|
||||
max_subagents,
|
||||
},
|
||||
plugin_registry,
|
||||
pending_telemetry_notice,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -127,6 +127,19 @@ pub enum MessageId {
|
||||
HistoryHintRestore,
|
||||
HistoryNoMatches,
|
||||
TranscriptReasoningExpand,
|
||||
// First-run anonymous usage disclosure.
|
||||
TelemetryNoticeHeadline,
|
||||
TelemetryNoticeBody,
|
||||
TelemetryNoticeCompactBody,
|
||||
TelemetryNoticeChoiceKeep,
|
||||
TelemetryNoticeChoiceDisable,
|
||||
TelemetryNoticeActionChoose,
|
||||
TelemetryNoticeActionConfirm,
|
||||
TelemetryNoticeActionExit,
|
||||
TelemetryNoticeReceiptEnabled,
|
||||
TelemetryNoticeReceiptDisabled,
|
||||
TelemetryNoticeReceiptEnabledUnsaved,
|
||||
TelemetryNoticeReceiptDisabledUnsaved,
|
||||
// StatusPicker — `/statusline` multi-select footer-item picker.
|
||||
StatusPickerTitle,
|
||||
StatusPickerInstruction,
|
||||
@@ -1512,6 +1525,18 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::HistoryHintRestore,
|
||||
MessageId::HistoryNoMatches,
|
||||
MessageId::TranscriptReasoningExpand,
|
||||
MessageId::TelemetryNoticeHeadline,
|
||||
MessageId::TelemetryNoticeBody,
|
||||
MessageId::TelemetryNoticeCompactBody,
|
||||
MessageId::TelemetryNoticeChoiceKeep,
|
||||
MessageId::TelemetryNoticeChoiceDisable,
|
||||
MessageId::TelemetryNoticeActionChoose,
|
||||
MessageId::TelemetryNoticeActionConfirm,
|
||||
MessageId::TelemetryNoticeActionExit,
|
||||
MessageId::TelemetryNoticeReceiptEnabled,
|
||||
MessageId::TelemetryNoticeReceiptDisabled,
|
||||
MessageId::TelemetryNoticeReceiptEnabledUnsaved,
|
||||
MessageId::TelemetryNoticeReceiptDisabledUnsaved,
|
||||
MessageId::StatusPickerTitle,
|
||||
MessageId::StatusPickerInstruction,
|
||||
MessageId::StatusPickerActionToggle,
|
||||
|
||||
+345
-199
@@ -1,61 +1,109 @@
|
||||
//! The first-run telemetry notice, on the interactive startup path.
|
||||
//! Eligibility and persistence for the interactive telemetry disclosure.
|
||||
//!
|
||||
//! Shown once, before the terminal enters raw mode, on the same TTY the user
|
||||
//! launched on. It is deliberately *not* hung off the setup wizard's deferral
|
||||
//! machinery: `defer_update_checkpoint_for_app` persists a completed
|
||||
//! constitution checkpoint without ever showing the user anything, and a
|
||||
//! telemetry decision recorded that way would be a decision nobody made.
|
||||
//!
|
||||
//! Anonymous usage counting is default-on. This notice explains that default
|
||||
//! before the terminal enters raw mode and provides an immediate durable
|
||||
//! opt-out:
|
||||
//!
|
||||
//! - `--skip-onboarding` does not suppress this privacy disclosure on a TTY.
|
||||
//! - non-TTY surfaces use the documented default and the same config/env kill
|
||||
//! switches without attempting an interactive prompt.
|
||||
//! - answered "no": off, and not asked again.
|
||||
//! Rendering belongs to the native TUI in [`crate::tui::telemetry_notice`].
|
||||
//! This module owns only the privacy-sensitive state transitions: deciding
|
||||
//! whether a disclosure is owed, recording the user's choice, and preserving
|
||||
//! an in-memory decision even when persistence fails.
|
||||
|
||||
use std::io::{BufRead, IsTerminal, Write};
|
||||
use std::io::IsTerminal;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION};
|
||||
use codewhale_telemetry::notice;
|
||||
use codewhale_telemetry::SessionSource;
|
||||
|
||||
/// Show the notice and record the answer, if and only if one is owed and this
|
||||
/// process is on a terminal that can ask.
|
||||
///
|
||||
/// Returns whether the disclosed default remains enabled. Never returns an
|
||||
/// error; all persistence failures are reported and leave the durable config
|
||||
/// opt-out authoritative when it was successfully written.
|
||||
pub fn prompt_if_due(_skip_onboarding: bool, config_path: Option<std::path::PathBuf>) -> bool {
|
||||
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
|
||||
return false;
|
||||
use crate::localization::MessageId;
|
||||
|
||||
/// Everything the native notice needs to commit the choice against the same
|
||||
/// files and session source that were resolved before the first TUI frame.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingTelemetryNotice {
|
||||
pub(crate) config_path: Option<PathBuf>,
|
||||
pub(crate) setup_state_path: PathBuf,
|
||||
pub(crate) session_source: SessionSource,
|
||||
}
|
||||
|
||||
/// Whether an interactive launch owes the native notice, may arm immediately,
|
||||
/// or must stay unarmed because the durable privacy state could not be read.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum TelemetryNoticePlan {
|
||||
Due(PendingTelemetryNotice),
|
||||
NotDue,
|
||||
SuppressArming,
|
||||
}
|
||||
|
||||
impl TelemetryNoticePlan {
|
||||
pub(crate) fn should_arm_before_tui(&self) -> bool {
|
||||
matches!(self, Self::NotDue)
|
||||
}
|
||||
// Read what the config file and the environment already say *before*
|
||||
// asking. The notice used to consult neither, so it ran on a machine whose
|
||||
// operator had declared `CODEWHALE_TELEMETRY=0` and whose config file
|
||||
// already said `telemetry = false`, and a `y` rewrote that `false` to
|
||||
// `true`.
|
||||
let store = match codewhale_config::ConfigStore::load(config_path.clone()) {
|
||||
|
||||
pub(crate) fn into_pending(self) -> Option<PendingTelemetryNotice> {
|
||||
match self {
|
||||
Self::Due(pending) => Some(pending),
|
||||
Self::NotDue | Self::SuppressArming => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The choice after applying it to an in-memory setup state.
|
||||
///
|
||||
/// `setup_state` is deliberately returned even if both writes failed. The
|
||||
/// telemetry predicate consumes this value immediately, so selecting Disable
|
||||
/// can never arm the current process merely because the filesystem was
|
||||
/// unwritable.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AppliedTelemetryDecision {
|
||||
pub(crate) setup_state: SetupState,
|
||||
pub(crate) status_message_id: MessageId,
|
||||
}
|
||||
|
||||
/// Return a native-notice plan when this interactive launch owes disclosure.
|
||||
///
|
||||
/// This is read-only. It never prints, blocks on a line read, creates telemetry
|
||||
/// state, or records a fictional answer. `--skip-onboarding` intentionally has
|
||||
/// no bearing on a privacy disclosure.
|
||||
pub(crate) fn plan_if_due(
|
||||
config_path: Option<PathBuf>,
|
||||
session_source: SessionSource,
|
||||
) -> TelemetryNoticePlan {
|
||||
if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
|
||||
return TelemetryNoticePlan::NotDue;
|
||||
}
|
||||
|
||||
let store = match codewhale_config::ConfigStore::load(config_path) {
|
||||
Ok(store) => store,
|
||||
Err(error) => {
|
||||
// A config we cannot read is a config we must not write.
|
||||
tracing::debug!("telemetry notice skipped; config unreadable: {error}");
|
||||
return false;
|
||||
tracing::warn!("telemetry stays unarmed; config unreadable: {error}");
|
||||
return TelemetryNoticePlan::SuppressArming;
|
||||
}
|
||||
};
|
||||
let setup_state_path = match SetupState::path() {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
tracing::warn!("telemetry stays unarmed; setup-state path unavailable: {error}");
|
||||
return TelemetryNoticePlan::SuppressArming;
|
||||
}
|
||||
};
|
||||
plan_for_store_and_state(store, setup_state_path, session_source)
|
||||
}
|
||||
|
||||
fn plan_for_store_and_state(
|
||||
store: codewhale_config::ConfigStore,
|
||||
setup_state_path: PathBuf,
|
||||
session_source: SessionSource,
|
||||
) -> TelemetryNoticePlan {
|
||||
let resolved = store
|
||||
.config
|
||||
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
|
||||
|
||||
let mut state = match SetupState::load() {
|
||||
Ok(Some(state)) => state,
|
||||
// A missing record is a first run, which is exactly when the notice is
|
||||
// owed. An *unreadable* record is not: overwriting it would be the one
|
||||
// failure mode that costs a user their constitution checkpoint.
|
||||
Ok(None) => SetupState::default(),
|
||||
let state = match load_notice_state_at(&setup_state_path) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
tracing::debug!("telemetry notice skipped; setup state unreadable: {error}");
|
||||
return false;
|
||||
// Never replace a corrupt constitution/setup sidecar with a fresh
|
||||
// telemetry-only record. The next successful setup repair can
|
||||
// make this notice eligible again.
|
||||
tracing::warn!("telemetry stays unarmed; setup state unreadable: {error}");
|
||||
return TelemetryNoticePlan::SuppressArming;
|
||||
}
|
||||
};
|
||||
let gate = NoticeGate {
|
||||
@@ -64,55 +112,108 @@ pub fn prompt_if_due(_skip_onboarding: bool, config_path: Option<std::path::Path
|
||||
recorded_opt_out: state.telemetry_opted_out(),
|
||||
floor_in_force: codewhale_config::telemetry_floor_in_force(),
|
||||
};
|
||||
if !gate.may_ask() {
|
||||
return false;
|
||||
if gate.may_ask() {
|
||||
TelemetryNoticePlan::Due(PendingTelemetryNotice {
|
||||
config_path: Some(store.path().to_path_buf()),
|
||||
setup_state_path,
|
||||
session_source,
|
||||
})
|
||||
} else {
|
||||
TelemetryNoticePlan::NotDue
|
||||
}
|
||||
}
|
||||
|
||||
let enabled = ask(&mut std::io::stderr(), &mut std::io::stdin().lock());
|
||||
/// Apply the native choice without ever making telemetry a launch blocker.
|
||||
///
|
||||
/// Disable is durable when either the root config or setup-state write lands.
|
||||
/// Keep-on is durable when the notice-version record lands. When no write can
|
||||
/// land, the choice still governs this process and the notice is shown again
|
||||
/// next launch.
|
||||
pub(crate) fn apply_decision(
|
||||
pending: &PendingTelemetryNotice,
|
||||
enabled: bool,
|
||||
) -> AppliedTelemetryDecision {
|
||||
let (mut state, state_may_be_saved) = match load_notice_state_at(&pending.setup_state_path) {
|
||||
Ok(state) => (state, true),
|
||||
Err(error) => {
|
||||
tracing::warn!("telemetry decision could not reload setup state: {error}");
|
||||
(SetupState::default(), false)
|
||||
}
|
||||
};
|
||||
|
||||
// Continuing with the disclosed default does not rewrite config. An
|
||||
// opt-out does: the same `telemetry = false` register applies to every
|
||||
// surface and every future run.
|
||||
if !enabled && let Err(error) = write_config_opt_out(config_path) {
|
||||
tracing::warn!("telemetry opt-out was not saved to config: {error}");
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
" Could not save the config setting; the setup-state opt-out will still be used.\n"
|
||||
);
|
||||
// The modal can remain open while another Codewhale process records an
|
||||
// opt-out. A stale Keep-on click must not overwrite that newer privacy
|
||||
// decision after we reload the shared setup state.
|
||||
if enabled && state.telemetry_opted_out() {
|
||||
return AppliedTelemetryDecision {
|
||||
setup_state: state,
|
||||
status_message_id: MessageId::TelemetryNoticeReceiptDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, enabled);
|
||||
if let Err(error) = state.save() {
|
||||
// Nothing was recorded, so the notice remains owed and will be shown
|
||||
// again. A successfully persisted config opt-out is still decisive.
|
||||
tracing::warn!("telemetry decision was not saved: {error}");
|
||||
|
||||
let config_saved = if enabled {
|
||||
false
|
||||
} else {
|
||||
match write_config_opt_out(pending.config_path.clone()) {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
tracing::warn!("telemetry opt-out was not saved to config: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
let state_saved = state_may_be_saved
|
||||
&& match state.save_to(&pending.setup_state_path) {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
tracing::warn!("telemetry decision was not saved: {error}");
|
||||
false
|
||||
}
|
||||
};
|
||||
let durable = if enabled {
|
||||
state_saved
|
||||
} else {
|
||||
config_saved || state_saved
|
||||
};
|
||||
|
||||
let status_message_id = if durable && enabled {
|
||||
MessageId::TelemetryNoticeReceiptEnabled
|
||||
} else if durable {
|
||||
MessageId::TelemetryNoticeReceiptDisabled
|
||||
} else if enabled {
|
||||
MessageId::TelemetryNoticeReceiptEnabledUnsaved
|
||||
} else {
|
||||
MessageId::TelemetryNoticeReceiptDisabledUnsaved
|
||||
};
|
||||
|
||||
AppliedTelemetryDecision {
|
||||
setup_state: state,
|
||||
status_message_id,
|
||||
}
|
||||
let _ = writeln!(std::io::stderr(), "{}\n", notice::decision_receipt(enabled));
|
||||
enabled
|
||||
}
|
||||
|
||||
/// Everything that decides whether the question may be *put*, as opposed to how
|
||||
/// Load a missing sidecar as a fresh state, but distinguish it from an
|
||||
/// existing unreadable/corrupt sidecar so the notice can never overwrite the
|
||||
/// latter with defaults.
|
||||
fn load_notice_state_at(path: &Path) -> Result<SetupState> {
|
||||
if !path
|
||||
.try_exists()
|
||||
.map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?
|
||||
{
|
||||
return Ok(SetupState::default());
|
||||
}
|
||||
SetupState::load_from(path)
|
||||
.ok_or_else(|| anyhow!("{} could not be read as setup state", path.display()))
|
||||
}
|
||||
|
||||
/// Everything that decides whether the question may be put, as opposed to how
|
||||
/// it is answered.
|
||||
///
|
||||
/// Being asked is not collection, but it is not free either: the answer is
|
||||
/// written to two durable registers, one of which may already hold the
|
||||
/// opposite. A question whose "yes" would reverse a decision somebody already
|
||||
/// made, or would be overridden by this environment anyway, is a question with
|
||||
/// no honest answer — so it is not asked.
|
||||
struct NoticeGate {
|
||||
/// No decision recorded for the current notice version.
|
||||
needs_notice: bool,
|
||||
/// `telemetry = false` is in the config file. This is the persistent
|
||||
/// opt-out the notice itself advertises; asking again and writing `true`
|
||||
/// over it is exactly the reversal the notice promises not to perform.
|
||||
persisted_off: bool,
|
||||
/// A previous notice recorded a decline. The migration from opt-in to
|
||||
/// opt-out must preserve that decision across notice-version bumps.
|
||||
recorded_opt_out: bool,
|
||||
/// An environment-level kill switch is in force. The operator has already
|
||||
/// answered for this machine, and a `y` here could not take effect on this
|
||||
/// run — but it would take effect on every later run that does not inherit
|
||||
/// the variable.
|
||||
floor_in_force: bool,
|
||||
}
|
||||
|
||||
@@ -122,99 +223,17 @@ impl NoticeGate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the immediate opt-out in the same config this process loaded.
|
||||
fn write_config_opt_out(config_path: Option<std::path::PathBuf>) -> anyhow::Result<()> {
|
||||
/// Persist the immediate opt-out in the exact config this process loaded.
|
||||
fn write_config_opt_out(config_path: Option<PathBuf>) -> Result<()> {
|
||||
let mut store = codewhale_config::ConfigStore::load(config_path)?;
|
||||
store.config.set_value("telemetry", "false")?;
|
||||
store.save()
|
||||
}
|
||||
|
||||
/// Render the notice to `out` and read one answer from `input`.
|
||||
///
|
||||
/// Split out so the wording, the default, and the parsing are testable without
|
||||
/// a terminal. Enter keeps the disclosed default; explicit negative answers
|
||||
/// disable it.
|
||||
fn ask(out: &mut impl Write, input: &mut impl BufRead) -> bool {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n {}\n\n{}\n\n [ Keep on ] [ Disable ]\n\n Selected: Keep on — press Enter to continue.\n",
|
||||
notice::NOTICE_HEADLINE,
|
||||
indent(notice::NOTICE_BODY),
|
||||
);
|
||||
let _ = write!(out, " {} ", notice::NOTICE_PROMPT);
|
||||
let _ = out.flush();
|
||||
|
||||
let mut answer = String::new();
|
||||
if input.read_line(&mut answer).is_err() {
|
||||
return true;
|
||||
}
|
||||
notice::answer_keeps_enabled(&answer)
|
||||
}
|
||||
|
||||
fn indent(body: &str) -> String {
|
||||
body.lines()
|
||||
.map(|line| {
|
||||
if line.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {line}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ask_with(answer: &str) -> (bool, String) {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut input = answer.as_bytes();
|
||||
let decision = ask(&mut out, &mut input);
|
||||
(decision, String::from_utf8(out).expect("utf8"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_keeps_the_disclosed_default_enabled() {
|
||||
assert!(ask_with("\n").0);
|
||||
assert!(ask_with("").0);
|
||||
assert!(ask_with(" \n").0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_negative_answers_disable() {
|
||||
assert!(ask_with("y\n").0);
|
||||
assert!(ask_with("Y\n").0);
|
||||
assert!(ask_with("yes\n").0);
|
||||
assert!(!ask_with("n\n").0);
|
||||
assert!(!ask_with("no\n").0);
|
||||
assert!(!ask_with("off\n").0);
|
||||
assert!(!ask_with("disable\n").0);
|
||||
assert!(ask_with("sure\n").0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_states_the_red_lines_and_the_way_out() {
|
||||
let (_, rendered) = ask_with("\n");
|
||||
for claim in [
|
||||
"does not collect your conversations, code, prompts, files",
|
||||
"per-turn or per-tool timeline",
|
||||
"random ID stored on this machine",
|
||||
"every 90 days",
|
||||
"docs/TELEMETRY.md",
|
||||
"codewhale config set telemetry false",
|
||||
"CODEWHALE_TELEMETRY=0",
|
||||
"press Enter to continue",
|
||||
] {
|
||||
assert!(rendered.contains(claim), "notice is missing: {claim}");
|
||||
}
|
||||
assert!(
|
||||
!rendered.contains("anonymized"),
|
||||
"the notice must not imply anonymization it does not perform"
|
||||
);
|
||||
}
|
||||
|
||||
fn gate(
|
||||
needs_notice: bool,
|
||||
persisted_off: bool,
|
||||
@@ -229,53 +248,180 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_is_not_put_to_someone_who_has_already_answered_it_durably() {
|
||||
// Regression: the gate consulted only the setup-state record, so on a
|
||||
// machine with `CODEWHALE_TELEMETRY=0` exported and `telemetry = false`
|
||||
// in the config file the notice rendered anyway — and `y` rewrote that
|
||||
// `false` to `true`, reversing a persistent opt-out with no warning.
|
||||
assert!(
|
||||
gate(true, false, false, false).may_ask(),
|
||||
"an ordinary first run"
|
||||
);
|
||||
assert!(
|
||||
!gate(true, true, false, false).may_ask(),
|
||||
"a persisted `telemetry = false` is an answer; do not ask again"
|
||||
);
|
||||
assert!(
|
||||
!gate(true, false, true, false).may_ask(),
|
||||
"a historical recorded decline is still an opt-out"
|
||||
);
|
||||
assert!(
|
||||
!gate(true, false, false, true).may_ask(),
|
||||
"an environment kill switch is an answer for this machine"
|
||||
);
|
||||
assert!(!gate(true, true, true, true).may_ask());
|
||||
// And the original condition still governs: an answered notice is not
|
||||
// re-asked for any reason.
|
||||
for persisted_off in [false, true] {
|
||||
for floor_in_force in [false, true] {
|
||||
assert!(!gate(false, persisted_off, false, floor_in_force).may_ask());
|
||||
}
|
||||
fn pending_at(config_path: PathBuf, setup_state_path: PathBuf) -> PendingTelemetryNotice {
|
||||
PendingTelemetryNotice {
|
||||
config_path: Some(config_path),
|
||||
setup_state_path,
|
||||
session_source: SessionSource::Interactive,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opting_out_writes_the_durable_config_floor() {
|
||||
fn the_notice_is_not_put_to_someone_who_already_answered_durably() {
|
||||
assert!(gate(true, false, false, false).may_ask());
|
||||
assert!(!gate(true, true, false, false).may_ask());
|
||||
assert!(!gate(true, false, true, false).may_ask());
|
||||
assert!(!gate(true, false, false, true).may_ask());
|
||||
assert!(!gate(false, false, false, false).may_ask());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opting_out_updates_both_durable_registers() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").expect("seed config");
|
||||
write_config_opt_out(Some(path.clone())).expect("save opt-out");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
let state_path = dir.path().join("setup_state.json");
|
||||
std::fs::write(&config_path, "").expect("seed config");
|
||||
let applied = apply_decision(&pending_at(config_path.clone(), state_path.clone()), false);
|
||||
|
||||
assert!(applied.setup_state.telemetry_opted_out());
|
||||
assert!(
|
||||
std::fs::read_to_string(&path)
|
||||
.expect("read back")
|
||||
std::fs::read_to_string(config_path)
|
||||
.expect("read config")
|
||||
.contains("telemetry = false")
|
||||
);
|
||||
assert!(
|
||||
SetupState::load_from(&state_path)
|
||||
.expect("saved state")
|
||||
.telemetry_opted_out()
|
||||
);
|
||||
assert_eq!(
|
||||
applied.status_message_id,
|
||||
MessageId::TelemetryNoticeReceiptDisabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_tty_test_surface_cannot_record_a_notice_decision() {
|
||||
assert!(!prompt_if_due(true, None));
|
||||
fn keeping_on_records_the_notice_without_rewriting_config() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
let state_path = dir.path().join("setup_state.json");
|
||||
std::fs::write(&config_path, "# keep me\n").expect("seed config");
|
||||
let applied = apply_decision(&pending_at(config_path.clone(), state_path.clone()), true);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(config_path).expect("read config"),
|
||||
"# keep me\n"
|
||||
);
|
||||
assert!(
|
||||
SetupState::load_from(&state_path)
|
||||
.expect("saved state")
|
||||
.telemetry_accepted(TELEMETRY_NOTICE_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
applied.status_message_id,
|
||||
MessageId::TelemetryNoticeReceiptEnabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_keep_choice_cannot_overwrite_a_newer_external_opt_out() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
let state_path = dir.path().join("setup_state.json");
|
||||
std::fs::write(&config_path, "# keep me\n").expect("seed config");
|
||||
let pending = pending_at(config_path.clone(), state_path.clone());
|
||||
|
||||
// The notice was already open when another process recorded Disable.
|
||||
let mut externally_updated = SetupState::default();
|
||||
externally_updated.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
|
||||
externally_updated
|
||||
.save_to(&state_path)
|
||||
.expect("save concurrent opt-out");
|
||||
|
||||
let applied = apply_decision(&pending, true);
|
||||
|
||||
assert!(applied.setup_state.telemetry_opted_out());
|
||||
assert_eq!(
|
||||
applied.status_message_id,
|
||||
MessageId::TelemetryNoticeReceiptDisabled
|
||||
);
|
||||
assert!(
|
||||
SetupState::load_from(&state_path)
|
||||
.expect("reloaded state")
|
||||
.telemetry_opted_out(),
|
||||
"the stale modal must preserve the newer on-disk decline"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(config_path).expect("read config"),
|
||||
"# keep me\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_setup_state_is_never_replaced_with_telemetry_defaults() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
let state_path = dir.path().join("setup_state.json");
|
||||
std::fs::write(&config_path, "").expect("seed config");
|
||||
std::fs::write(&state_path, "not-json").expect("seed corrupt state");
|
||||
|
||||
assert!(load_notice_state_at(&state_path).is_err());
|
||||
let store = codewhale_config::ConfigStore::load(Some(config_path)).expect("load config");
|
||||
let plan = plan_for_store_and_state(store, state_path.clone(), SessionSource::Interactive);
|
||||
assert!(matches!(&plan, TelemetryNoticePlan::SuppressArming));
|
||||
assert!(
|
||||
!plan.should_arm_before_tui(),
|
||||
"unreadable privacy state must fail closed instead of arming by default"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&state_path).expect("read corrupt state"),
|
||||
"not-json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpersisted_disable_choice_still_exists_in_memory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
// A directory cannot be loaded/saved as a TOML config file, so this
|
||||
// deterministically exercises the no-durable-register path even when
|
||||
// tests run as a privileged user.
|
||||
let unwritable_config = dir.path().to_path_buf();
|
||||
let corrupt_state = dir.path().join("setup_state.json");
|
||||
std::fs::write(&corrupt_state, "not-json").expect("seed corrupt state");
|
||||
let applied = apply_decision(&pending_at(unwritable_config, corrupt_state.clone()), false);
|
||||
|
||||
assert!(applied.setup_state.telemetry_opted_out());
|
||||
assert_eq!(
|
||||
applied.status_message_id,
|
||||
MessageId::TelemetryNoticeReceiptDisabledUnsaved
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(corrupt_state).expect("read corrupt state"),
|
||||
"not-json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpersisted_keep_choice_is_reported_as_selected_but_unsaved() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
let corrupt_state = dir.path().join("setup_state.json");
|
||||
std::fs::write(&config_path, "").expect("seed config");
|
||||
std::fs::write(&corrupt_state, "not-json").expect("seed corrupt state");
|
||||
|
||||
let applied = apply_decision(&pending_at(config_path, corrupt_state.clone()), true);
|
||||
|
||||
assert!(
|
||||
applied
|
||||
.setup_state
|
||||
.telemetry_accepted(TELEMETRY_NOTICE_VERSION),
|
||||
"the explicit choice still governs this process"
|
||||
);
|
||||
assert_eq!(
|
||||
applied.status_message_id,
|
||||
MessageId::TelemetryNoticeReceiptEnabledUnsaved
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(corrupt_state).expect("read corrupt state"),
|
||||
"not-json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_tty_test_surface_cannot_schedule_the_native_notice() {
|
||||
assert!(matches!(
|
||||
plan_if_due(None, SessionSource::Interactive),
|
||||
TelemetryNoticePlan::NotDue
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ fn every_surface_is_named_by_the_subcommand_not_the_executable() {
|
||||
telemetry_surface(command_of(&["codewhale-tui", "fork", "--last"]).as_ref()),
|
||||
Surface::Tui
|
||||
);
|
||||
assert_eq!(
|
||||
telemetry_surface(command_of(&["codewhale-tui", "pr", "42"]).as_ref()),
|
||||
Surface::Tui
|
||||
);
|
||||
assert_eq!(
|
||||
telemetry_surface(command_of(&["codewhale-tui", "exec", "hello"]).as_ref()),
|
||||
Surface::Exec
|
||||
@@ -38,11 +42,12 @@ fn every_surface_is_named_by_the_subcommand_not_the_executable() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_diagnostics_never_arm_usage_counting() {
|
||||
fn read_only_commands_never_arm_usage_counting() {
|
||||
for args in [
|
||||
vec!["codewhale-tui", "doctor"],
|
||||
vec!["codewhale-tui", "doctor", "--json"],
|
||||
vec!["codewhale-tui", "session-diagnostics", "session.jsonl"],
|
||||
vec!["codewhale-tui", "sessions"],
|
||||
vec!["codewhale-tui", "setup", "--status"],
|
||||
] {
|
||||
let command = command_of(&args);
|
||||
@@ -59,11 +64,35 @@ fn read_only_diagnostics_never_arm_usage_counting() {
|
||||
let command = command_of(&args);
|
||||
assert!(
|
||||
!telemetry_command_is_read_only(command.as_ref()),
|
||||
"{args:?} is not a read-only diagnostic"
|
||||
"{args:?} is not a read-only command"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_resume_session_listing_cannot_consume_the_process_telemetry_arm() {
|
||||
// Bare `codewhale resume` on Windows invokes `sessions` in-process before
|
||||
// starting the selected resumed TUI. The listing must remain state-free so
|
||||
// that the process-global telemetry initializer is still available to the
|
||||
// resumed TUI after its native privacy decision.
|
||||
let command = command_of(&["codewhale-tui", "sessions"]);
|
||||
assert_eq!(telemetry_surface(command.as_ref()), Surface::Cli);
|
||||
assert!(telemetry_command_is_read_only(command.as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_existing_setup_state_suppresses_telemetry_instead_of_defaulting_on() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let state_path = dir.path().join("setup_state.json");
|
||||
std::fs::write(&state_path, "not-json").expect("seed corrupt state");
|
||||
|
||||
assert!(codewhale_telemetry::load_setup_state_for_decision_at(&state_path).is_none());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(state_path).expect("corrupt state remains untouched"),
|
||||
"not-json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_source_distinguishes_resume_and_fork_from_a_fresh_launch() {
|
||||
assert_eq!(telemetry_session_source(None), SessionSource::Interactive);
|
||||
@@ -75,6 +104,10 @@ fn the_session_source_distinguishes_resume_and_fork_from_a_fresh_launch() {
|
||||
telemetry_session_source(command_of(&["codewhale-tui", "fork", "--last"]).as_ref()),
|
||||
SessionSource::Fork
|
||||
);
|
||||
assert_eq!(
|
||||
telemetry_session_source(command_of(&["codewhale-tui", "pr", "42"]).as_ref()),
|
||||
SessionSource::Interactive
|
||||
);
|
||||
assert_eq!(
|
||||
telemetry_session_source(command_of(&["codewhale-tui", "serve", "--http"]).as_ref()),
|
||||
SessionSource::Api
|
||||
|
||||
@@ -92,6 +92,7 @@ pub mod startup_defaults;
|
||||
pub mod streaming;
|
||||
pub mod streaming_thinking;
|
||||
mod subagent_routing;
|
||||
pub(crate) mod telemetry_notice;
|
||||
pub mod theme_picker;
|
||||
mod tool_routing;
|
||||
pub mod transcript;
|
||||
|
||||
@@ -346,9 +346,13 @@ impl OceanRamp {
|
||||
&& theme.surface_bg == crate::palette::UI_THEME.surface_bg
|
||||
{
|
||||
return Some(Self {
|
||||
surface: Color::Rgb(0x0e, 0x17, 0x29),
|
||||
middle: Color::Rgb(0x08, 0x11, 0x1c),
|
||||
deep: Color::Rgb(0x03, 0x07, 0x0d),
|
||||
// Keep the authored Whale column unmistakably blue all the
|
||||
// way to the floor. These restrained ocean shades sit between
|
||||
// the shell's ink surfaces and its ambient blue: the empty
|
||||
// field gains depth without becoming a saturated blue panel.
|
||||
surface: Color::Rgb(0x10, 0x2a, 0x45),
|
||||
middle: Color::Rgb(0x0a, 0x1e, 0x33),
|
||||
deep: Color::Rgb(0x06, 0x13, 0x20),
|
||||
ambient: Color::Rgb(0x26, 0x48, 0x66),
|
||||
});
|
||||
}
|
||||
@@ -394,9 +398,11 @@ impl OceanRamp {
|
||||
}
|
||||
let position = f32::from(row.min(height - 1)) / f32::from(height - 1);
|
||||
if position <= 0.42 {
|
||||
mix_colors(self.surface, self.middle, position / 0.42)
|
||||
// Ease into each depth anchor so large empty regions read as calm
|
||||
// water bands rather than a mechanically uniform color ramp.
|
||||
mix_colors(self.surface, self.middle, smoothstep(position / 0.42))
|
||||
} else {
|
||||
mix_colors(self.middle, self.deep, (position - 0.42) / 0.58)
|
||||
mix_colors(self.middle, self.deep, smoothstep((position - 0.42) / 0.58))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ fn contrast_ratio(foreground: Color, background: Color) -> f64 {
|
||||
#[test]
|
||||
fn whale_ramp_is_perceptibly_deep_not_merely_non_equal() {
|
||||
let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
|
||||
assert_eq!(ramp.surface, Color::Rgb(0x0e, 0x17, 0x29));
|
||||
assert_eq!(ramp.middle, Color::Rgb(0x08, 0x11, 0x1c));
|
||||
assert_eq!(ramp.deep, Color::Rgb(0x03, 0x07, 0x0d));
|
||||
assert_eq!(ramp.surface, Color::Rgb(0x10, 0x2a, 0x45));
|
||||
assert_eq!(ramp.middle, Color::Rgb(0x0a, 0x1e, 0x33));
|
||||
assert_eq!(ramp.deep, Color::Rgb(0x06, 0x13, 0x20));
|
||||
assert!(
|
||||
distance(ramp.surface, ramp.deep) >= 32,
|
||||
"the selected underwater treatment must read at a glance"
|
||||
@@ -43,6 +43,70 @@ fn whale_ramp_is_perceptibly_deep_not_merely_non_equal() {
|
||||
assert_ne!(ramp.color_at(0, 20), ramp.color_at(19, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_column_stays_blue_and_gently_banded_at_full_screen_depth() {
|
||||
let theme = crate::palette::UI_THEME;
|
||||
let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
|
||||
let mut previous = ramp.color_at(0, 80);
|
||||
|
||||
for row in 0..80 {
|
||||
let current = ramp.color_at(row, 80);
|
||||
let (red, green, blue) = rgb(current).expect("RGB ocean color");
|
||||
assert!(
|
||||
blue > green && green > red,
|
||||
"row {row} lost the authored blue-ocean ordering: {current:?}"
|
||||
);
|
||||
assert!(
|
||||
relative_luminance(current) > relative_luminance(theme.surface_bg),
|
||||
"row {row} fell back into the near-black shell field"
|
||||
);
|
||||
assert!(
|
||||
distance(previous, current) <= 4,
|
||||
"row {row} introduced a hard depth seam"
|
||||
);
|
||||
previous = current;
|
||||
}
|
||||
|
||||
assert_eq!(ramp.color_at(0, 80), ramp.surface);
|
||||
assert_eq!(ramp.color_at(79, 80), ramp.deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_ocean_keeps_text_and_semantic_roles_readable() {
|
||||
let theme = crate::palette::UI_THEME;
|
||||
let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
|
||||
let foregrounds = [
|
||||
("body", theme.text_body),
|
||||
("soft", theme.text_soft),
|
||||
("muted", theme.text_muted),
|
||||
("hint", theme.text_hint),
|
||||
("action", theme.accent_primary),
|
||||
("live", theme.status_working),
|
||||
("human", theme.accent_action),
|
||||
("warning", theme.warning),
|
||||
("danger", theme.error_fg),
|
||||
("act mode", theme.mode_agent),
|
||||
("plan mode", theme.mode_plan),
|
||||
("operate", theme.mode_operate),
|
||||
("full-access mode", theme.mode_yolo),
|
||||
("success", theme.success),
|
||||
];
|
||||
|
||||
for (background_name, background) in [
|
||||
("ocean surface", ramp.surface),
|
||||
("ocean middle", ramp.middle),
|
||||
("ocean deep", ramp.deep),
|
||||
] {
|
||||
for (foreground_name, foreground) in foregrounds {
|
||||
let ratio = contrast_ratio(foreground, background);
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"Whale {foreground_name} on {background_name} contrast {ratio:.2} is below 4.50"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_theme_stays_light_enough_for_light_theme_text() {
|
||||
let ramp = OceanRamp::for_theme(&crate::palette::LIGHT_UI_THEME).expect("RGB theme");
|
||||
@@ -195,9 +259,9 @@ fn ambient_ink_matches_sunk_sky_shades_and_survives_reset_surfaces() {
|
||||
fn shimmer_is_subtle_and_concentrated_near_the_surface() {
|
||||
let ramp = OceanRamp::for_theme(&crate::palette::UI_THEME).expect("RGB theme");
|
||||
let surface_a = ramp.color_at_phase(0, 20, 0, ShellPhase::Idle);
|
||||
let surface_b = ramp.color_at_phase(0, 20, 3_000, ShellPhase::Idle);
|
||||
let surface_b = ramp.color_at_phase(0, 20, 22_500, ShellPhase::Idle);
|
||||
let deep_a = ramp.color_at_phase(19, 20, 0, ShellPhase::Idle);
|
||||
let deep_b = ramp.color_at_phase(19, 20, 3_000, ShellPhase::Idle);
|
||||
let deep_b = ramp.color_at_phase(19, 20, 22_500, ShellPhase::Idle);
|
||||
|
||||
let surface_shift = distance(surface_a, surface_b);
|
||||
assert!(
|
||||
@@ -398,6 +462,34 @@ fn split_shell_surfaces_share_one_absolute_row_column() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_viewport_water_column_reaches_both_terminal_edges() {
|
||||
let theme = crate::palette::UI_THEME;
|
||||
let ramp = OceanRamp::for_theme(&theme).expect("RGB theme");
|
||||
let viewport = Rect::new(0, 0, 120, 32);
|
||||
let mut buf = Buffer::empty(viewport);
|
||||
for y in viewport.top()..viewport.bottom() {
|
||||
for x in viewport.left()..viewport.right() {
|
||||
buf[(x, y)].set_bg(theme.surface_bg);
|
||||
}
|
||||
}
|
||||
buf[(60, 16)].set_bg(theme.selection_bg);
|
||||
|
||||
let column = OceanColumn::new(ramp, viewport, 0, None, ShellPhase::Idle, false, 0);
|
||||
column.paint_matching(viewport, &mut buf, theme.surface_bg);
|
||||
|
||||
for y in viewport.top()..viewport.bottom() {
|
||||
let expected = ramp.color_at(y, viewport.height);
|
||||
assert_eq!(buf[(viewport.left(), y)].bg, expected);
|
||||
assert_eq!(buf[(viewport.right() - 1, y)].bg, expected);
|
||||
}
|
||||
assert_eq!(
|
||||
buf[(60, 16)].bg,
|
||||
theme.selection_bg,
|
||||
"semantic surfaces must remain protected inside the full-width water column"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- v0.9.4: life presence eases the animated/static boundary ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! Native first-run disclosure for anonymous usage counting.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap},
|
||||
};
|
||||
|
||||
use crate::localization::{Locale, MessageId, tr};
|
||||
use crate::palette;
|
||||
use crate::telemetry_notice::PendingTelemetryNotice;
|
||||
use crate::tui::menu_style;
|
||||
use crate::tui::views::{
|
||||
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, action_footer_lines,
|
||||
centered_modal_area, render_modal_footer, render_modal_surface,
|
||||
};
|
||||
|
||||
/// The privacy notice is a normal Codewhale modal: one focus owner, two
|
||||
/// explicit choices, and no second shell prompt after confirmation.
|
||||
pub(crate) struct TelemetryNoticeView {
|
||||
pending: PendingTelemetryNotice,
|
||||
locale: Locale,
|
||||
enabled: bool,
|
||||
row_hitboxes: RefCell<Vec<(Rect, bool)>>,
|
||||
}
|
||||
|
||||
impl TelemetryNoticeView {
|
||||
#[must_use]
|
||||
pub(crate) fn new(pending: PendingTelemetryNotice, locale: Locale) -> Self {
|
||||
Self {
|
||||
pending,
|
||||
locale,
|
||||
enabled: true,
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_keep(&mut self) {
|
||||
self.enabled = true;
|
||||
}
|
||||
|
||||
fn select_disable(&mut self) {
|
||||
self.enabled = false;
|
||||
}
|
||||
|
||||
fn toggle(&mut self) {
|
||||
self.enabled = !self.enabled;
|
||||
}
|
||||
|
||||
fn commit(&self, enabled: bool) -> ViewAction {
|
||||
ViewAction::EmitAndClose(ViewEvent::TelemetryNoticeDecided {
|
||||
enabled,
|
||||
pending: self.pending.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render_choice(&self, label: MessageId, enabled: bool) -> Line<'static> {
|
||||
let selected = self.enabled == enabled;
|
||||
let marker = crate::tui::glyphs::selection_marker(selected);
|
||||
let style = if selected {
|
||||
menu_style::selected_row_style().add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
Line::from(Span::styled(
|
||||
format!("{marker} {}", tr(self.locale, label)),
|
||||
style,
|
||||
))
|
||||
}
|
||||
|
||||
fn action_hints(&self) -> [ActionHint; 3] {
|
||||
[
|
||||
ActionHint::new(
|
||||
"↑↓",
|
||||
tr(self.locale, MessageId::TelemetryNoticeActionChoose),
|
||||
),
|
||||
ActionHint::new(
|
||||
"Enter",
|
||||
tr(self.locale, MessageId::TelemetryNoticeActionConfirm),
|
||||
),
|
||||
ActionHint::new("Esc", tr(self.locale, MessageId::TelemetryNoticeActionExit)),
|
||||
]
|
||||
}
|
||||
|
||||
fn compact_action_line(&self) -> Line<'static> {
|
||||
let key_style = Style::default()
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let verb_style = Style::default().fg(palette::TEXT_MUTED);
|
||||
Line::from(vec![
|
||||
Span::styled(" ↑↓ ", key_style),
|
||||
Span::styled(
|
||||
tr(self.locale, MessageId::TelemetryNoticeActionChoose).into_owned(),
|
||||
verb_style,
|
||||
),
|
||||
Span::styled(" · Enter ", key_style),
|
||||
Span::styled(
|
||||
tr(self.locale, MessageId::TelemetryNoticeActionConfirm).into_owned(),
|
||||
verb_style,
|
||||
),
|
||||
Span::styled(" · Esc ", key_style),
|
||||
Span::styled(
|
||||
tr(self.locale, MessageId::TelemetryNoticeActionExit).into_owned(),
|
||||
verb_style,
|
||||
),
|
||||
Span::raw(" "),
|
||||
])
|
||||
}
|
||||
|
||||
fn tiny_action_line() -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
" ↑↓ · Enter · Esc ",
|
||||
Style::default()
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}
|
||||
|
||||
fn compact_notice_text(locale: Locale) -> String {
|
||||
// Locale packs author semantic clauses on separate lines for larger
|
||||
// compact modals. Reflow those clauses at the actual viewport width
|
||||
// so constrained terminals do not spend half-empty rows on source
|
||||
// newlines and clip the schema or persistent opt-out path.
|
||||
tr(locale, MessageId::TelemetryNoticeCompactBody)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn full_popup_area(area: Rect) -> Rect {
|
||||
centered_modal_area(area, 84, 22, 38, 18)
|
||||
}
|
||||
|
||||
/// Use the full disclosure only when its exact localized wrapping leaves
|
||||
/// room for both choices and the action rail. Width/height thresholds alone
|
||||
/// selected the full copy at 56x18 while giving it only eleven prose rows.
|
||||
fn full_notice_fits(&self, area: Rect) -> bool {
|
||||
let popup = Self::full_popup_area(area);
|
||||
// Full mode has a one-cell border and two cells of horizontal padding
|
||||
// on both sides. Vertical padding is zero.
|
||||
let inner_width = popup.width.saturating_sub(6);
|
||||
let inner_height = popup.height.saturating_sub(2);
|
||||
if inner_width == 0 || inner_height < 3 {
|
||||
return false;
|
||||
}
|
||||
let footer_height =
|
||||
u16::try_from(action_footer_lines(&self.action_hints(), inner_width).len())
|
||||
.unwrap_or(u16::MAX);
|
||||
let prose_height = inner_height.saturating_sub(footer_height.saturating_add(2));
|
||||
let full_body = tr(self.locale, MessageId::TelemetryNoticeBody).into_owned();
|
||||
let wrapped_rows = Paragraph::new(full_body)
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(inner_width);
|
||||
wrapped_rows <= usize::from(prose_height)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for TelemetryNoticeView {
|
||||
fn kind(&self) -> ModalKind {
|
||||
ModalKind::TelemetryNotice
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key.code, KeyCode::Char('c' | 'C'))
|
||||
{
|
||||
return ViewAction::EmitAndClose(ViewEvent::TelemetryNoticeCancelled);
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::TelemetryNoticeCancelled),
|
||||
KeyCode::Enter => self.commit(self.enabled),
|
||||
KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.select_keep();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Right => {
|
||||
self.select_disable();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.toggle();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Char('y' | 'Y') => self.commit(true),
|
||||
KeyCode::Char('n' | 'N') => self.commit(false),
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
self.select_keep();
|
||||
ViewAction::None
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
self.select_disable();
|
||||
ViewAction::None
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
let selected = self
|
||||
.row_hitboxes
|
||||
.borrow()
|
||||
.iter()
|
||||
.find_map(|(rect, enabled)| {
|
||||
rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
.then_some(*enabled)
|
||||
});
|
||||
selected.map_or(ViewAction::None, |enabled| self.commit(enabled))
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let compact = !self.full_notice_fits(area);
|
||||
// At the smallest supported height, use the whole frame. The border,
|
||||
// localized action rail, both choices, and every privacy red line all
|
||||
// remain visible at 40x12; larger frames keep the calm outer gutter.
|
||||
let popup_area = if compact && area.height <= 12 {
|
||||
area
|
||||
} else if compact {
|
||||
centered_modal_area(area, 84, 16, 38, 12)
|
||||
} else {
|
||||
Self::full_popup_area(area)
|
||||
};
|
||||
render_modal_surface(area, popup_area, buf);
|
||||
|
||||
let mut block = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
format!(" {} ", tr(self.locale, MessageId::TelemetryNoticeHeadline)),
|
||||
Style::default()
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(if compact {
|
||||
Padding::new(0, 0, 0, 0)
|
||||
} else {
|
||||
Padding::new(2, 2, 0, 0)
|
||||
});
|
||||
if compact {
|
||||
// The compact footer lives in the border instead of consuming one
|
||||
// of the eight disclosure rows available at 40x12. The release
|
||||
// floor uses key-only hints: localized verbs can exceed the whole
|
||||
// 38-column border, while the keys remain language-independent.
|
||||
block = block.title_bottom(if popup_area.width <= 40 {
|
||||
Self::tiny_action_line()
|
||||
} else {
|
||||
self.compact_action_line()
|
||||
});
|
||||
}
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
|
||||
let content = if compact {
|
||||
inner
|
||||
} else {
|
||||
render_modal_footer(inner, buf, &self.action_hints())
|
||||
};
|
||||
let choice_height = 2.min(content.height);
|
||||
let choices = Rect {
|
||||
x: content.x,
|
||||
y: content.bottom().saturating_sub(choice_height),
|
||||
width: content.width,
|
||||
height: choice_height,
|
||||
};
|
||||
let prose = Rect {
|
||||
x: content.x,
|
||||
y: content.y,
|
||||
width: content.width,
|
||||
height: content.height.saturating_sub(choice_height),
|
||||
};
|
||||
|
||||
let notice = if compact {
|
||||
Self::compact_notice_text(self.locale)
|
||||
} else {
|
||||
tr(self.locale, MessageId::TelemetryNoticeBody).into_owned()
|
||||
};
|
||||
Paragraph::new(notice)
|
||||
.style(Style::default().fg(palette::TEXT_MUTED))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(prose, buf);
|
||||
|
||||
self.row_hitboxes.borrow_mut().clear();
|
||||
if choices.height > 0 {
|
||||
self.row_hitboxes
|
||||
.borrow_mut()
|
||||
.push((Rect::new(choices.x, choices.y, choices.width, 1), true));
|
||||
}
|
||||
if choices.height > 1 {
|
||||
self.row_hitboxes.borrow_mut().push((
|
||||
Rect::new(choices.x, choices.y.saturating_add(1), choices.width, 1),
|
||||
false,
|
||||
));
|
||||
}
|
||||
Paragraph::new(vec![
|
||||
self.render_choice(MessageId::TelemetryNoticeChoiceKeep, true),
|
||||
self.render_choice(MessageId::TelemetryNoticeChoiceDisable, false),
|
||||
])
|
||||
.render(choices, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_telemetry::SessionSource;
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
fn pending() -> PendingTelemetryNotice {
|
||||
PendingTelemetryNotice {
|
||||
config_path: Some("config.toml".into()),
|
||||
setup_state_path: "setup_state.json".into(),
|
||||
session_source: SessionSource::Interactive,
|
||||
}
|
||||
}
|
||||
|
||||
fn decision(action: ViewAction) -> Option<bool> {
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::TelemetryNoticeDecided { enabled, .. }) => {
|
||||
Some(enabled)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_keeps_the_disclosed_default() {
|
||||
let mut view = TelemetryNoticeView::new(pending(), Locale::En);
|
||||
assert_eq!(
|
||||
decision(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_navigation_and_direct_shortcuts_are_explicit() {
|
||||
let mut view = TelemetryNoticeView::new(pending(), Locale::En);
|
||||
assert!(matches!(
|
||||
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
|
||||
ViewAction::None
|
||||
));
|
||||
assert_eq!(
|
||||
decision(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let mut view = TelemetryNoticeView::new(pending(), Locale::En);
|
||||
assert_eq!(
|
||||
decision(view.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE))),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
decision(view.handle_key(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE))),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_exits_without_inventing_a_choice() {
|
||||
let mut view = TelemetryNoticeView::new(pending(), Locale::En);
|
||||
assert!(matches!(
|
||||
view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
ViewAction::EmitAndClose(ViewEvent::TelemetryNoticeCancelled)
|
||||
));
|
||||
}
|
||||
|
||||
fn rendered_text(locale: Locale, width: u16, height: u16) -> String {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
|
||||
let view = TelemetryNoticeView::new(pending(), locale);
|
||||
terminal
|
||||
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render notice");
|
||||
let buf = terminal.backend().buffer();
|
||||
(0..height)
|
||||
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclosure_and_both_choices_survive_responsive_sizes() {
|
||||
for (width, height) in [(40, 12), (56, 18), (60, 18), (70, 20), (80, 24), (100, 32)] {
|
||||
let text = rendered_text(Locale::En, width, height);
|
||||
assert!(
|
||||
text.contains("Anonymous usage counting"),
|
||||
"{width}x{height}"
|
||||
);
|
||||
assert!(text.contains("Keep anonymous usage on"), "{width}x{height}");
|
||||
assert!(text.contains("Disable anonymous usage"), "{width}x{height}");
|
||||
assert!(text.contains("Enter"), "{width}x{height}");
|
||||
if width < 80 {
|
||||
for required in [
|
||||
"chat/code/prompts/files/names",
|
||||
"time/result",
|
||||
"feature/error",
|
||||
"90d",
|
||||
"model",
|
||||
"content/credentials",
|
||||
"Schema:",
|
||||
"docs/TELEMETRY.md",
|
||||
"Off:",
|
||||
"codewhale",
|
||||
"config",
|
||||
"set",
|
||||
"telemetry",
|
||||
"false",
|
||||
] {
|
||||
assert!(
|
||||
text.contains(required),
|
||||
"{width}x{height} lost {required}:\n{text}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert!(text.contains("conversations"), "{width}x{height}");
|
||||
assert!(
|
||||
text.contains("Full schema, field by field: docs/TELEMETRY.md"),
|
||||
"{width}x{height} clipped the full schema path:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("codewhale config set telemetry false"),
|
||||
"{width}x{height} clipped the full opt-out command:\n{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_copy_is_selected_only_when_its_localized_wrapping_fits() {
|
||||
let view = TelemetryNoticeView::new(pending(), Locale::En);
|
||||
for (width, height) in [(40, 12), (56, 18), (60, 18), (70, 20)] {
|
||||
assert!(
|
||||
!view.full_notice_fits(Rect::new(0, 0, width, height)),
|
||||
"{width}x{height} must use compact disclosure"
|
||||
);
|
||||
}
|
||||
for (width, height) in [(80, 24), (100, 32)] {
|
||||
assert!(
|
||||
view.full_notice_fits(Rect::new(0, 0, width, height)),
|
||||
"{width}x{height} has room for the full disclosure"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_locale_keeps_both_choices_visible_at_every_supported_size() {
|
||||
for locale in Locale::shipped_complete() {
|
||||
let keep = tr(*locale, MessageId::TelemetryNoticeChoiceKeep);
|
||||
let disable = tr(*locale, MessageId::TelemetryNoticeChoiceDisable);
|
||||
for (width, height) in [(40, 12), (56, 18), (60, 18), (70, 20), (80, 24), (100, 32)] {
|
||||
let text = rendered_text(*locale, width, height);
|
||||
// TestBackend stores the continuation cell of each wide CJK
|
||||
// glyph as a space. Remove whitespace from both sides so the
|
||||
// assertion detects clipping rather than that storage detail.
|
||||
let compact_text = text
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.collect::<String>();
|
||||
let compact_keep = keep
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.collect::<String>();
|
||||
let compact_disable = disable
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.collect::<String>();
|
||||
assert!(
|
||||
compact_text.contains(&compact_keep),
|
||||
"{} {width}x{height} clipped keep choice `{keep}`:\n{text}",
|
||||
locale.tag()
|
||||
);
|
||||
assert!(
|
||||
compact_text.contains(&compact_disable),
|
||||
"{} {width}x{height} clipped disable choice `{disable}`:\n{text}",
|
||||
locale.tag()
|
||||
);
|
||||
if (width, height) == (40, 12) || (width, height) == (56, 18) {
|
||||
assert!(
|
||||
compact_text.contains("docs/TELEMETRY.md"),
|
||||
"{} {width}x{height} clipped compact schema path:\n{text}",
|
||||
locale.tag(),
|
||||
);
|
||||
for required in ["codewhale", "config", "set", "telemetry", "false"] {
|
||||
assert!(
|
||||
compact_text.contains(required),
|
||||
"{} {width}x{height} clipped `{required}` from compact opt-out path:\n{text}",
|
||||
locale.tag(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_locale_fits_the_complete_compact_disclosure_at_40x12() {
|
||||
let mut overflow = Vec::new();
|
||||
for locale in Locale::shipped_complete() {
|
||||
let rows = Paragraph::new(TelemetryNoticeView::compact_notice_text(*locale))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(38);
|
||||
if rows > 8 {
|
||||
overflow.push((locale.tag(), rows));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
overflow.is_empty(),
|
||||
"compact disclosure exceeds eight prose rows at 40x12: {overflow:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_rendered_choice_commits_that_choice() {
|
||||
let mut view = TelemetryNoticeView::new(pending(), Locale::En);
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render notice");
|
||||
let disable = view.row_hitboxes.borrow()[1].0;
|
||||
assert_eq!(
|
||||
decision(view.handle_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: disable.x,
|
||||
row: disable.y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_full_body_is_the_schema_owned_notice_verbatim() {
|
||||
assert_eq!(
|
||||
tr(Locale::En, MessageId::TelemetryNoticeBody).as_ref(),
|
||||
codewhale_telemetry::notice::NOTICE_BODY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_complete_locale_has_native_notice_copy() {
|
||||
let ids = [
|
||||
MessageId::TelemetryNoticeHeadline,
|
||||
MessageId::TelemetryNoticeBody,
|
||||
MessageId::TelemetryNoticeCompactBody,
|
||||
MessageId::TelemetryNoticeChoiceKeep,
|
||||
MessageId::TelemetryNoticeChoiceDisable,
|
||||
MessageId::TelemetryNoticeActionChoose,
|
||||
MessageId::TelemetryNoticeActionConfirm,
|
||||
MessageId::TelemetryNoticeActionExit,
|
||||
MessageId::TelemetryNoticeReceiptEnabled,
|
||||
MessageId::TelemetryNoticeReceiptDisabled,
|
||||
MessageId::TelemetryNoticeReceiptEnabledUnsaved,
|
||||
MessageId::TelemetryNoticeReceiptDisabledUnsaved,
|
||||
];
|
||||
for locale in Locale::shipped_complete() {
|
||||
for id in ids {
|
||||
let copy = tr(*locale, id);
|
||||
assert!(!copy.trim().is_empty(), "{} has empty {id:?}", locale.tag());
|
||||
assert_ne!(
|
||||
copy.as_ref(),
|
||||
format!("{id:?}"),
|
||||
"{} lacks {id:?}",
|
||||
locale.tag()
|
||||
);
|
||||
if *locale != Locale::En {
|
||||
assert_ne!(
|
||||
copy,
|
||||
tr(Locale::En, id),
|
||||
"{} copied English {id:?}",
|
||||
locale.tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ const BLOCK_SEPARATOR_ROWS: usize = 1;
|
||||
enum TranscriptBoundary {
|
||||
/// Successive cells are literally one reasoning/answer phase.
|
||||
Joined,
|
||||
/// Adjacent tool cells share a rail; separation keeps that rail visible.
|
||||
/// Adjacent tool cells share one compact rail with no per-call padding.
|
||||
GroupedTool,
|
||||
/// Transition between response phases, tools, Work, or notices.
|
||||
Activity,
|
||||
@@ -808,7 +808,9 @@ fn transcript_boundary(
|
||||
) -> TranscriptBoundary {
|
||||
if same_tool_group {
|
||||
debug_assert_eq!(current, next);
|
||||
// Distinct calls sharing a rail still need a railed separator.
|
||||
// Distinct calls sharing a rail are one compact activity group. The
|
||||
// rail itself carries the grouping; padding every low-level call would
|
||||
// recreate the density problem this boundary matrix exists to solve.
|
||||
return TranscriptBoundary::GroupedTool;
|
||||
}
|
||||
|
||||
@@ -840,12 +842,9 @@ const fn spacer_rows_for_boundary(
|
||||
spacing: TranscriptSpacing,
|
||||
) -> usize {
|
||||
match (boundary, spacing) {
|
||||
(TranscriptBoundary::Joined, _) => 0,
|
||||
(
|
||||
TranscriptBoundary::GroupedTool | TranscriptBoundary::Activity,
|
||||
TranscriptSpacing::Compact,
|
||||
) => 0,
|
||||
(TranscriptBoundary::GroupedTool | TranscriptBoundary::Activity, _) => BLOCK_SEPARATOR_ROWS,
|
||||
(TranscriptBoundary::Joined | TranscriptBoundary::GroupedTool, _) => 0,
|
||||
(TranscriptBoundary::Activity, TranscriptSpacing::Compact) => 0,
|
||||
(TranscriptBoundary::Activity, _) => BLOCK_SEPARATOR_ROWS,
|
||||
(TranscriptBoundary::Turn, TranscriptSpacing::Compact | TranscriptSpacing::Comfortable) => {
|
||||
BLOCK_SEPARATOR_ROWS
|
||||
}
|
||||
|
||||
@@ -538,13 +538,12 @@ fn adjacent_tool_cells_render_as_one_railed_group() {
|
||||
"adjacent tool cells must never be separated by a bare blank row — that \
|
||||
would tear the card box open: {lines:?}"
|
||||
);
|
||||
// They are separated, though: by a rail-carrying spacer, so two distinct
|
||||
// commands read as two blocks without the group losing its outline.
|
||||
assert!(
|
||||
lines.iter().any(|line| line.trim_end() == "\u{2502}"),
|
||||
"distinct tool cells inside one rail group need a rail spacer between \
|
||||
them: {lines:?}"
|
||||
!lines.iter().any(|line| line.trim_end() == "\u{2502}"),
|
||||
"one tool group must stay compact instead of padding every call: {lines:?}"
|
||||
);
|
||||
assert_eq!(spacer_rows_after_cell(&cache, 0), 0);
|
||||
assert_eq!(spacer_rows_after_cell(&cache, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -612,12 +611,13 @@ fn semantic_boundary_matrix_has_four_deliberate_rhythm_levels() {
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Comfortable),
|
||||
1
|
||||
0,
|
||||
"the shared rail carries grouping without a row per tool call"
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Spacious),
|
||||
1,
|
||||
"one row is the whole vocabulary above compact — never two"
|
||||
0,
|
||||
"even spacious mode breathes around the group, not inside it"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -741,8 +741,8 @@ fn durable_work_starts_a_new_activity_rail_without_wasting_compact_rows() {
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_after_cell(&comfortable, 0),
|
||||
1,
|
||||
"two distinct commands sharing a rail still need one row between them"
|
||||
0,
|
||||
"two commands inside one activity rail must remain compact"
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_after_cell(&comfortable, 1),
|
||||
|
||||
@@ -143,6 +143,13 @@ pub(crate) async fn submit_initial_input_if_ready(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if app
|
||||
.view_stack
|
||||
.contains_kind(crate::tui::views::ModalKind::TelemetryNotice)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if app.onboarding != OnboardingState::None {
|
||||
if app.status_message.is_none() && !app.input.trim().is_empty() {
|
||||
app.status_message = Some(INITIAL_PROMPT_DEFERRED_STATUS.to_string());
|
||||
|
||||
@@ -61,6 +61,7 @@ pub async fn run_tui(
|
||||
config: &Config,
|
||||
options: TuiOptions,
|
||||
plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
|
||||
pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
|
||||
) -> Result<()> {
|
||||
let use_alt_screen = options.use_alt_screen;
|
||||
let use_mouse_capture = options.use_mouse_capture;
|
||||
@@ -458,7 +459,21 @@ pub async fn run_tui(
|
||||
tokio::sync::mpsc::channel::<crate::tui::app::DispatchApplyFn>(2);
|
||||
app.dispatch_completion_tx = Some(dispatch_completion_tx);
|
||||
|
||||
if std::mem::take(&mut app.start_remote_control_on_launch) {
|
||||
// The disclosure is the final startup view pushed, so setup/provider
|
||||
// surfaces remain ready underneath but nothing can appear above the
|
||||
// privacy decision. This also makes the first visible frame a native
|
||||
// Codewhale surface instead of a shell questionnaire.
|
||||
if let Some(pending) = pending_telemetry_notice {
|
||||
app.view_stack
|
||||
.push(crate::tui::telemetry_notice::TelemetryNoticeView::new(
|
||||
pending,
|
||||
app.ui_locale,
|
||||
));
|
||||
}
|
||||
|
||||
if app.view_stack.top_kind() != Some(ModalKind::TelemetryNotice)
|
||||
&& std::mem::take(&mut app.start_remote_control_on_launch)
|
||||
{
|
||||
start_remote_control_session(&mut app);
|
||||
}
|
||||
submit_initial_input_if_ready(&mut app, config, &engine_handle).await?;
|
||||
@@ -3369,6 +3384,30 @@ pub(crate) async fn run_event_loop(
|
||||
// to canonical Ctrl+C so the quit-arm flow always runs (#4090).
|
||||
normalize_raw_ctrl_c(&mut key);
|
||||
|
||||
// The first-run disclosure is the launch's decision boundary. It
|
||||
// owns every key before Help, Settings, onboarding, decision
|
||||
// cards, or the composer can see it; Esc/Ctrl+C exit without
|
||||
// recording a choice, and Enter/Y/N are handled by the native
|
||||
// view itself.
|
||||
if app.view_stack.top_kind() == Some(ModalKind::TelemetryNotice) {
|
||||
let events = app.view_stack.handle_key(key);
|
||||
app.needs_redraw = true;
|
||||
if handle_view_events_boxed(
|
||||
terminal,
|
||||
app,
|
||||
config,
|
||||
&task_manager,
|
||||
&mut engine_handle,
|
||||
&mut web_config_session,
|
||||
events,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A route change made in-session is temporary and stays that way
|
||||
// until the user EXPLICITLY persists it with a command
|
||||
// (/fleet save updates the selected Fleet, /fleet save-as saves a
|
||||
|
||||
@@ -5,17 +5,22 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Keep the working shell readable on wide terminals without introducing
|
||||
/// separate geometry for the transcript, composer, and chrome. Dense content
|
||||
/// still has 112 columns; additional terminal width becomes calm, symmetric
|
||||
/// breathing room instead of ever-longer prose lines.
|
||||
const SESSION_SHELL_MAX_WIDTH: u16 = 112;
|
||||
/// Keep compact terminals fully fluid, then introduce only a modest symmetric
|
||||
/// gutter as the canvas grows. One cell per twelve columns beyond the compact
|
||||
/// breakpoint gives wide sessions breathing room without turning the product
|
||||
/// into a narrow centered rail; the cap prevents ultra-wide terminals from
|
||||
/// accumulating large dead margins.
|
||||
const SESSION_SHELL_FLUID_WIDTH: u16 = 112;
|
||||
const SESSION_SHELL_GUTTER_STEP: u16 = 12;
|
||||
const SESSION_SHELL_MAX_SIDE_GUTTER: u16 = 16;
|
||||
|
||||
pub(crate) fn session_shell_area(area: Rect) -> Rect {
|
||||
let width = area.width.min(SESSION_SHELL_MAX_WIDTH);
|
||||
let side_gutter = (area.width.saturating_sub(SESSION_SHELL_FLUID_WIDTH)
|
||||
/ SESSION_SHELL_GUTTER_STEP)
|
||||
.min(SESSION_SHELL_MAX_SIDE_GUTTER);
|
||||
Rect {
|
||||
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
|
||||
width,
|
||||
x: area.x.saturating_add(side_gutter),
|
||||
width: area.width.saturating_sub(side_gutter.saturating_mul(2)),
|
||||
..area
|
||||
}
|
||||
}
|
||||
@@ -700,10 +705,11 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) {
|
||||
// Show onboarding screen if needed
|
||||
if app.onboarding != OnboardingState::None {
|
||||
onboarding::render(f, size, app);
|
||||
// The provider step hosts the canonical setup picker as a modal on
|
||||
// top of the onboarding backdrop; without this the pushed view is
|
||||
// invisible and recovery appears to hang on an empty legacy screen.
|
||||
if app.onboarding == OnboardingState::Provider && !app.view_stack.is_empty() {
|
||||
// Onboarding is a backdrop, not a separate screen manager. Render any
|
||||
// native view above every onboarding step so shared pickers and the
|
||||
// first-run privacy disclosure cannot become invisible outside the
|
||||
// Provider step.
|
||||
if !app.view_stack.is_empty() {
|
||||
let buf = f.buffer_mut();
|
||||
app.view_stack.render(size, buf);
|
||||
}
|
||||
@@ -711,8 +717,11 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) {
|
||||
}
|
||||
|
||||
if app.launch.visible {
|
||||
crate::tui::underwater::render_launch_screen(shell_area, f.buffer_mut(), app);
|
||||
crate::tui::underwater::record_launch_row_areas(shell_area, &mut app.launch);
|
||||
// Launch is a distinct full-canvas choice state, not a reading column.
|
||||
// Keep it edge-to-edge so opening Codewhale never recreates black side
|
||||
// banks before the responsive session ocean takes over.
|
||||
crate::tui::underwater::render_launch_screen(size, f.buffer_mut(), app);
|
||||
crate::tui::underwater::record_launch_row_areas(size, &mut app.launch);
|
||||
if !app.view_stack.is_empty() {
|
||||
if app.view_stack.top_kind() == Some(ModalKind::Approval) {
|
||||
app.viewport.last_approval_area = app.view_stack.top_occupied_region(size);
|
||||
@@ -1032,6 +1041,12 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) {
|
||||
// backgrounds such as selection, hover, errors, and code blocks do not
|
||||
// match these base colors and therefore remain intact.
|
||||
if let Some(column) = shell_ocean {
|
||||
// The working canvas may keep a small responsive gutter, but the water
|
||||
// does not stop at that content edge. Paint the cleared terminal floor
|
||||
// first so wide layouts read as one ocean rather than a blue card
|
||||
// floating between black banks. `paint_matching` leaves every semantic
|
||||
// widget background untouched.
|
||||
column.paint_matching(size, f.buffer_mut(), app.ui_theme.surface_bg);
|
||||
column.paint_matching(header_area, f.buffer_mut(), app.ui_theme.header_bg);
|
||||
if top_work_strip_height > 0 {
|
||||
column.paint_matching(body_chunks[0], f.buffer_mut(), app.ui_theme.surface_bg);
|
||||
|
||||
@@ -853,6 +853,23 @@ pub(crate) async fn handle_view_events(
|
||||
) -> Result<bool> {
|
||||
for event in events {
|
||||
match event {
|
||||
ViewEvent::TelemetryNoticeDecided { enabled, pending } => {
|
||||
let applied = crate::telemetry_notice::apply_decision(&pending, enabled);
|
||||
// Feed the just-made choice directly into the predicate. A
|
||||
// failed write can make Codewhale ask again next launch, but
|
||||
// it can never reverse Disable for this process.
|
||||
crate::apply_tui_telemetry_decision(&pending, &applied.setup_state);
|
||||
let status_message = app.tr(applied.status_message_id).into_owned();
|
||||
app.push_status_toast(status_message, StatusToastLevel::Info, Some(8_000));
|
||||
if std::mem::take(&mut app.start_remote_control_on_launch) {
|
||||
start_remote_control_session(app);
|
||||
}
|
||||
submit_initial_input_if_ready(app, config, engine_handle).await?;
|
||||
}
|
||||
ViewEvent::TelemetryNoticeCancelled => {
|
||||
let _ = engine_handle.send(Op::Shutdown).await;
|
||||
return Ok(true);
|
||||
}
|
||||
ViewEvent::CommandPaletteSelected { action } => match action {
|
||||
crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => {
|
||||
if execute_command_input(
|
||||
|
||||
@@ -36,20 +36,31 @@ use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn session_shell_area_stays_fluid_until_wide_then_centers_on_a_readable_rail() {
|
||||
fn session_shell_area_keeps_compact_geometry_and_uses_most_of_wide_terminals() {
|
||||
for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (112, 40)] {
|
||||
let fluid = Rect::new(4, 3, width, height);
|
||||
assert_eq!(frame::session_shell_area(fluid), fluid);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
frame::session_shell_area(Rect::new(4, 3, 140, 40)),
|
||||
Rect::new(18, 3, 112, 40)
|
||||
);
|
||||
assert_eq!(
|
||||
frame::session_shell_area(Rect::new(4, 3, 200, 50)),
|
||||
Rect::new(48, 3, 112, 50)
|
||||
);
|
||||
for (width, height, side_gutter) in [(140, 40, 2), (200, 50, 7), (240, 60, 10)] {
|
||||
let host = Rect::new(4, 3, width, height);
|
||||
let shell = frame::session_shell_area(host);
|
||||
assert_eq!(shell.x, host.x + side_gutter, "{width} columns");
|
||||
assert_eq!(
|
||||
shell.right() + side_gutter,
|
||||
host.right(),
|
||||
"{width} columns must retain symmetric gutters"
|
||||
);
|
||||
assert_eq!(shell.height, host.height);
|
||||
assert!(
|
||||
u32::from(shell.width) * 100 >= u32::from(host.width) * 85,
|
||||
"{width} columns left only {}% usable",
|
||||
u32::from(shell.width) * 100 / u32::from(host.width)
|
||||
);
|
||||
}
|
||||
|
||||
let ultra_wide = frame::session_shell_area(Rect::new(0, 0, 400, 60));
|
||||
assert_eq!(ultra_wide, Rect::new(16, 0, 368, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3454,7 +3465,7 @@ fn render_underwater_test_app(app: &mut App, width: u16, height: u16) -> String
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_underwater_shell_aligns_transcript_and_composer_on_the_shared_rail() {
|
||||
fn wide_underwater_shell_aligns_transcript_and_composer_on_the_shared_canvas() {
|
||||
let mut app = create_test_app();
|
||||
app.history = vec![HistoryCell::User {
|
||||
content: "A deliberately short prompt.".to_string(),
|
||||
@@ -3465,8 +3476,34 @@ fn wide_underwater_shell_aligns_transcript_and_composer_on_the_shared_rail() {
|
||||
|
||||
let transcript = app.viewport.last_transcript_area.expect("transcript area");
|
||||
let composer = app.viewport.last_composer_area.expect("composer area");
|
||||
assert_eq!((transcript.x, transcript.width), (24, 112));
|
||||
assert_eq!((composer.x, composer.width), (24, 112));
|
||||
assert_eq!((transcript.x, transcript.width), (4, 152));
|
||||
assert_eq!((composer.x, composer.width), (4, 152));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_underwater_canvas_carries_the_ocean_to_both_terminal_edges() {
|
||||
let mut app = create_test_app();
|
||||
app.onboarding_workspace_trust_gate = false;
|
||||
app.onboarding = OnboardingState::None;
|
||||
let surface_bg = app.ui_theme.surface_bg;
|
||||
let config = Config::default();
|
||||
let mut terminal = Terminal::new(TestBackend::new(200, 32)).expect("wide test terminal");
|
||||
terminal
|
||||
.draw(|frame| render(frame, &mut app, &config))
|
||||
.expect("render wide ocean canvas");
|
||||
let buffer = terminal.backend().buffer();
|
||||
|
||||
let mut ocean_tint_seen = false;
|
||||
for y in 0..buffer.area.height {
|
||||
let left = buffer[(0, y)].bg;
|
||||
let right = buffer[(buffer.area.width - 1, y)].bg;
|
||||
assert_eq!(left, right, "row {y} split into mismatched outer banks");
|
||||
ocean_tint_seen |= left != surface_bg;
|
||||
}
|
||||
assert!(
|
||||
ocean_tint_seen,
|
||||
"wide gutters retained the flat terminal floor instead of the shared ocean"
|
||||
);
|
||||
}
|
||||
|
||||
fn long_reasoning(label: &str, streaming: bool) -> HistoryCell {
|
||||
@@ -12827,6 +12864,50 @@ async fn startup_prompt_waits_for_onboarding_then_dispatches() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_prompt_waits_for_native_telemetry_decision_then_dispatches() {
|
||||
let mut app = create_test_app();
|
||||
app.input = "inspect the workspace".to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
app.auto_submit_initial_input = true;
|
||||
app.onboarding = OnboardingState::None;
|
||||
app.view_stack
|
||||
.push(crate::tui::telemetry_notice::TelemetryNoticeView::new(
|
||||
crate::telemetry_notice::PendingTelemetryNotice {
|
||||
config_path: Some("config.toml".into()),
|
||||
setup_state_path: "setup_state.json".into(),
|
||||
session_source: codewhale_telemetry::SessionSource::Interactive,
|
||||
},
|
||||
app.ui_locale,
|
||||
));
|
||||
let config = Config::default();
|
||||
let mut engine = crate::core::engine::mock_engine_handle();
|
||||
|
||||
submit_initial_input_if_ready(&mut app, &config, &engine.handle)
|
||||
.await
|
||||
.expect("defer for disclosure");
|
||||
|
||||
assert!(app.auto_submit_initial_input);
|
||||
assert_eq!(app.input, "inspect the workspace");
|
||||
assert!(engine.rx_op.try_recv().is_err());
|
||||
|
||||
assert_eq!(
|
||||
app.view_stack.pop().map(|view| view.kind()),
|
||||
Some(ModalKind::TelemetryNotice)
|
||||
);
|
||||
submit_initial_input_if_ready(&mut app, &config, &engine.handle)
|
||||
.await
|
||||
.expect("submit after disclosure");
|
||||
|
||||
assert!(!app.auto_submit_initial_input);
|
||||
match engine.rx_op.recv().await.expect("send message op") {
|
||||
crate::core::ops::Op::SendMessage { content, .. } => {
|
||||
assert!(content.contains("inspect the workspace"));
|
||||
}
|
||||
other => panic!("expected SendMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_user_message_records_prompt_for_cancel_restore() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -62,6 +62,8 @@ pub enum ModalKind {
|
||||
FilePicker,
|
||||
StatusPicker,
|
||||
FeedbackPicker,
|
||||
/// Blocking first-run disclosure rendered inside the native TUI.
|
||||
TelemetryNotice,
|
||||
ThemePicker,
|
||||
ContextMenu,
|
||||
ContextInspector,
|
||||
@@ -633,6 +635,14 @@ pub enum ContextMenuAction {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ViewEvent {
|
||||
/// The native privacy disclosure was explicitly answered.
|
||||
TelemetryNoticeDecided {
|
||||
enabled: bool,
|
||||
pending: crate::telemetry_notice::PendingTelemetryNotice,
|
||||
},
|
||||
/// The disclosure was dismissed without an answer; exit this launch and
|
||||
/// ask again next time rather than inventing a choice.
|
||||
TelemetryNoticeCancelled,
|
||||
CommandPaletteSelected {
|
||||
action: CommandPaletteAction,
|
||||
},
|
||||
|
||||
@@ -4157,7 +4157,7 @@ mod tests {
|
||||
use crate::tui::active_cell::ActiveCell;
|
||||
use crate::tui::app::{
|
||||
App, AppMode, ComposerDensity, TaskPanelEntry, TaskPanelEntryKind, ToolCollapseMode,
|
||||
TuiOptions,
|
||||
TranscriptSpacing, TuiOptions,
|
||||
};
|
||||
use crate::tui::history::{
|
||||
ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus,
|
||||
@@ -4301,6 +4301,109 @@ mod tests {
|
||||
app.add_message(success_tool_cell("web_search"));
|
||||
}
|
||||
|
||||
fn spacer_rows_after_transcript_cell(app: &App, target_cell: usize) -> usize {
|
||||
let mut saw_target = false;
|
||||
let mut spacer_rows = 0;
|
||||
for meta in app.viewport.transcript_cache.line_meta() {
|
||||
match meta {
|
||||
TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target_cell => {
|
||||
saw_target = true;
|
||||
spacer_rows = 0;
|
||||
}
|
||||
TranscriptLineMeta::Spacer { .. } if saw_target => spacer_rows += 1,
|
||||
TranscriptLineMeta::CellLine { .. } if saw_target => break,
|
||||
TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => {}
|
||||
}
|
||||
}
|
||||
spacer_rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_widget_breathes_between_groups_without_padding_tool_rows_at_any_width() {
|
||||
for (width, height) in [(40, 8), (120, 12)] {
|
||||
let mut app = create_test_app();
|
||||
app.low_motion = true;
|
||||
app.fancy_animations = false;
|
||||
app.transcript_spacing = TranscriptSpacing::Comfortable;
|
||||
|
||||
for turn in 0..4 {
|
||||
app.add_message(HistoryCell::User {
|
||||
content: format!("turn {turn}: inspect the release receipts"),
|
||||
});
|
||||
app.add_message(HistoryCell::Assistant {
|
||||
content: format!("I will inspect receipt group {turn}."),
|
||||
streaming: false,
|
||||
});
|
||||
app.add_message(success_tool_cell(&format!("read_{turn}")));
|
||||
app.add_message(success_tool_cell(&format!("verify_{turn}")));
|
||||
app.add_message(HistoryCell::Assistant {
|
||||
content: format!("receipt group {turn} is complete"),
|
||||
streaming: false,
|
||||
});
|
||||
}
|
||||
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
|
||||
let mut top_buf = Buffer::empty(area);
|
||||
ChatWidget::new(&mut app, area).render(area, &mut top_buf);
|
||||
|
||||
assert_eq!(app.viewport.last_transcript_top, 0, "width={width}");
|
||||
assert!(
|
||||
app.viewport.last_transcript_total > usize::from(height),
|
||||
"fixture must scroll at width={width}"
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_after_transcript_cell(&app, 0),
|
||||
1,
|
||||
"the top-level user turn needs a breathing row at width={width}"
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_after_transcript_cell(&app, 1),
|
||||
1,
|
||||
"answer to tool-group transition needs a breathing row at width={width}"
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_after_transcript_cell(&app, 2),
|
||||
0,
|
||||
"calls inside one tool group must stay compact at width={width}"
|
||||
);
|
||||
assert_eq!(
|
||||
spacer_rows_after_transcript_cell(&app, 3),
|
||||
1,
|
||||
"the completed tool group needs a breathing row at width={width}"
|
||||
);
|
||||
assert!(
|
||||
buffer_text(&top_buf, area).contains("turn 0"),
|
||||
"top scroll source drifted at width={width}"
|
||||
);
|
||||
|
||||
let total = app.viewport.last_transcript_total;
|
||||
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
|
||||
let mut tail_buf = Buffer::empty(area);
|
||||
ChatWidget::new(&mut app, area).render(area, &mut tail_buf);
|
||||
|
||||
assert_eq!(app.viewport.last_transcript_total, total, "width={width}");
|
||||
assert!(app.viewport.last_transcript_top > 0, "width={width}");
|
||||
assert!(
|
||||
buffer_text(&tail_buf, area).contains("receipt group 3 is complete"),
|
||||
"tail scroll lost the final source-backed cell at width={width}"
|
||||
);
|
||||
assert!(
|
||||
app.viewport
|
||||
.transcript_cache
|
||||
.line_meta()
|
||||
.iter()
|
||||
.all(|meta| match meta {
|
||||
TranscriptLineMeta::CellLine { cell_index, .. } => {
|
||||
*cell_index < app.history.len()
|
||||
}
|
||||
TranscriptLineMeta::Spacer { .. } => true,
|
||||
}),
|
||||
"spacing rows must not invent source-cell ownership at width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_flash_uses_original_index_map_for_collapsed_rows() {
|
||||
let history = vec![
|
||||
|
||||
+172
-11
@@ -12,12 +12,14 @@
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::qa_harness::harness::{Harness, make_sealed_workspace};
|
||||
use crate::qa_harness::keys;
|
||||
use crate::qa_harness::modes::mode;
|
||||
use sha2::{Digest, Sha256};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -25,7 +27,35 @@ const BOOT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const KEY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SKILL_SCAN_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const COMPOSER_READY_TEXT: &str = "Write a task";
|
||||
const SESSION_SHELL_MAX_WIDTH: u16 = 112;
|
||||
// Keep this geometry oracle in step with `tui::ui::frame::session_shell_area`.
|
||||
// Integration tests cannot import that private renderer helper, so the PTY
|
||||
// contract repeats only its three public-facing constants.
|
||||
const SESSION_SHELL_FLUID_WIDTH: u16 = 112;
|
||||
const SESSION_SHELL_GUTTER_STEP: u16 = 12;
|
||||
const SESSION_SHELL_MAX_SIDE_GUTTER: u16 = 16;
|
||||
|
||||
fn expected_session_shell_side_gutter(cols: u16) -> u16 {
|
||||
(cols.saturating_sub(SESSION_SHELL_FLUID_WIDTH) / SESSION_SHELL_GUTTER_STEP)
|
||||
.min(SESSION_SHELL_MAX_SIDE_GUTTER)
|
||||
}
|
||||
|
||||
fn expected_session_shell_width(cols: u16) -> u16 {
|
||||
cols.saturating_sub(expected_session_shell_side_gutter(cols).saturating_mul(2))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pty_geometry_oracle_keeps_wide_canvas_above_the_release_floor() {
|
||||
for (cols, expected_gutter) in [(140, 2), (200, 7), (240, 10)] {
|
||||
let gutter = expected_session_shell_side_gutter(cols);
|
||||
let width = expected_session_shell_width(cols);
|
||||
assert_eq!(gutter, expected_gutter, "{cols} columns");
|
||||
assert!(
|
||||
u32::from(width) * 100 >= u32::from(cols) * 85,
|
||||
"{cols} columns left only {}% usable",
|
||||
u32::from(width) * 100 / u32::from(cols)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Operate-mode composer placeholder, pinned as shipped copy since the
|
||||
/// goal-first placeholder rewording (bf0478395): the mode ramp legs below
|
||||
@@ -103,6 +133,28 @@ fn enter_launch_session(h: &mut Harness) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_native_telemetry_notice(h: &mut Harness, home: &Path) -> anyhow::Result<()> {
|
||||
h.wait_for_text("Anonymous usage counting", BOOT_TIMEOUT)?;
|
||||
h.wait_for_text("Keep anonymous usage on", KEY_TIMEOUT)?;
|
||||
h.wait_for_text("Disable anonymous usage", KEY_TIMEOUT)?;
|
||||
assert_eq!(
|
||||
h.terminal_modes().state(mode::ALT_SCREEN),
|
||||
Some(true),
|
||||
"the disclosure must be inside the native TUI, not printed before it\n{}",
|
||||
h.terminal_modes().debug_dump()
|
||||
);
|
||||
assert!(
|
||||
!h.frame().contains("[Y/n]"),
|
||||
"the retired shell questionnaire leaked into the native frame\n{}",
|
||||
h.debug_dump()
|
||||
);
|
||||
assert!(
|
||||
!home.join(".codewhale/telemetry/state.json").exists(),
|
||||
"telemetry armed before the disclosure was answered"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_newline_and_stash_chords_keep_stable_roles() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
@@ -288,7 +340,7 @@ fn foreground_at_text(
|
||||
fn composer_edge_rows(frame: &crate::qa_harness::Frame, placeholder: &str) -> (u16, u16) {
|
||||
let input_row = visible_row_with_text(frame, placeholder)
|
||||
.unwrap_or_else(|| panic!("composer placeholder {placeholder:?} missing"));
|
||||
let minimum_rule_cells = usize::from(frame.cols().min(SESSION_SHELL_MAX_WIDTH) / 2);
|
||||
let minimum_rule_cells = usize::from(expected_session_shell_width(frame.cols()) / 2);
|
||||
let is_rule = |row: u16| {
|
||||
frame
|
||||
.row(row)
|
||||
@@ -948,12 +1000,24 @@ web_search = true
|
||||
.size(40, 140)
|
||||
.spawn()?;
|
||||
|
||||
// The first-run telemetry notice is the first thing an interactive launch
|
||||
// shows, before the terminal enters raw mode. Enter keeps the plainly
|
||||
// disclosed default enabled; explicit negative answers disable it.
|
||||
h.wait_for_text("Keep anonymous usage counting on?", BOOT_TIMEOUT)?;
|
||||
// The first visible decision is a native Codewhale modal. Nothing is armed
|
||||
// until Enter keeps the plainly disclosed default enabled.
|
||||
wait_for_native_telemetry_notice(&mut h, ws.home())?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("Press Enter to continue", BOOT_TIMEOUT)?;
|
||||
let setup_state =
|
||||
codewhale_config::SetupState::load_from(&ws.home().join(".codewhale/setup_state.json"))
|
||||
.expect("native Keep choice must be recorded");
|
||||
assert!(setup_state.telemetry_accepted(codewhale_config::TELEMETRY_NOTICE_VERSION));
|
||||
assert!(
|
||||
!std::fs::read_to_string(ws.home().join(".codewhale/config.toml"))?
|
||||
.contains("telemetry = false"),
|
||||
"keeping the default must not rewrite the config as an opt-out"
|
||||
);
|
||||
assert!(
|
||||
ws.home().join(".codewhale/telemetry/state.json").exists(),
|
||||
"telemetry should arm only after the native Keep choice"
|
||||
);
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("Choose your language", BOOT_TIMEOUT)?;
|
||||
h.send(keys::key::enter())?;
|
||||
@@ -961,7 +1025,10 @@ web_search = true
|
||||
// keeps the current theme and advances. This is also the only PTY-level
|
||||
// execution of that step's event-loop wiring, so a hang here is a real
|
||||
// wiring bug, not a script gap.
|
||||
h.wait_for_text("Make It Yours", BOOT_TIMEOUT)?;
|
||||
// The shared view stack now renders above every onboarding state, so wait
|
||||
// for the actual native picker (not the Appearance backdrop underneath)
|
||||
// before sending its commit key.
|
||||
h.wait_for_text("theme · live preview", BOOT_TIMEOUT)?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("Know this workspace", BOOT_TIMEOUT)?;
|
||||
h.wait_for_text("Press 1/Y to trust and continue", BOOT_TIMEOUT)?;
|
||||
@@ -977,6 +1044,100 @@ web_search = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_telemetry_notice_disable_is_durable_and_never_arms() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let config_path = ws.home().join(".codewhale/config.toml");
|
||||
std::fs::write(&config_path, "# telemetry native-modal test\n")?;
|
||||
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
|
||||
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
|
||||
.env("CODEWHALE_TELEMETRY", "1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
])
|
||||
.size(40, 140)
|
||||
.spawn()?;
|
||||
|
||||
wait_for_native_telemetry_notice(&mut h, ws.home())?;
|
||||
h.send(keys::key::right())?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
|
||||
|
||||
let setup_state =
|
||||
codewhale_config::SetupState::load_from(&ws.home().join(".codewhale/setup_state.json"))
|
||||
.expect("native Disable choice must be recorded");
|
||||
assert!(setup_state.telemetry_declined(codewhale_config::TELEMETRY_NOTICE_VERSION));
|
||||
assert!(
|
||||
std::fs::read_to_string(&config_path)?.contains("telemetry = false"),
|
||||
"Disable must persist the root config opt-out"
|
||||
);
|
||||
assert!(
|
||||
!ws.home().join(".codewhale/telemetry/state.json").exists(),
|
||||
"Disable must not arm the current session"
|
||||
);
|
||||
assert!(
|
||||
!ws.home()
|
||||
.join(".codewhale/telemetry/install_id.json")
|
||||
.exists(),
|
||||
"Disable must not create an anonymous install identity"
|
||||
);
|
||||
|
||||
let _ = h.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_telemetry_notice_escape_defers_without_leaking_terminal_modes() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
let config_path = ws.home().join(".codewhale/config.toml");
|
||||
let config_before = "# leave this byte-identical\n";
|
||||
std::fs::write(&config_path, config_before)?;
|
||||
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
|
||||
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
|
||||
.env("CODEWHALE_TELEMETRY", "1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
])
|
||||
.size(40, 140)
|
||||
.spawn()?;
|
||||
|
||||
wait_for_native_telemetry_notice(&mut h, ws.home())?;
|
||||
h.send(keys::key::esc())?;
|
||||
assert_eq!(h.wait_for_exit(KEY_TIMEOUT), Some(0));
|
||||
assert_eq!(std::fs::read_to_string(&config_path)?, config_before);
|
||||
let setup_state_path = ws.home().join(".codewhale/setup_state.json");
|
||||
if let Some(state) = codewhale_config::SetupState::load_from(&setup_state_path) {
|
||||
assert!(state.needs_telemetry_notice(codewhale_config::TELEMETRY_NOTICE_VERSION));
|
||||
}
|
||||
assert!(!ws.home().join(".codewhale/telemetry/state.json").exists());
|
||||
assert!(
|
||||
h.terminal_modes().leaked_modes().is_empty(),
|
||||
"Esc left terminal modes enabled\n{}",
|
||||
h.terminal_modes().debug_dump()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression for #1085: after a turn exits through the error path, terminal
|
||||
/// origin/scroll-region state must not leave blank rows above the TUI.
|
||||
#[test]
|
||||
@@ -3764,12 +3925,12 @@ fn horizontal_rule_fills_session_shell(
|
||||
cols: u16,
|
||||
) -> bool {
|
||||
let text = frame.row(row);
|
||||
let shell_width = cols.min(SESSION_SHELL_MAX_WIDTH);
|
||||
let shell_start = (cols.saturating_sub(shell_width)) / 2;
|
||||
let shell_start = expected_session_shell_side_gutter(cols);
|
||||
let shell_width = expected_session_shell_width(cols);
|
||||
let shell_end = shell_start.saturating_add(shell_width);
|
||||
let chars = text.chars().collect::<Vec<_>>();
|
||||
// Frame rows omit trailing blank cells. A centered rail therefore ends at
|
||||
// `shell_end`, while a fluid rail still reaches the terminal edge.
|
||||
// Frame rows omit trailing blank cells. A responsive canvas therefore ends
|
||||
// at `shell_end`, including its modest symmetric wide-terminal gutters.
|
||||
UnicodeWidthStr::width(text.as_str()) == usize::from(shell_end)
|
||||
&& chars
|
||||
.iter()
|
||||
|
||||
+19
-16
@@ -1,11 +1,13 @@
|
||||
# Codewhale product telemetry
|
||||
|
||||
**Status for 0.9.6: anonymous usage counting is on by default and can be
|
||||
disabled immediately.** The first interactive launch says exactly what is
|
||||
counted and preselects "Keep on"; `n`, `no`, `off`, or `disable` records a
|
||||
durable opt-out. Headless surfaces follow the same documented default without
|
||||
pretending an interactive notice was shown. Every decline recorded by the
|
||||
former 0.9.4 opt-in notice remains off after upgrade.
|
||||
disabled immediately.** The first interactive launch summarizes what is counted,
|
||||
links the exact field-by-field schema, and preselects "Keep on" in a native
|
||||
startup modal. Arrow keys or Tab choose, Enter confirms, and `Y`/`N` are direct
|
||||
shortcuts. Telemetry remains unarmed until that choice. Headless surfaces follow
|
||||
the same documented default without pretending an interactive notice was shown.
|
||||
Every decline recorded by the former 0.9.4 opt-in notice remains off after
|
||||
upgrade.
|
||||
|
||||
Codewhale does not collect conversations, code, prompts, files, file/repo/branch
|
||||
names, model content, or credentials. It sends no per-turn or per-tool timeline.
|
||||
@@ -44,14 +46,15 @@ codewhale --telemetry false # the same kill switch, for one command
|
||||
**`telemetry = false` in the config file is the opt-out.** It is a floor:
|
||||
`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a
|
||||
setting you can undo by accident from a wrapper script is not a setting. It
|
||||
deletes the random install id, truncates every buffered event and every
|
||||
dry-run record, and writes a tombstone that a session already running re-checks
|
||||
before it appends anything and before it sends anything. If any part of that
|
||||
wipe fails, the tombstone is still there and the buffer is undrainable — a
|
||||
failed wipe fails closed. Every later run re-asserts the tombstone for as long
|
||||
as the setting stands, so it survives; turning telemetry back on means writing
|
||||
`telemetry = true` in the same place, and that is also what clears it. Nothing
|
||||
buffered before that point is ever sent.
|
||||
deletes the random install id, truncates every buffered event and every dry-run
|
||||
record, and writes a tombstone. Appends, identity/state writes, and delivery all
|
||||
share the wipe's ordering lock, so once opt-out returns no pre-opt-out write or
|
||||
POST remains in flight. If any part of that wipe fails, the tombstone is still
|
||||
there and the buffer is undrainable — a failed wipe fails closed. Every later
|
||||
run re-asserts the same tombstone for as long as the setting stands, so it
|
||||
survives; turning telemetry back on means writing `telemetry = true` in the same
|
||||
place, and that is also what clears it. Nothing buffered before that point is
|
||||
ever sent.
|
||||
|
||||
**The environment variable and the flag are kill switches, not opt-outs.**
|
||||
Telemetry is off for the run, nothing is written, nothing is sent — and
|
||||
@@ -80,7 +83,7 @@ Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `buffer.jsonl` | pending events, one JSON object per line |
|
||||
| `buffer.jsonl.lock` | a sibling lock file; only compaction takes it |
|
||||
| `buffer.jsonl.lock` | the sibling ordering lock shared by writes, delivery, arming, and wipe |
|
||||
| `dryrun.jsonl` | where batches go when the endpoint is configured empty |
|
||||
| `state.json` | the last app version seen and the last flush attempt |
|
||||
| `install_id.json` | the random install id and when it was minted |
|
||||
@@ -175,8 +178,8 @@ most once per flush point and never grows a queue.
|
||||
A surface emits by default unless the machine has a persistent opt-out or the
|
||||
run has a kill switch. The notice is only rendered on a TTY. So:
|
||||
|
||||
- **`tui`** — shows the disclosure before raw mode on first interactive launch,
|
||||
then follows the answer.
|
||||
- **`tui`** — enters the native TUI first, shows the disclosure as a startup
|
||||
modal, stays unarmed until the first interactive choice, then follows it.
|
||||
- **`exec`, `cli`, `app-server`, `mcp-server`, `serve`** — follow the documented
|
||||
default on a fresh home and every persistent/run-scoped opt-out on any home.
|
||||
- **Fleet workers never emit**, on any surface, by construction (`crates/tui/src/fleet/host.rs:1362`).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"_comment": "Ceiling for `#[allow(dead_code)]` across crates/. This number may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update",
|
||||
"_issue": "https://github.com/Hmbown/CodeWhale/issues/4785",
|
||||
"total": 451,
|
||||
"total": 448,
|
||||
"per_crate": {
|
||||
"config": 1,
|
||||
"tools": 2,
|
||||
"tui": 448
|
||||
"tui": 445
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +187,8 @@
|
||||
"document_kind": "codewhale.source_structure_budget",
|
||||
"large_module_threshold_lines": 1000,
|
||||
"max_large_module_count": 178,
|
||||
"max_module_lines": 17676,
|
||||
"max_total_owned_rust_lines": 683146,
|
||||
"max_module_lines": 17739,
|
||||
"max_total_owned_rust_lines": 684375,
|
||||
"schema_version": 1,
|
||||
"workspace_packages": [
|
||||
"codewhale-agent",
|
||||
|
||||
Reference in New Issue
Block a user