From 6f0befde55ee1ca6b48d4a16630fd4dc10c63d89 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Mon, 13 Jul 2026 22:01:36 +0200 Subject: [PATCH] feat(tui): add read-only settings view and transcript search Add a read-only Panel::Settings view (S key / command-palette entry) listing configured LLM providers, MCP servers, and sub-agent definitions across three tabs, sourced live from MetricsSnapshot. ProviderSummary is built via explicit whitelist field-copy so secret fields can never leak into the view even if ProviderEntry gains a new secret field later. Metrics are populated from three dedicated sites (startup, provider switch, config reload) rather than piggybacked on MCP lifecycle events, so the view stays live instead of stale/empty. Add a Ctrl+F transcript search overlay mirroring the existing Ctrl+R reverse-search interaction pattern: case-insensitive substring match against message content and tool names, highlight-and-scroll (not filter), next/prev match cycling, Esc restores the pre-search scroll position, Enter accepts. Routed at the top-level key decoder for deterministic mutual exclusion with Ctrl+R/command-palette/file-picker. Scroll-to-match derives from the live-rendered line layout so matches inside collapsed tool-output blocks scroll to a visible anchor rather than an assumed-expanded position, and the render cache is bypassed only for actually-matched messages to avoid a transcript-wide reparse on every keystroke while search is open. Both features are zeph-tui only, read-only, and introduce no new background task (MCP status already flows through the existing metrics channel; tool-output text is already inline in message content) and no new config.toml keys. Closes #6024 Closes #6023 --- CHANGELOG.md | 14 + crates/zeph-core/src/agent/builder.rs | 91 +++ crates/zeph-core/src/agent/config_reload.rs | 53 ++ crates/zeph-core/src/agent/provider_cmd.rs | 50 ++ crates/zeph-core/src/metrics.rs | 236 ++++++++ crates/zeph-tui/src/app/action.rs | 22 + crates/zeph-tui/src/app/draw.rs | 10 + crates/zeph-tui/src/app/keys.rs | 183 +++++- crates/zeph-tui/src/app/mod.rs | 9 + crates/zeph-tui/src/app/reducer.rs | 354 +++++++++++- crates/zeph-tui/src/app/state.rs | 18 +- crates/zeph-tui/src/app/tests.rs | 3 + crates/zeph-tui/src/command.rs | 22 +- crates/zeph-tui/src/metrics.rs | 7 +- crates/zeph-tui/src/widgets/chat.rs | 431 +++++++++++++- crates/zeph-tui/src/widgets/help.rs | 14 +- crates/zeph-tui/src/widgets/mod.rs | 2 + crates/zeph-tui/src/widgets/settings.rs | 532 ++++++++++++++++++ ...mmand_palette_rounded_border_snapshot.snap | 5 +- ...i__widgets__help__tests__help_default.snap | 12 +- .../zeph-tui/src/widgets/transcript_search.rs | 322 +++++++++++ src/runner.rs | 1 + 22 files changed, 2346 insertions(+), 45 deletions(-) create mode 100644 crates/zeph-tui/src/widgets/settings.rs create mode 100644 crates/zeph-tui/src/widgets/transcript_search.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a319f83c..b9f44e840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **TUI**: added a read-only settings view (`S` key or the `settings` command-palette + entry) listing configured LLM providers, MCP servers, and sub-agent definitions in + three tabs, sourced as a live snapshot from `MetricsSnapshot` (never re-parsed from + disk). Provider entries are built by explicit whitelist field-copy so secret fields + (API keys, Cocoon access hash, Candle HF token) can never leak into the view; the + agents tab shows configured definitions, distinct from the runtime subagent sidebar's + spawned instances. Read/list only in this release — edit-in-place is deferred (#6024). +- **TUI**: added `Ctrl+F` in-transcript search — search the currently visible + conversation by message content or tool name, with match highlighting and + scroll-to-match, `Ctrl+F`/`Up`/`Down` to cycle matches, `Esc` to cancel (restoring the + prior scroll position), and `Enter` to accept. Independent of the existing `Ctrl+R` + input-history search; a match inside a currently-collapsed tool-output block scrolls + to the message's visible anchor rather than a position that assumes the block is + expanded (#6023). - **Memory**: added MemGuard-inspired type-aware retrieval composition (`[memory.type_aware_compose]`, spec 064, #6086). Retrieval-only, fetch-time gate on `schedule_context_fetchers`: a new `FunctionalType` enum (`episodic` / `user_fact` / `behavioral_rule` / `reasoning_strategy` / diff --git a/crates/zeph-core/src/agent/builder.rs b/crates/zeph-core/src/agent/builder.rs index 93ce03138..76ccec4dc 100644 --- a/crates/zeph-core/src/agent/builder.rs +++ b/crates/zeph-core/src/agent/builder.rs @@ -762,6 +762,52 @@ impl Agent { self } + /// Populate the TUI settings view's `providers` and `agent_definitions` metrics + /// fields (issue #6024) from the current provider pool and sub-agent definitions. + /// + /// Must be called after [`with_provider_pool`][Self::with_provider_pool], + /// [`with_orchestration`][Self::with_orchestration] (if sub-agent definitions are + /// used), and [`with_metrics`][Self::with_metrics] — it is a `send_modify` against + /// the already-wired metrics channel, mirroring [`with_static_metrics`][Self::with_static_metrics]. + /// Re-run the same population at the two other sites documented on + /// [`crate::metrics::MetricsSnapshot::providers`]: `/provider` switch and config + /// hot-reload — this call only covers the unconditional startup population. + /// + /// # Panics + /// + /// Panics if called before [`with_metrics`][Self::with_metrics] (no sender is wired yet). + #[must_use] + pub fn with_settings_metrics(self) -> Self { + let active_provider_name = if self.runtime.config.active_provider_name.is_empty() { + self.provider.name().to_owned() + } else { + self.runtime.config.active_provider_name.clone() + }; + let providers = crate::metrics::ProviderSummary::build_pool( + &self.runtime.providers.provider_pool, + &active_provider_name, + ); + let agent_definitions = self + .services + .orchestration + .subagent_manager + .as_ref() + .map(|mgr| crate::metrics::AgentDefSummary::build_all(mgr.definitions())) + .unwrap_or_default(); + let tx = self + .runtime + .metrics + .metrics_tx + .as_ref() + .expect("with_settings_metrics must be called after with_metrics"); + let _span = tracing::info_span!("core.metrics.settings_snapshot").entered(); + tx.send_modify(|m| { + m.providers = providers; + m.agent_definitions = agent_definitions; + }); + self + } + /// Inject a shared provider override slot for runtime model switching (e.g. via ACP /// `set_session_config_option`). The agent checks and swaps the provider before each turn. #[must_use] @@ -3779,6 +3825,51 @@ mod tests { } } + /// Issue #6024, startup call site: `with_settings_metrics()` must populate + /// `MetricsSnapshot.providers`/`agent_definitions` from whatever provider pool / + /// subagent manager were wired earlier in the chain, using the running provider's + /// own name as the active marker when `active_provider_name` is unset (mirrors the + /// same fallback `provider_cmd.rs`'s `provider_list_as_string` uses). + #[test] + fn with_settings_metrics_populates_providers_from_pool() { + let (tx, rx) = tokio::sync::watch::channel(MetricsSnapshot::default()); + let snapshot = crate::agent::state::ProviderConfigSnapshot { + claude_api_key: None, + openai_api_key: None, + gemini_api_key: None, + compatible_api_keys: std::collections::HashMap::new(), + llm_request_timeout_secs: 30, + embedding_model: String::new(), + gonka_private_key: None, + gonka_address: None, + cocoon_access_hash: None, + }; + let _ = make_agent() + .with_metrics(tx) + .with_provider_pool( + vec![ProviderEntry { + name: Some("mock".into()), + default: true, + ..Default::default() + }], + snapshot, + ) + .with_settings_metrics(); + + let s = rx.borrow(); + assert_eq!(s.providers.len(), 1); + assert_eq!(s.providers[0].name, "mock"); + assert!( + s.providers[0].active, + "active_provider_name is unset, so the running MockProvider's own name (\"mock\") \ + must be used as the active marker fallback" + ); + assert!( + s.agent_definitions.is_empty(), + "no subagent_manager was wired, so agent_definitions must be empty, not panic" + ); + } + #[test] fn default_speculation_engine_is_none() { let agent = make_agent(); diff --git a/crates/zeph-core/src/agent/config_reload.rs b/crates/zeph-core/src/agent/config_reload.rs index 5878b03d4..b6969b615 100644 --- a/crates/zeph-core/src/agent/config_reload.rs +++ b/crates/zeph-core/src/agent/config_reload.rs @@ -11,6 +11,7 @@ use super::{Agent, resolve_context_budget}; use crate::channel::Channel; use crate::config::Config; use crate::context::ContextBudget; +use zeph_llm::provider::LlmProvider as _; impl Agent { #[allow(clippy::too_many_lines)] @@ -154,6 +155,32 @@ impl Agent { .clone_from(&config.hooks.turn_complete); // file_changed_hooks require watcher restart to take effect — skipped here. + // Re-derive the settings-view provider/agent-definition lists (issue #6024): a + // hot-reload can add, remove, or edit `[[llm.providers]]` entries or sub-agent + // definitions, and — per the same rationale as `apply_provider_switch_metrics` + // — `providers`/`agent_definitions` are never refreshed by the MCP-lifecycle-driven + // `update_mcp_metrics`, so they must be re-emitted explicitly here. + let active_provider_name = if self.runtime.config.active_provider_name.is_empty() { + self.provider.name().to_owned() + } else { + self.runtime.config.active_provider_name.clone() + }; + let providers = crate::metrics::ProviderSummary::build_pool( + &self.runtime.providers.provider_pool, + &active_provider_name, + ); + let agent_definitions = self + .services + .orchestration + .subagent_manager + .as_ref() + .map(|mgr| crate::metrics::AgentDefSummary::build_all(mgr.definitions())) + .unwrap_or_default(); + self.update_metrics(|m| { + m.providers = providers; + m.agent_definitions = agent_definitions; + }); + tracing::info!("config reloaded"); } /// Load config from disk, apply plugin overlays, validate, and warn on shell divergence. @@ -342,4 +369,30 @@ mod tests { "prior runtime state must be preserved when the reloaded config fails validate()" ); } + + // ── Settings-view metrics refresh on hot-reload (issue #6024) ────────────────── + + #[test] + fn reload_config_repopulates_settings_metrics() { + let config = Config::default(); + let (_dir, path) = write_config(&config); + + let mut agent = QuickTestAgent::minimal("ok").agent; + agent.runtime.lifecycle.plugins_dir = std::path::PathBuf::new(); + agent.runtime.lifecycle.config_path = Some(path); + agent.runtime.providers.provider_pool = vec![zeph_config::ProviderEntry { + name: Some("fast".to_owned()), + default: true, + ..zeph_config::ProviderEntry::default() + }]; + let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default()); + agent.runtime.metrics.metrics_tx = Some(tx); + + agent.reload_config(); + + let snapshot = rx.borrow(); + assert_eq!(snapshot.providers.len(), 1); + assert_eq!(snapshot.providers[0].name, "fast"); + assert!(snapshot.providers[0].default); + } } diff --git a/crates/zeph-core/src/agent/provider_cmd.rs b/crates/zeph-core/src/agent/provider_cmd.rs index 446de3e60..3ce53a0bd 100644 --- a/crates/zeph-core/src/agent/provider_cmd.rs +++ b/crates/zeph-core/src/agent/provider_cmd.rs @@ -367,11 +367,21 @@ impl Agent { .and_then(|c| c.generation.top_p.map(|v| v as f32)); let switched_model = self.runtime.config.model_name.clone(); let name = configured_name.to_owned(); + // Re-derive the settings-view provider list (issue #6024) so its `active` marker + // reflects the new provider — `providers` is populated at dedicated call sites + // (startup, here, config reload), never from the MCP-lifecycle-driven + // `update_mcp_metrics`, so it stays correct even when the switch happens before + // any MCP event ever fires. + let providers = crate::metrics::ProviderSummary::build_pool( + &self.runtime.providers.provider_pool, + &name, + ); self.update_metrics(|m| { m.provider_name.clone_from(&name); m.model_name = switched_model; m.provider_temperature = provider_temperature; m.provider_top_p = provider_top_p; + m.providers = providers; }); } @@ -717,6 +727,46 @@ mod tests { assert_eq!(agent.runtime.config.model_name, "llama3.2"); } + /// Issue #6024 (S2 regression scenario): after a `/provider` switch, the settings + /// view's `providers` list must re-derive with the `active` marker moved to the + /// newly-selected provider, not stay stale on the previously-active one. This is + /// the exact wiring `apply_provider_switch_metrics` exists to keep correct. + #[tokio::test] + async fn provider_switch_updates_settings_metrics_active_marker() { + let entry_a = make_entry("ollama", ProviderKind::Ollama, Some("qwen3:8b")); + let entry_b = make_entry("ollama2", ProviderKind::Ollama, Some("llama3.2")); + let snapshot = ollama_snapshot(); + let provider_a = + crate::provider_factory::build_provider_for_switch(&entry_a, &snapshot, None).unwrap(); + + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + let mut agent = Agent::new(provider_a, channel, registry, None, 5, executor); + agent.runtime.providers.provider_pool = vec![entry_a, entry_b]; + agent.runtime.providers.provider_config_snapshot = Some(snapshot); + + let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default()); + agent.runtime.metrics.metrics_tx = Some(tx); + + let out = agent.handle_provider_command_as_string("ollama2").await; + assert!(out.contains("Switched to provider:"), "unexpected: {out}"); + + let snapshot = rx.borrow(); + assert_eq!(snapshot.providers.len(), 2); + let active_names: Vec<&str> = snapshot + .providers + .iter() + .filter(|p| p.active) + .map(|p| p.name.as_str()) + .collect(); + assert_eq!( + active_names, + vec!["ollama2"], + "exactly the newly-switched-to provider must be marked active" + ); + } + #[tokio::test] async fn provider_status_no_metrics() { let mut qa = QuickTestAgent::minimal("ok"); diff --git a/crates/zeph-core/src/metrics.rs b/crates/zeph-core/src/metrics.rs index 1ad86743e..7aac213bf 100644 --- a/crates/zeph-core/src/metrics.rs +++ b/crates/zeph-core/src/metrics.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use std::collections::VecDeque; +use std::sync::Arc; use tokio::sync::watch; use zeph_common::SecurityEventCategory; @@ -129,6 +130,170 @@ pub struct McpServerStatus { pub output_schemas_dropped: usize, } +/// Read-only, secret-free summary of a single `[[llm.providers]]` entry for the TUI +/// settings view (issue #6024). +/// +/// Built by **explicit whitelist-copying** of safe fields from `ProviderEntry` — never +/// by cloning the entry and redacting afterward — so a future secret field added to +/// `ProviderEntry` cannot silently leak through this struct (NFR-002/FR-010 of +/// `/specs/061-tui-settings-editor-parity/spec.md`). Secret-bearing fields +/// (`api_key`, `cocoon_access_hash`, `candle.hf_token`) are intentionally absent. +#[derive(Debug, Clone, Default)] +pub struct ProviderSummary { + /// Effective provider name (`ProviderEntry::effective_name`). + pub name: String, + /// Provider backend type as a lowercase string (e.g. `"claude"`, `"openai"`). + pub provider_type: String, + /// Configured model identifier, if any. + pub model: Option, + /// API base URL with any embedded userinfo credentials redacted. + pub base_url: Option, + /// Configured max output tokens, if any. + pub max_tokens: Option, + /// Configured embedding model, if any. + pub embedding_model: Option, + /// Configured STT model, if any. + pub stt_model: Option, + /// Whether this entry is the configured default chat provider. + pub default: bool, + /// Whether this entry is the currently active provider for the running agent. + pub active: bool, +} + +impl ProviderSummary { + /// Build the whitelist-copied, secret-free summary list for the settings view. + /// + /// Explicitly copies only the safe field whitelist from each `ProviderEntry` rather + /// than cloning the entry and redacting it afterward, so a future secret field added + /// to `ProviderEntry` cannot silently leak through this struct — see the type-level + /// doc for the full rationale. `base_url` has any embedded userinfo credentials + /// (`https://user:pass@host`) stripped via [`zeph_db::redact_url`]. + /// + /// `active_provider_name` should already be resolved to the effective active name + /// (callers typically fall back to the running provider's own name when the config's + /// `active_provider_name` field is empty, mirroring `provider_cmd.rs`'s own pattern). + /// + /// # Examples + /// + /// ``` + /// use zeph_config::ProviderEntry; + /// use zeph_core::metrics::ProviderSummary; + /// + /// let entry = ProviderEntry { + /// name: Some("fast".to_owned()), + /// model: Some("gpt-4o-mini".to_owned()), + /// default: true, + /// api_key: Some("sk-should-never-appear".to_owned()), + /// ..ProviderEntry::default() + /// }; + /// let summaries = ProviderSummary::build_pool(&[entry], "fast"); + /// assert_eq!(summaries[0].name, "fast"); + /// assert!(summaries[0].active); + /// ``` + #[must_use] + pub fn build_pool( + pool: &[zeph_config::ProviderEntry], + active_provider_name: &str, + ) -> Arc<[Self]> { + pool.iter() + .map(|entry| { + let name = entry.effective_name(); + let active = name.eq_ignore_ascii_case(active_provider_name); + Self { + provider_type: entry.provider_type.as_str().to_owned(), + model: entry.model.clone(), + base_url: entry + .base_url + .as_ref() + .map(|u| zeph_db::redact_url(u).unwrap_or_else(|| u.clone())), + max_tokens: entry.max_tokens, + embedding_model: entry.embedding_model.clone(), + stt_model: entry.stt_model.clone(), + default: entry.default, + active, + name, + } + }) + .collect() + } +} + +/// Read-only summary of a sub-agent **definition** (template) for the TUI settings +/// view (issue #6024), distinct from a runtime spawned instance ([`SubAgentMetrics`]). +#[derive(Debug, Clone, Default)] +pub struct AgentDefSummary { + /// Agent definition name. + pub name: String, + /// Human-readable description from the definition's frontmatter. + pub description: String, + /// Effective model spec as a string (`"inherit"` or a named provider), if any. + pub model: Option, + /// Definition source, e.g. `"project/my-agent.md"`. + pub source: Option, + /// Stringified memory scope (`"user"`, `"project"`, `"local"`), if any. + pub memory_scope: Option, + /// Human-readable summary of the tool access policy (e.g. `"allow: shell, Read"`). + pub tools_summary: String, +} + +impl AgentDefSummary { + /// Build the summary list for the settings view's Agents tab from the loaded + /// sub-agent **definitions** (`.zeph/agents/*.md` templates), not runtime instances. + /// + /// # Examples + /// + /// ``` + /// use zeph_subagent::SubAgentDef; + /// use zeph_core::metrics::AgentDefSummary; + /// + /// let def = SubAgentDef::for_test("reviewer"); + /// let summaries = AgentDefSummary::build_all(&[def]); + /// assert_eq!(summaries[0].name, "reviewer"); + /// ``` + #[must_use] + pub fn build_all(defs: &[zeph_subagent::SubAgentDef]) -> Arc<[Self]> { + defs.iter().map(Self::from_def).collect() + } + + fn from_def(def: &zeph_subagent::SubAgentDef) -> Self { + Self { + name: def.name.clone(), + description: def.description.clone(), + model: def.model.as_ref().map(|m| m.as_str().to_owned()), + source: def.source.clone(), + memory_scope: def.memory.map(|scope| { + match scope { + zeph_config::MemoryScope::User => "user", + zeph_config::MemoryScope::Project => "project", + zeph_config::MemoryScope::Local => "local", + // MemoryScope is #[non_exhaustive]; fall back to Debug for any future variant. + other => return format!("{other:?}").to_lowercase(), + } + .to_owned() + }), + tools_summary: tools_summary(&def.tools, &def.disallowed_tools), + } + } +} + +/// Render a [`zeph_config::ToolPolicy`] plus its extra denylist as a short human-readable +/// string for the settings view (e.g. `"allow: shell, Read (except: Write)"`). +fn tools_summary(policy: &zeph_config::ToolPolicy, disallowed: &[String]) -> String { + use std::fmt::Write as _; + + let mut summary = match policy { + zeph_config::ToolPolicy::InheritAll => "inherit all".to_owned(), + zeph_config::ToolPolicy::AllowList(list) => format!("allow: {}", list.join(", ")), + zeph_config::ToolPolicy::DenyList(list) => format!("deny: {}", list.join(", ")), + // ToolPolicy is #[non_exhaustive]; fall back to Debug for any future variant. + other => format!("{other:?}"), + }; + if !disallowed.is_empty() { + let _ = write!(summary, " (except: {})", disallowed.join(", ")); + } + summary +} + /// Bayesian confidence data for a single skill, used by TUI confidence bar. #[derive(Debug, Clone, Default)] pub struct SkillConfidence { @@ -432,6 +597,16 @@ pub struct MetricsSnapshot { pub cocoon_model_count: usize, /// TON wallet balance in TON units. `None` when unknown or Cocoon not configured. pub cocoon_ton_balance: Option, + /// Secret-free summaries of configured `[[llm.providers]]` entries, for the TUI + /// settings view's Providers tab (issue #6024). Refreshed at startup, on `/provider` + /// switch, and on config hot-reload — never derived from `update_mcp_metrics`, whose + /// MCP-lifecycle-only trigger would leave this stale or empty when no MCP servers are + /// configured. `Arc<[T]>` keeps this snapshot cheap to clone on the metrics watch channel. + pub providers: Arc<[ProviderSummary]>, + /// Summaries of configured sub-agent **definitions** (templates), for the TUI settings + /// view's Agents tab (issue #6024) — distinct from `sub_agents` (runtime instances). + /// Refreshed at the same sites as `providers`. + pub agent_definitions: Arc<[AgentDefSummary]>, } /// Snapshot of a single in-flight background shell run for TUI display. @@ -1116,4 +1291,65 @@ mod tests { recorder.observe_turn_duration(Duration::from_secs(3)); recorder.observe_tool_execution(Duration::from_millis(100)); } + + // ── ProviderSummary / AgentDefSummary whitelist-copy (issue #6024) ───────────── + + #[test] + fn provider_summary_never_carries_secret_fields() { + // SC-003: seed a provider with every secret-bearing field and assert none of + // their values are reachable anywhere on the resulting ProviderSummary — the + // struct has no fields that could hold them, by construction. + let entry = zeph_config::ProviderEntry { + name: Some("leaky".to_owned()), + api_key: Some("sk-SUPERSECRET".to_owned()), + cocoon_access_hash: Some("hash-SUPERSECRET".to_owned()), + candle: Some(zeph_config::CandleInlineConfig { + hf_token: Some("hf_SUPERSECRET".to_owned()), + ..Default::default() + }), + ..zeph_config::ProviderEntry::default() + }; + let summaries = ProviderSummary::build_pool(&[entry], "leaky"); + assert_eq!(summaries.len(), 1); + let debug = format!("{:?}", summaries[0]); + assert!(!debug.contains("SUPERSECRET")); + } + + #[test] + fn provider_summary_marks_active_case_insensitively() { + let entry = zeph_config::ProviderEntry { + name: Some("Fast".to_owned()), + ..zeph_config::ProviderEntry::default() + }; + let summaries = ProviderSummary::build_pool(&[entry], "fast"); + assert!(summaries[0].active); + } + + #[test] + fn provider_summary_redacts_base_url_userinfo() { + let entry = zeph_config::ProviderEntry { + name: Some("compat".to_owned()), + base_url: Some("https://user:secret@example.com/v1".to_owned()), + ..zeph_config::ProviderEntry::default() + }; + let summaries = ProviderSummary::build_pool(&[entry], "compat"); + let base_url = summaries[0].base_url.as_deref().unwrap_or_default(); + assert!(!base_url.contains("secret")); + assert!(base_url.contains("example.com")); + } + + #[test] + fn provider_summary_empty_pool_produces_empty_slice() { + let summaries = ProviderSummary::build_pool(&[], ""); + assert!(summaries.is_empty()); + } + + #[test] + fn agent_def_summary_maps_definition_fields() { + let def = zeph_subagent::SubAgentDef::for_test("reviewer"); + let summaries = AgentDefSummary::build_all(&[def]); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].name, "reviewer"); + assert_eq!(summaries[0].tools_summary, "inherit all"); + } } diff --git a/crates/zeph-tui/src/app/action.rs b/crates/zeph-tui/src/app/action.rs index d1b2fd19a..22bd9ef73 100644 --- a/crates/zeph-tui/src/app/action.rs +++ b/crates/zeph-tui/src/app/action.rs @@ -183,6 +183,28 @@ pub(crate) enum Action { /// Close the reverse-search overlay. CloseReverseSearch, + // ── Transcript search (issue #6023) ──────────────────────────────────────── + /// Open the `Ctrl+F` transcript-search overlay. + OpenTranscriptSearch, + /// Type in the transcript-search query field. + TranscriptSearchInput(PaletteEdit), + /// Advance to the next match, wrapping. + TranscriptSearchNext, + /// Move to the previous match, wrapping. + TranscriptSearchPrev, + /// Accept the current match: close the overlay, leaving the scroll position. + TranscriptSearchAccept, + /// Close the overlay without accepting, restoring the pre-search scroll position. + CloseTranscriptSearch, + + // ── Settings view (issue #6024) ───────────────────────────────────────────── + /// Switch the settings view to the next tab (Providers → MCP → Agents), wrapping. + SettingsTabNext, + /// Switch the settings view to the previous tab, wrapping. + SettingsTabPrev, + /// Move the active tab's row selection up or down. + SettingsSelectMove(VertDir), + // ── Confirm dialog ───────────────────────────────────────────────────────── /// Respond to the current confirm dialog (true = yes, false = no). ConfirmRespond(bool), diff --git a/crates/zeph-tui/src/app/draw.rs b/crates/zeph-tui/src/app/draw.rs index eeae62fe5..c3225564c 100644 --- a/crates/zeph-tui/src/app/draw.rs +++ b/crates/zeph-tui/src/app/draw.rs @@ -108,6 +108,10 @@ impl App { widgets::reverse_search::render(state, &history, frame, layout.input, &self.theme); } + if let Some(state) = &self.transcript_search { + widgets::transcript_search::render(state, frame, layout.input, &self.theme); + } + // Render toasts above the input, below modal overlays. if self.motion != zeph_config::Motion::Off && self.delights.toasts { widgets::toast::render(&self.toasts, frame, layout.chat, &self.theme, now); @@ -338,6 +342,12 @@ impl App { ); } + // Overlay the read-only settings view over the subagents slot when `S` is + // active (issue #6024), mirroring the Fleet/Durable overlay precedent. + if self.active_panel == Panel::Settings { + widgets::settings::render(&self.metrics, &mut self.settings, frame, area, &self.theme); + } + // Overlay task registry over the subagents slot when `/tasks` is toggled. if self.show_task_panel { if self.task_supervisor.is_some() { diff --git a/crates/zeph-tui/src/app/keys.rs b/crates/zeph-tui/src/app/keys.rs index fd29940b5..7778321a9 100644 --- a/crates/zeph-tui/src/app/keys.rs +++ b/crates/zeph-tui/src/app/keys.rs @@ -64,6 +64,16 @@ impl App { return Self::decode_file_picker_key(key); } + // Transcript search (issue #6023): routed mode-agnostically at the top level + // (unlike reverse-search, which is Insert-only) so Ctrl+F works whether it was + // opened from Normal or Insert mode, and so the two overlays are mutually + // exclusive — while this one is open, all keys route here, so Ctrl+R cannot + // open reverse-search underneath it (the inverse is guarded by the Ctrl+F + // open-arms' `reverse_search.is_none()` check). + if self.transcript_search.is_some() { + return Self::decode_transcript_search_key(key); + } + match self.sessions.current().input_mode { InputMode::Normal => self.decode_normal_key(key), InputMode::Insert => self.decode_insert_key(key), @@ -748,11 +758,32 @@ impl App { None } + /// Decode a key event while the read-only `Settings` panel has focus (issue #6024). + /// Mirrors [`decode_subagent_panel_key`]: `Left`/`Right`/`h`/`l` switch tabs, + /// `j`/`k`/`Down`/`Up` move the row selection, `Esc` returns to `Chat`. No mutation + /// keys — v1 is read-only. + fn decode_settings_panel_key(&self, key: KeyEvent) -> Option { + if self.active_panel != Panel::Settings { + return None; + } + match key.code { + KeyCode::Left | KeyCode::Char('h') => Some(Action::SettingsTabPrev), + KeyCode::Right | KeyCode::Char('l') => Some(Action::SettingsTabNext), + KeyCode::Down | KeyCode::Char('j') => Some(Action::SettingsSelectMove(VertDir::Down)), + KeyCode::Up | KeyCode::Char('k') => Some(Action::SettingsSelectMove(VertDir::Up)), + KeyCode::Esc => Some(Action::SetActivePanel(Panel::Chat)), + _ => None, + } + } + #[allow(clippy::too_many_lines)] fn decode_normal_key(&self, key: KeyEvent) -> Option { if let Some(a) = self.decode_subagent_panel_key(key) { return Some(a); } + if let Some(a) = self.decode_settings_panel_key(key) { + return Some(a); + } match key.code { KeyCode::Esc if self.is_agent_busy() => Some(Action::CancelAgent), KeyCode::Char('q') => Some(Action::Quit), @@ -772,10 +803,22 @@ impl App { KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(Action::ClearTranscript) } + // Ctrl+F (transcript search, issue #6023) must be checked BEFORE the plain + // `f`->Fleet arm below, which is itself guarded with `!CONTROL` so it no + // longer swallows Ctrl+F (mirrors the Ctrl+L precedent above). + KeyCode::Char('f') + if key.modifiers.contains(KeyModifiers::CONTROL) + && self.reverse_search.is_none() => + { + Some(Action::OpenTranscriptSearch) + } KeyCode::Char('?') => Some(Action::SetHelp(true)), KeyCode::Char('p') => Some(Action::TogglePlanView), - KeyCode::Char('f') => Some(Action::SetActivePanel(Panel::Fleet)), + KeyCode::Char('f') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Action::SetActivePanel(Panel::Fleet)) + } KeyCode::Char('D') => Some(Action::SetActivePanel(Panel::Durable)), + KeyCode::Char('S') => Some(Action::SetActivePanel(Panel::Settings)), KeyCode::Char('a') => Some(Action::SetActivePanel(Panel::SubAgents)), KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(Action::CopyLastAssistant) @@ -998,6 +1041,17 @@ impl App { None } } + // Ctrl+F (transcript search, issue #6023): must precede the `Char(c)` + // catch-all below, which has no modifier guard and would otherwise insert + // a literal 'f' into the input. Mutual exclusion with Ctrl+R mirrors the + // arm above. + KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if self.slash_autocomplete.is_none() { + Some(Action::OpenTranscriptSearch) + } else { + None + } + } KeyCode::Char('@') => Some(Action::OpenFilePicker), KeyCode::Char(c) => Some(Action::InsertChar(c)), _ => None, @@ -1036,6 +1090,26 @@ impl App { } } + /// Decode a key event while the transcript-search overlay is open (issue #6023). + /// Mirrors [`decode_reverse_search_key`]: `Esc` cancels, `Enter` accepts, + /// `Ctrl+F`/`Down` advance to the next match, `Up` moves to the previous match. + fn decode_transcript_search_key(key: KeyEvent) -> Option { + let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let is_alt = key.modifiers.contains(KeyModifiers::ALT); + match key.code { + KeyCode::Esc => Some(Action::CloseTranscriptSearch), + KeyCode::Enter => Some(Action::TranscriptSearchAccept), + KeyCode::Char('f') if is_ctrl => Some(Action::TranscriptSearchNext), + KeyCode::Down => Some(Action::TranscriptSearchNext), + KeyCode::Up => Some(Action::TranscriptSearchPrev), + KeyCode::Backspace => Some(Action::TranscriptSearchInput(PaletteEdit::PopChar)), + KeyCode::Char(c) if !is_ctrl && !is_alt => { + Some(Action::TranscriptSearchInput(PaletteEdit::PushChar(c))) + } + _ => None, + } + } + pub(super) fn handle_history_up(&mut self) { self.sessions.current_mut().paste_state = None; if self.sessions.current().input.is_empty() @@ -1300,4 +1374,111 @@ mod tests { let msg = &app.sessions.current().messages.last().unwrap().content; assert!(msg.contains("not available")); } + + // ── Ctrl+F / Ctrl+R key-decode routing (issue #6023) ──────────────────────── + // + // SC-001 of spec 060 explicitly asks for a regression test proving Ctrl+R is + // unaffected by the new Ctrl+F binding, plus the edge case of the two overlays + // being mutually exclusive. These decode `KeyEvent`s directly through the private + // `decode_key` entry point (accessible from this submodule) rather than the full + // `handle_key` -> `reduce` -> `run_effects` pipeline, isolating the routing logic. + + fn ctrl_key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + fn plain_key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + } + + #[test] + fn ctrl_f_in_normal_mode_opens_transcript_search_not_fleet() { + let (mut app, _user_rx, _agent_tx) = make_app(); + app.sessions.current_mut().input_mode = InputMode::Normal; + + let action = app.decode_key(ctrl_key('f')); + + assert_eq!(action, Some(Action::OpenTranscriptSearch)); + } + + #[test] + fn plain_f_in_normal_mode_still_opens_fleet() { + // Regression: the `!CONTROL` guard added to the plain-`f` arm must not affect + // unmodified `f` — it must still open the Fleet panel exactly as before #6023. + let (mut app, _user_rx, _agent_tx) = make_app(); + app.sessions.current_mut().input_mode = InputMode::Normal; + + let action = app.decode_key(plain_key('f')); + + assert_eq!(action, Some(Action::SetActivePanel(Panel::Fleet))); + } + + #[test] + fn ctrl_f_in_insert_mode_opens_transcript_search_not_literal_char() { + let (mut app, _user_rx, _agent_tx) = make_app(); + app.sessions.current_mut().input_mode = InputMode::Insert; + + let action = app.decode_key(ctrl_key('f')); + + assert_eq!( + action, + Some(Action::OpenTranscriptSearch), + "must not fall through to the InsertChar('f') catch-all" + ); + } + + #[test] + fn ctrl_r_in_insert_mode_still_opens_reverse_search() { + // SC-001 regression: Ctrl+R behavior must be completely unaffected by #6023. + let (mut app, _user_rx, _agent_tx) = make_app(); + app.sessions.current_mut().input_mode = InputMode::Insert; + + let action = app.decode_key(ctrl_key('r')); + + assert_eq!(action, Some(Action::OpenReverseSearch)); + } + + #[test] + fn ctrl_f_is_noop_while_reverse_search_is_open() { + // Mutual exclusion (spec 060 edge-case table): opening transcript search while + // ReverseSearchState is already open must not succeed. + let (mut app, _user_rx, _agent_tx) = make_app(); + app.sessions.current_mut().input_mode = InputMode::Insert; + app.reverse_search = Some(crate::widgets::reverse_search::ReverseSearchState::new(&[])); + + let action = app.decode_key(ctrl_key('f')); + + assert_eq!( + action, None, + "Ctrl+F must not open transcript search while reverse-search is active" + ); + } + + #[test] + fn ctrl_r_is_noop_while_transcript_search_is_open() { + // Inverse of the above: once transcript search is open, ALL keys route to its + // own decoder (top-level `decode_key` short-circuit), so Ctrl+R cannot open + // reverse-search underneath it. + let (mut app, _user_rx, _agent_tx) = make_app(); + app.transcript_search = + Some(crate::widgets::transcript_search::TranscriptSearchState::new(0)); + + let action = app.decode_key(ctrl_key('r')); + + assert_eq!( + action, None, + "Ctrl+R must not open reverse-search while transcript search is active" + ); + } + + #[test] + fn esc_closes_transcript_search_when_open() { + let (mut app, _user_rx, _agent_tx) = make_app(); + app.transcript_search = + Some(crate::widgets::transcript_search::TranscriptSearchState::new(0)); + + let action = app.decode_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(action, Some(Action::CloseTranscriptSearch)); + } } diff --git a/crates/zeph-tui/src/app/mod.rs b/crates/zeph-tui/src/app/mod.rs index a7ace9bdd..12e97fd36 100644 --- a/crates/zeph-tui/src/app/mod.rs +++ b/crates/zeph-tui/src/app/mod.rs @@ -71,6 +71,8 @@ pub enum Panel { Fleet, /// The durable execution journal panel (side column). Durable, + /// The read-only settings view: LLM providers, MCP servers, and agent definitions. + Settings, } /// Discriminates what the main chat area is currently displaying. @@ -400,6 +402,13 @@ pub struct App { file_index: Option, slash_autocomplete: Option, reverse_search: Option, + /// `Ctrl+F` transcript-search overlay state (issue #6023). `None` when closed. + /// + /// Fully independent of `reverse_search` — no shared mutable state — but the two + /// overlays are mutually exclusive at the key-routing level (`decode_key`). + pub(crate) transcript_search: Option, + /// Read-only settings view state: active tab and per-tab selection (issue #6024). + pub(crate) settings: crate::widgets::settings::SettingsViewState, pub should_quit: bool, user_input_tx: mpsc::Sender, agent_event_rx: mpsc::Receiver, diff --git a/crates/zeph-tui/src/app/reducer.rs b/crates/zeph-tui/src/app/reducer.rs index f3ec75efa..66ffc9603 100644 --- a/crates/zeph-tui/src/app/reducer.rs +++ b/crates/zeph-tui/src/app/reducer.rs @@ -115,10 +115,12 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec { Panel::Resources => Panel::SubAgents, Panel::SubAgents | Panel::Tasks => Panel::Fleet, Panel::Fleet => Panel::Durable, - Panel::Durable => Panel::Chat, + Panel::Durable => Panel::Settings, + Panel::Settings => Panel::Chat, }; - // Routed through set_active_panel so cycling into SubAgents/Fleet/Durable - // clears show_task_panel the same way every other entry point does (#6061). + // Routed through set_active_panel so cycling into SubAgents/Fleet/Durable/ + // Settings clears show_task_panel the same way every other entry point does + // (#6061). app.set_active_panel(next); vec![] } @@ -570,6 +572,92 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec { vec![] } + // ── Transcript search (issue #6023) ───────────────────────────────────── + Action::OpenTranscriptSearch => { + let pre_search_scroll_offset = app.scroll_offset(); + app.transcript_search = Some( + crate::widgets::transcript_search::TranscriptSearchState::new( + pre_search_scroll_offset, + ), + ); + vec![] + } + Action::TranscriptSearchInput(edit) => { + let messages = app.visible_messages(); + if let Some(ref mut s) = app.transcript_search { + match edit { + PaletteEdit::PushChar(c) => s.push_char(c, &messages), + PaletteEdit::PopChar => s.pop_char(&messages), + } + } + if let Some(target) = app.transcript_search.as_ref().and_then( + crate::widgets::transcript_search::TranscriptSearchState::selected_message_index, + ) && let Some(offset) = app.line_offset_of_message(target) + { + app.begin_scroll(offset); + } + vec![] + } + Action::TranscriptSearchNext => { + if let Some(ref mut s) = app.transcript_search { + s.select_next(); + } + if let Some(target) = app.transcript_search.as_ref().and_then( + crate::widgets::transcript_search::TranscriptSearchState::selected_message_index, + ) && let Some(offset) = app.line_offset_of_message(target) + { + app.begin_scroll(offset); + } + vec![] + } + Action::TranscriptSearchPrev => { + if let Some(ref mut s) = app.transcript_search { + s.select_previous(); + } + if let Some(target) = app.transcript_search.as_ref().and_then( + crate::widgets::transcript_search::TranscriptSearchState::selected_message_index, + ) && let Some(offset) = app.line_offset_of_message(target) + { + app.begin_scroll(offset); + } + vec![] + } + Action::TranscriptSearchAccept => { + // Leave the transcript scrolled at the accepted match (FR-007) — only the + // overlay closes, scroll_offset is left as-is. + app.transcript_search = None; + vec![] + } + Action::CloseTranscriptSearch => { + let restore = app + .transcript_search + .as_ref() + .map(|s| s.pre_search_scroll_offset); + app.transcript_search = None; + if let Some(offset) = restore { + app.begin_scroll(offset); + } + vec![] + } + + // ── Settings view (issue #6024) ───────────────────────────────────────── + Action::SettingsTabNext => { + app.settings.next_tab(); + vec![] + } + Action::SettingsTabPrev => { + app.settings.previous_tab(); + vec![] + } + Action::SettingsSelectMove(dir) => { + let count = app.settings_active_tab_len(); + match dir { + VertDir::Down => app.settings.select_next(count), + VertDir::Up => app.settings.select_previous(count), + } + vec![] + } + // ── Confirm dialog ────────────────────────────────────────────────────── Action::ConfirmRespond(answer) => { if let Some(mut state) = app.confirm_state.take() @@ -707,6 +795,13 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec { app.set_active_panel(Panel::Durable); return vec![]; } + TuiCommand::Settings => { + app.set_active_panel(Panel::Settings); + return vec![]; + } + TuiCommand::TranscriptSearch => { + return reduce(app, Action::OpenTranscriptSearch); + } TuiCommand::PlanToggleView => { app.sessions.current_mut().plan_view_active = !app.sessions.current().plan_view_active; @@ -1939,4 +2034,257 @@ mod tests { let msg = rx.try_recv().expect("channel must have one message"); assert_eq!(msg, "/subagent spawn review the diff"); } + + // ── Transcript search reducer wiring (issue #6023) ────────────────────────── + // + // These exercise `reduce()` end-to-end through the public `Action` surface — not + // the underlying `TranscriptSearchState`/`line_offset_of_message` helpers directly + // (those have their own unit tests in `widgets/transcript_search.rs` and + // `widgets/chat.rs`) — so a wiring regression (e.g. a handler that stops calling + // `begin_scroll`, or stops restoring `pre_search_scroll_offset`) would be caught + // here even if the underlying helpers stay individually correct. + + /// Build an app with a populated transcript, splash disabled, and a real + /// `last_layout` (via `AppLayout::compute`, no `Frame` needed) so + /// `App::line_offset_of_message` — and therefore the reducer's `begin_scroll` + /// calls — actually resolve to `Some` instead of short-circuiting on `None`. + fn make_app_with_transcript() -> (App, mpsc::Receiver) { + let (mut app, rx) = make_app(); + app.sessions.current_mut().show_splash = false; + // Disable smooth-scroll so begin_scroll writes scroll_offset synchronously + // instead of an animated scroll_anim that only resolves over several ticks — + // these tests assert on scroll_offset directly. + app.delights.smooth_scroll = false; + for i in 0..30 { + app.sessions + .current_mut() + .messages + .push(crate::ChatMessage::new( + crate::MessageRole::Assistant, + format!("filler message number {i}"), + )); + } + app.sessions + .current_mut() + .messages + .push(crate::ChatMessage::new( + crate::MessageRole::Assistant, + "the needle is here".to_owned(), + )); + for i in 0..30 { + app.sessions + .current_mut() + .messages + .push(crate::ChatMessage::new( + crate::MessageRole::Assistant, + format!("trailer message number {i}"), + )); + } + let area = ratatui::layout::Rect::new(0, 0, 100, 20); + app.last_layout = Some(crate::layout::AppLayout::compute( + area, + app.show_side_panels(), + app.desired_input_height(), + app.effective_collapsed(), + )); + (app, rx) + } + + #[test] + fn open_transcript_search_captures_pre_search_scroll_offset() { + let (mut app, _rx) = make_app_with_transcript(); + app.sessions.current_mut().scroll_offset = 7; + + let effects = reduce(&mut app, Action::OpenTranscriptSearch); + + assert!(effects.is_empty()); + let state = app + .transcript_search + .as_ref() + .expect("overlay must be open"); + assert_eq!(state.pre_search_scroll_offset, 7); + assert!(state.matches.is_empty(), "no query typed yet"); + } + + #[test] + fn transcript_search_input_scrolls_to_off_screen_match() { + // SC-002: a query matching text in an off-screen earlier message must scroll + // the transcript so that message becomes visible. + let (mut app, _rx) = make_app_with_transcript(); + reduce(&mut app, Action::OpenTranscriptSearch); + let before_scroll = app.sessions.current().scroll_offset; + + for c in "needle".chars() { + reduce( + &mut app, + Action::TranscriptSearchInput(PaletteEdit::PushChar(c)), + ); + } + + let state = app.transcript_search.as_ref().expect("overlay stays open"); + assert_eq!( + state.matches.len(), + 1, + "exactly one message contains 'needle'" + ); + assert_ne!( + app.sessions.current().scroll_offset, + before_scroll, + "matching a message must move the scroll position (begin_scroll was invoked)" + ); + } + + #[test] + fn transcript_search_next_and_prev_move_scroll_between_matches() { + let (mut app, _rx) = make_app_with_transcript(); + app.sessions + .current_mut() + .messages + .push(crate::ChatMessage::new( + crate::MessageRole::Assistant, + "needle again near the bottom".to_owned(), + )); + reduce(&mut app, Action::OpenTranscriptSearch); + for c in "needle".chars() { + reduce( + &mut app, + Action::TranscriptSearchInput(PaletteEdit::PushChar(c)), + ); + } + let state = app.transcript_search.as_ref().unwrap(); + assert_eq!(state.matches.len(), 2); + let offset_after_input = app.sessions.current().scroll_offset; + + reduce(&mut app, Action::TranscriptSearchNext); + let offset_after_next = app.sessions.current().scroll_offset; + assert_ne!( + offset_after_next, offset_after_input, + "advancing to the next match must move the scroll target" + ); + + reduce(&mut app, Action::TranscriptSearchPrev); + let offset_after_prev = app.sessions.current().scroll_offset; + assert_eq!( + offset_after_prev, offset_after_input, + "stepping back must return to the first match's scroll target" + ); + } + + #[test] + fn close_transcript_search_restores_pre_search_scroll_offset() { + // FR-006: Esc cancels search and restores the scroll position from before it + // was opened, discarding any scroll movement search performed while active. + let (mut app, _rx) = make_app_with_transcript(); + app.sessions.current_mut().scroll_offset = 3; + reduce(&mut app, Action::OpenTranscriptSearch); + for c in "needle".chars() { + reduce( + &mut app, + Action::TranscriptSearchInput(PaletteEdit::PushChar(c)), + ); + } + assert_ne!( + app.sessions.current().scroll_offset, + 3, + "search must have moved the scroll position for this test to be meaningful" + ); + + let effects = reduce(&mut app, Action::CloseTranscriptSearch); + + assert!(effects.is_empty()); + assert!(app.transcript_search.is_none(), "overlay must close"); + assert_eq!( + app.sessions.current().scroll_offset, + 3, + "Esc must restore the pre-search scroll_offset" + ); + } + + #[test] + fn transcript_search_accept_closes_overlay_and_leaves_scroll_at_match() { + // FR-007: Enter accepts the current match, closes the overlay, and leaves the + // transcript scrolled at the match — it must NOT restore the pre-search offset. + let (mut app, _rx) = make_app_with_transcript(); + app.sessions.current_mut().scroll_offset = 3; + reduce(&mut app, Action::OpenTranscriptSearch); + for c in "needle".chars() { + reduce( + &mut app, + Action::TranscriptSearchInput(PaletteEdit::PushChar(c)), + ); + } + let scroll_at_match = app.sessions.current().scroll_offset; + assert_ne!(scroll_at_match, 3); + + let effects = reduce(&mut app, Action::TranscriptSearchAccept); + + assert!(effects.is_empty()); + assert!(app.transcript_search.is_none(), "overlay must close"); + assert_eq!( + app.sessions.current().scroll_offset, + scroll_at_match, + "Enter must leave the transcript scrolled at the accepted match, not restore pre-search state" + ); + } + + #[test] + fn transcript_search_dispatch_command_opens_overlay() { + let (mut app, _rx) = make_app_with_transcript(); + let effects = reduce(&mut app, Action::Dispatch(TuiCommand::TranscriptSearch)); + assert!(effects.is_empty()); + assert!(app.transcript_search.is_some()); + } + + // ── Settings view reducer wiring (issue #6024) ────────────────────────────── + + #[test] + fn settings_tab_next_and_prev_cycle_through_reducer() { + let (mut app, _rx) = make_app(); + assert_eq!( + app.settings.tab, + crate::widgets::settings::SettingsTab::Providers + ); + + reduce(&mut app, Action::SettingsTabNext); + assert_eq!(app.settings.tab, crate::widgets::settings::SettingsTab::Mcp); + + reduce(&mut app, Action::SettingsTabNext); + assert_eq!( + app.settings.tab, + crate::widgets::settings::SettingsTab::Agents + ); + + reduce(&mut app, Action::SettingsTabPrev); + assert_eq!(app.settings.tab, crate::widgets::settings::SettingsTab::Mcp); + } + + #[test] + fn settings_select_move_advances_and_clamps_via_settings_active_tab_len() { + let (mut app, _rx) = make_app(); + app.metrics.providers = vec![ + zeph_core::metrics::ProviderSummary::default(), + zeph_core::metrics::ProviderSummary::default(), + ] + .into(); + + reduce(&mut app, Action::SettingsSelectMove(VertDir::Down)); + assert_eq!(app.settings.selected_index(), 1); + + // Clamped at count - 1 (2 providers => max index 1), proving the reducer wires + // the live provider count through settings_active_tab_len rather than an + // unbounded increment. + reduce(&mut app, Action::SettingsSelectMove(VertDir::Down)); + assert_eq!(app.settings.selected_index(), 1); + + reduce(&mut app, Action::SettingsSelectMove(VertDir::Up)); + assert_eq!(app.settings.selected_index(), 0); + } + + #[test] + fn settings_dispatch_command_opens_settings_panel() { + let (mut app, _rx) = make_app(); + let effects = reduce(&mut app, Action::Dispatch(TuiCommand::Settings)); + assert!(effects.is_empty()); + assert_eq!(app.active_panel, Panel::Settings); + } } diff --git a/crates/zeph-tui/src/app/state.rs b/crates/zeph-tui/src/app/state.rs index 0aafb4088..b5b59a904 100644 --- a/crates/zeph-tui/src/app/state.rs +++ b/crates/zeph-tui/src/app/state.rs @@ -75,6 +75,8 @@ impl App { file_index: None, slash_autocomplete: None, reverse_search: None, + transcript_search: None, + settings: crate::widgets::settings::SettingsViewState::default(), should_quit: false, user_input_tx, agent_event_rx, @@ -1037,7 +1039,7 @@ impl App { // Force-expand slot 3 whenever an overlay is rendering into the subagents rect. let slot3_has_overlay = matches!( self.active_panel, - Panel::SubAgents | Panel::Fleet | Panel::Durable + Panel::SubAgents | Panel::Fleet | Panel::Durable | Panel::Settings ) || self.show_task_panel || self .metrics @@ -1051,6 +1053,16 @@ impl App { eff } + /// Returns the number of rows in the settings view's currently active tab + /// (issue #6024), used to clamp `Action::SettingsSelectMove` navigation. + pub(crate) fn settings_active_tab_len(&self) -> usize { + match self.settings.tab { + crate::widgets::settings::SettingsTab::Providers => self.metrics.providers.len(), + crate::widgets::settings::SettingsTab::Mcp => self.metrics.mcp_servers.len(), + crate::widgets::settings::SettingsTab::Agents => self.metrics.agent_definitions.len(), + } + } + /// Sets `active_panel`, keeping `show_task_panel` in sync so at most one /// panel/overlay ever claims `render_subagents_slot`'s shared `Rect` per frame (#6061). /// @@ -1070,7 +1082,9 @@ impl App { self.active_panel = p; match p { Panel::Tasks => self.show_task_panel = true, - Panel::SubAgents | Panel::Fleet | Panel::Durable => self.show_task_panel = false, + Panel::SubAgents | Panel::Fleet | Panel::Durable | Panel::Settings => { + self.show_task_panel = false; + } Panel::Chat | Panel::Skills | Panel::Memory | Panel::Resources => {} } } diff --git a/crates/zeph-tui/src/app/tests.rs b/crates/zeph-tui/src/app/tests.rs index 83117dfc5..faa045413 100644 --- a/crates/zeph-tui/src/app/tests.rs +++ b/crates/zeph-tui/src/app/tests.rs @@ -186,6 +186,9 @@ fn tab_cycles_panels() { app.handle_event(AppEvent::Key(tab)); assert_eq!(app.active_panel, Panel::Durable); + app.handle_event(AppEvent::Key(tab)); + assert_eq!(app.active_panel, Panel::Settings); + app.handle_event(AppEvent::Key(tab)); assert_eq!(app.active_panel, Panel::Chat); } diff --git a/crates/zeph-tui/src/command.rs b/crates/zeph-tui/src/command.rs index 22f3e22f6..e884612d9 100644 --- a/crates/zeph-tui/src/command.rs +++ b/crates/zeph-tui/src/command.rs @@ -130,6 +130,10 @@ pub enum TuiCommand { FleetPanel, // Durable execution journal (spec-064, #4949) DurablePanel, + // Read-only settings view: LLM providers, MCP servers, agent definitions (#6024) + Settings, + // Ctrl+F in-transcript search overlay (#6023) + TranscriptSearch, // Worktree subsystem (#4679) WorktreeList, WorktreeClean, @@ -309,6 +313,20 @@ fn build_view_commands() -> Vec { shortcut: Some("D"), command: TuiCommand::DurablePanel, }, + CommandEntry { + id: "settings", + label: "Settings: browse providers, MCP servers, and agents", + category: "view", + shortcut: Some("S"), + command: TuiCommand::Settings, + }, + CommandEntry { + id: "search:transcript", + label: "Find in conversation (Ctrl+F)", + category: "view", + shortcut: Some("Ctrl+F"), + command: TuiCommand::TranscriptSearch, + }, ] } @@ -1150,8 +1168,8 @@ mod tests { #[test] fn registry_has_correct_count() { - // +1 view:latency (#6059) - assert_eq!(command_registry().len(), 28); + // +1 view:latency (#6059); +2 settings + search:transcript (#6024/#6023) + assert_eq!(command_registry().len(), 30); } #[test] diff --git a/crates/zeph-tui/src/metrics.rs b/crates/zeph-tui/src/metrics.rs index 60707dde2..3fc6698eb 100644 --- a/crates/zeph-tui/src/metrics.rs +++ b/crates/zeph-tui/src/metrics.rs @@ -19,7 +19,8 @@ pub use zeph_common::SecurityEventCategory; pub use zeph_core::goal::{GoalSnapshot, GoalStatus}; pub use zeph_core::metrics::{ - CategoryScore, ClassifierMetricsSnapshot, McpServerConnectionStatus, McpServerStatus, - MetricsCollector, MetricsSnapshot, ProbeCategory, ProbeVerdict, SecurityEvent, SkillConfidence, - SubAgentMetrics, TaskGraphSnapshot, TaskMetricsSnapshot, TaskSnapshotRow, + AgentDefSummary, CategoryScore, ClassifierMetricsSnapshot, McpServerConnectionStatus, + McpServerStatus, MetricsCollector, MetricsSnapshot, ProbeCategory, ProbeVerdict, + ProviderSummary, SecurityEvent, SkillConfidence, SubAgentMetrics, TaskGraphSnapshot, + TaskMetricsSnapshot, TaskSnapshotRow, }; diff --git a/crates/zeph-tui/src/widgets/chat.rs b/crates/zeph-tui/src/widgets/chat.rs index 1e3a80074..1b731ba7a 100644 --- a/crates/zeph-tui/src/widgets/chat.rs +++ b/crates/zeph-tui/src/widgets/chat.rs @@ -57,7 +57,27 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect, cache: &mut RenderCa }; let flash_style = app.theme.tool_accent; - let (mut lines, all_md_links) = collect_message_lines_from( + // While transcript search (Ctrl+F, issue #6023) is active, bypass the render cache + // so highlight spans always reflect the current query, and thread the match set + // through for span-splitting. `TranscriptHighlight` borrows the query/matches for + // the duration of this call only — no state is stored beyond this frame. + let search_query_lower = app + .transcript_search + .as_ref() + .map(|s| s.query_lower().to_owned()); + let search_matches = app + .transcript_search + .as_ref() + .map(|s| s.matches.clone()) + .unwrap_or_default(); + let highlight = search_query_lower.as_deref().and_then(|q| { + (!q.is_empty()).then_some(TranscriptHighlight { + query_lower: q, + matches: &search_matches, + }) + }); + + let (mut lines, all_md_links, _message_line_starts) = collect_message_lines_from( &messages, truncation_info.as_deref(), cache, @@ -72,6 +92,7 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect, cache: &mut RenderCa app.theme_generation(), &flash_groups, flash_style, + highlight.as_ref(), ); let total = lines.len(); @@ -115,6 +136,25 @@ pub fn render(app: &mut App, frame: &mut Frame, area: Rect, cache: &mut RenderCa max_scroll } +/// A frozen, borrowed view of the active transcript search (issue #6023) passed into +/// [`collect_message_lines_from`] for highlight span-splitting. Built fresh from +/// `App.transcript_search` on every call to [`render`] — no state is retained beyond +/// the current frame. +pub(crate) struct TranscriptHighlight<'a> { + /// The already-lowercased search query (lowercased once by the caller, not + /// per-message — see `TranscriptSearchState::query_lower`). + pub query_lower: &'a str, + /// Original message indices (into the slice passed to `collect_message_lines_from`) + /// that matched the query. + pub matches: &'a [usize], +} + +impl TranscriptHighlight<'_> { + fn is_match(&self, idx: usize) -> bool { + self.matches.contains(&idx) + } +} + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site fn collect_message_lines_from( messages: &[crate::app::ChatMessage], @@ -131,9 +171,14 @@ fn collect_message_lines_from( theme_generation: u64, flash_groups: &std::collections::HashSet, flash_style: ratatui::style::Style, -) -> (Vec>, Vec) { + search: Option<&TranscriptHighlight<'_>>, +) -> (Vec>, Vec, Vec) { let mut lines: Vec> = Vec::new(); let mut all_md_links: Vec = Vec::new(); + // First rendered line index of each original message, parallel to `messages`. + // Grouped tool-cell members (see `MessageGroup::Grouped`) all share their group's + // start line, since they render as one folded visual block. + let mut message_line_starts: Vec = vec![0; messages.len()]; // Show truncation marker at the top when transcript was truncated (W4). if let Some(info) = truncation_info { @@ -150,6 +195,7 @@ fn collect_message_lines_from( for (group_pos, group) in groups.iter().enumerate() { match group { MessageGroup::Single { idx, msg } => { + message_line_starts[*idx] = lines.len(); let accent = match msg.role { MessageRole::User => theme.user_message, MessageRole::Assistant => theme.assistant_accent, @@ -193,26 +239,51 @@ fn collect_message_lines_from( // Note: tool messages with streaming=true use throbber_idx for the braille // spinner. Between content chunks the spinner freezes, but tool output // typically arrives in one batch, making this trade-off acceptable. - let (msg_lines, msg_md_links) = - if let Some((cached_lines, cached_links)) = cache.get(*idx, &cache_key) { - (cached_lines.to_vec(), cached_links.to_vec()) - } else { - let (rendered, extracted) = render_message_lines( - msg, - tool_expanded, - tool_density, - throbber_idx, - ascii, - theme, - wrap_width, - show_labels, - ); - cache.put(*idx, cache_key, rendered.clone(), extracted.clone()); - (rendered, extracted) - }; + // + // While transcript search is active, the cache is bypassed only for + // messages that actually match the query (NFR-001/SC-003 — a + // transcript-wide bypass would reintroduce full markdown/tree-sitter + // reparsing for every message on every redraw while search is open, the + // exact cost `RenderCache` exists to eliminate; matched messages must + // still bypass so a stale plain-rendered cache entry can never be reused + // in place of the highlighted variant under the same `RenderCacheKey`). + let (msg_lines, msg_md_links) = if search.is_some_and(|hl| hl.is_match(*idx)) { + render_message_lines( + msg, + tool_expanded, + tool_density, + throbber_idx, + ascii, + theme, + wrap_width, + show_labels, + ) + } else if let Some((cached_lines, cached_links)) = cache.get(*idx, &cache_key) { + (cached_lines.to_vec(), cached_links.to_vec()) + } else { + let (rendered, extracted) = render_message_lines( + msg, + tool_expanded, + tool_density, + throbber_idx, + ascii, + theme, + wrap_width, + show_labels, + ); + cache.put(*idx, cache_key, rendered.clone(), extracted.clone()); + (rendered, extracted) + }; all_md_links.extend(msg_md_links); + let msg_lines = match search { + Some(hl) if hl.is_match(*idx) => { + highlight_matches_in_lines(msg_lines, hl.query_lower, theme.highlight) + } + _ => msg_lines, + }; + let is_user = msg.role == MessageRole::User; let user_bg = theme.user_message_bg; @@ -233,6 +304,12 @@ fn collect_message_lines_from( start_idx, members, } => { + // Grouped members render as one folded cell — every member's original + // index shares the same anchor line (the group's start), since that is + // the closest visible anchor for a match inside any one of them. + for member_offset in 0..members.len() { + message_line_starts[*start_idx + member_offset] = lines.len(); + } let role_changed = prev_role != Some(MessageRole::Tool); if role_changed { if group_pos > 0 { @@ -286,7 +363,97 @@ fn collect_message_lines_from( } } } - (lines, all_md_links) + (lines, all_md_links, message_line_starts) +} + +/// Split matching substrings out of `lines` into their own [`Span`]s styled with +/// `highlight_style`, using the same span-splitting technique `SyntaxHighlighter` uses +/// for token spans (`highlight.rs`). Matching is case-insensitive against `query_lower` +/// (already lowercased once by the caller — see `TranscriptHighlight::query_lower`). +fn highlight_matches_in_lines( + lines: Vec>, + query_lower: &str, + highlight_style: Style, +) -> Vec> { + if query_lower.is_empty() { + return lines; + } + let query_chars: Vec = query_lower.chars().collect(); + lines + .into_iter() + .map(|line| { + let spans = line + .spans + .into_iter() + .flat_map(|span| highlight_span(span, &query_chars, highlight_style)) + .collect::>(); + Line::from(spans) + }) + .collect() +} + +/// Case-insensitive substring highlighter for one span. +/// +/// Slices `span.content` only at byte offsets sourced from its own `char_indices()`, so +/// every produced sub-slice is guaranteed to land on a char boundary — this cannot panic +/// regardless of query or content. Matching compares each candidate char's +/// [`char::to_lowercase`] iterator against the query char's, which correctly handles the +/// rare one-to-many lowercasing case (e.g. `İ` → `i̇`) without needing to align byte +/// lengths between a lowercased copy and the original string. +fn highlight_span( + span: Span<'static>, + query_chars: &[char], + highlight_style: Style, +) -> Vec> { + if query_chars.is_empty() { + return vec![span]; + } + let content = span.content.as_ref(); + let positions: Vec<(usize, char)> = content.char_indices().collect(); + let n = positions.len(); + let qlen = query_chars.len(); + if n < qlen { + return vec![span]; + } + + let mut out = Vec::new(); + let mut seg_start = 0usize; + let mut i = 0usize; + while i + qlen <= n { + let is_match = (0..qlen).all(|k| { + positions[i + k] + .1 + .to_lowercase() + .eq(query_chars[k].to_lowercase()) + }); + if is_match { + let match_start = positions[i].0; + let match_end = positions + .get(i + qlen) + .map_or(content.len(), |(byte, _)| *byte); + if match_start > seg_start { + out.push(Span::styled( + content[seg_start..match_start].to_string(), + span.style, + )); + } + out.push(Span::styled( + content[match_start..match_end].to_string(), + span.style.patch(highlight_style), + )); + seg_start = match_end; + i += qlen; + } else { + i += 1; + } + } + if seg_start < content.len() { + out.push(Span::styled(content[seg_start..].to_string(), span.style)); + } + if out.is_empty() { + out.push(Span::styled(content.to_string(), span.style)); + } + out } #[allow(clippy::too_many_arguments)] // structured inputs; a Params struct would not reduce complexity @@ -1340,6 +1507,70 @@ fn wrap_spans(spans: Vec>, max_width: usize) -> Vec> result } +impl App { + /// Compute the `scroll_offset` that brings message `msg_idx`'s rendered block to + /// the top of the chat viewport (issue #6023, transcript search next/prev/accept). + /// + /// **S4 (mandatory):** derives the offset from the exact same render inputs the live + /// renderer uses this frame — current chat-area width and the active + /// collapse/expand (`e`), tool-density (`c`), and source-label flags — via the same + /// [`collect_message_lines_from`] the frame renderer calls. A match whose text lives + /// inside a currently-collapsed or filtered tool-output block therefore scrolls to + /// that message's visible anchor (the collapsed cell), not to a position computed + /// against a different, always-expanded layout where the match text would not + /// actually be on screen. + /// + /// Returns `None` before the first frame has been drawn (`last_layout` unset, so the + /// chat area's width/height are not yet known) or when `msg_idx` is out of range. + pub(crate) fn line_offset_of_message(&mut self, msg_idx: usize) -> Option { + let area = self.last_layout.as_ref()?.chat; + if area.width == 0 || area.height == 0 { + return None; + } + let inner_height = area.height as usize; + let wrap_width = area.width.saturating_sub(2) as usize; + + let messages = self.visible_messages(); + if msg_idx >= messages.len() { + return None; + } + let truncation_info = self.transcript_truncation_info(); + let tool_expanded = self.tool_expanded(); + let tool_density = self.tool_density(); + let show_labels = self.show_source_labels(); + let ascii = self.is_ascii_only(); + let theme_generation = self.theme_generation(); + + let mut cache = std::mem::take(&mut self.sessions.current_mut().render_cache); + let (lines, _links, starts) = collect_message_lines_from( + &messages, + truncation_info.as_deref(), + &mut cache, + area.width, + wrap_width, + &self.theme, + tool_expanded, + tool_density, + show_labels, + 0, + ascii, + theme_generation, + &std::collections::HashSet::new(), + self.theme.tool_accent, + None, + ); + self.sessions.current_mut().render_cache = cache; + + // Mirror render()'s top-padding when the transcript is shorter than the viewport. + let content_total = lines.len(); + let padded_total = content_total.max(inner_height); + let padding = padded_total - content_total; + let start = starts[msg_idx] + padding; + let max_scroll = padded_total.saturating_sub(inner_height); + Some(max_scroll.saturating_sub(start)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1714,7 +1945,7 @@ mod tests { make_chat_msg(crate::app::MessageRole::Assistant, "Hi"), ]; let mut cache = crate::app::RenderCache::default(); - let (lines, _) = collect_message_lines_from( + let (lines, _, _) = collect_message_lines_from( &messages, None, &mut cache, @@ -1729,6 +1960,7 @@ mod tests { 0, &std::collections::HashSet::new(), ratatui::style::Style::default(), + None, ); let all_text: String = lines .iter() @@ -1775,7 +2007,7 @@ mod tests { let theme = Theme::default(); let messages = vec![make_chat_msg(crate::app::MessageRole::User, "Hello world")]; let mut cache = crate::app::RenderCache::default(); - let (lines, _) = collect_message_lines_from( + let (lines, _, _) = collect_message_lines_from( &messages, None, &mut cache, @@ -1790,6 +2022,7 @@ mod tests { 0, &std::collections::HashSet::new(), ratatui::style::Style::default(), + None, ); let has_bg = lines .iter() @@ -2285,7 +2518,7 @@ mod tests { .map(|i| make_read_msg(&format!("src/f{i}.rs"))) .collect(); let mut cache = crate::app::RenderCache::default(); - let (lines, _) = collect_message_lines_from( + let (lines, _, _) = collect_message_lines_from( &messages, None, &mut cache, @@ -2300,6 +2533,7 @@ mod tests { 0, &std::collections::HashSet::new(), ratatui::style::Style::default(), + None, ); let text: String = lines .iter() @@ -2539,4 +2773,155 @@ mod tests { "streaming-incomplete block must have at least one CodeBlock-tagged line" ); } + + // ── Transcript search: highlighting + line_offset_of_message (issue #6023) ───── + + #[test] + fn highlight_span_splits_matching_substring() { + let span = Span::styled("hello world".to_owned(), Style::default()); + let query: Vec = "world".chars().collect(); + let out = highlight_span( + span, + &query, + Style::default().bg(ratatui::style::Color::Yellow), + ); + let text: String = out.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(text, "hello world"); + assert!( + out.iter() + .any(|s| s.content == "world" && s.style.bg == Some(ratatui::style::Color::Yellow)) + ); + } + + #[test] + fn highlight_span_case_insensitive() { + let span = Span::styled("Hello WORLD".to_owned(), Style::default()); + let query: Vec = "world".chars().collect(); + let out = highlight_span( + span, + &query, + Style::default().bg(ratatui::style::Color::Yellow), + ); + assert!(out.iter().any(|s| s.content.eq_ignore_ascii_case("world") + && s.style.bg == Some(ratatui::style::Color::Yellow))); + } + + #[test] + fn highlight_span_no_match_returns_original() { + let span = Span::styled("hello".to_owned(), Style::default()); + let query: Vec = "xyz".chars().collect(); + let out = highlight_span(span, &query, Style::default()); + assert_eq!(out.len(), 1); + assert_eq!(out[0].content, "hello"); + } + + #[test] + fn highlight_span_never_panics_on_unicode() { + // Regression guard: must not panic when a multi-byte-lowering character (e.g. + // Turkish dotted capital İ) appears near a match boundary. + let span = Span::styled("İstanbul world İstanbul".to_owned(), Style::default()); + let query: Vec = "world".chars().collect(); + let out = highlight_span(span, &query, Style::default()); + let text: String = out.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(text, "İstanbul world İstanbul"); + } + + fn make_app_with_messages(count: usize, tool_output_lines: usize) -> (crate::App, usize) { + use std::fmt::Write as _; + + let (user_tx, _user_rx) = tokio::sync::mpsc::channel(16); + let (_agent_tx, agent_rx) = tokio::sync::mpsc::channel(16); + let mut app = crate::App::new(user_tx, agent_rx); + app.sessions.current_mut().messages.clear(); + app.sessions.current_mut().show_splash = false; + for i in 0..count { + app.sessions.current_mut().messages.push(make_chat_msg( + crate::app::MessageRole::Assistant, + &format!("filler message {i}"), + )); + } + let mut output = "$ big-command".to_owned(); + for i in 0..tool_output_lines { + let _ = write!(output, "\nline{i}"); + } + let tool_idx = app.sessions.current().messages.len(); + app.sessions + .current_mut() + .messages + .push(make_tool_msg(&output)); + for i in 0..count { + app.sessions.current_mut().messages.push(make_chat_msg( + crate::app::MessageRole::Assistant, + &format!("trailer message {i}"), + )); + } + (app, tool_idx) + } + + #[test] + fn line_offset_of_message_none_before_first_draw() { + let (mut app, tool_idx) = make_app_with_messages(5, 20); + assert_eq!(app.line_offset_of_message(tool_idx), None); + } + + #[test] + fn line_offset_of_message_scrolls_collapsed_tool_anchor_into_view() { + // S4 (mandatory): a match inside a currently-collapsed tool-output block must + // scroll to the message's visible anchor (its header row, always rendered), + // not a position computed as if the block were expanded. + let (mut app, tool_idx) = make_app_with_messages(30, 20); + assert!(!app.tool_expanded(), "tool output starts collapsed"); + + // Populate app.last_layout via a real draw pass. + let _ = crate::test_utils::render_to_string(100, 20, |frame, _area| { + app.draw(frame); + }); + + let offset_collapsed = app + .line_offset_of_message(tool_idx) + .expect("layout is populated after draw()"); + + // Toggle to expanded and recompute — since the collapsed cell renders far fewer + // lines (head+tail+ellipsis) than the fully expanded 21-line block, the two + // computed offsets must differ; this proves the helper reads the live + // tool_expanded flag rather than a hardcoded always-expanded layout. + app.sessions.current_mut().render_cache.clear(); + let effects = + crate::app::reducer::reduce(&mut app, crate::app::action::Action::ToggleToolExpanded); + crate::app::reducer::run_effects(&mut app, effects); + assert!(app.tool_expanded()); + let offset_expanded = app + .line_offset_of_message(tool_idx) + .expect("layout still populated"); + + assert_ne!( + offset_collapsed, offset_expanded, + "collapsed and expanded layouts must scroll to different offsets for the same message" + ); + + // Applying the collapsed-state offset must bring the tool message's header + // (its always-visible anchor, e.g. the tool name) into the rendered viewport. + app.sessions.current_mut().render_cache.clear(); + let effects = + crate::app::reducer::reduce(&mut app, crate::app::action::Action::ToggleToolExpanded); + crate::app::reducer::run_effects(&mut app, effects); + assert!(!app.tool_expanded()); + app.sessions.current_mut().scroll_offset = offset_collapsed; + let output = crate::test_utils::render_to_string(100, 20, |frame, _area| { + app.draw(frame); + }); + assert!( + output.contains("bash"), + "scrolling to the collapsed tool message's offset must show its header anchor; got: {output}" + ); + } + + #[test] + fn line_offset_of_message_out_of_range_returns_none() { + let (mut app, _tool_idx) = make_app_with_messages(2, 5); + let _ = crate::test_utils::render_to_string(100, 20, |frame, _area| { + app.draw(frame); + }); + assert_eq!(app.line_offset_of_message(9999), None); + } } diff --git a/crates/zeph-tui/src/widgets/help.rs b/crates/zeph-tui/src/widgets/help.rs index d5c560f00..c89a6c137 100644 --- a/crates/zeph-tui/src/widgets/help.rs +++ b/crates/zeph-tui/src/widgets/help.rs @@ -9,8 +9,8 @@ use ratatui::widgets::{Block, BorderType, Borders, Cell, Clear, Row, Table}; use crate::layout::centered_rect; use crate::theme::Theme; -// 33 data rows + 1 header row + 2 border lines -const POPUP_HEIGHT: u16 = 36; +// 47 data rows + 1 header row + 2 border lines +const POPUP_HEIGHT: u16 = 50; pub fn render(frame: &mut Frame, area: Rect, theme: &Theme) { let popup = centered_rect(70, POPUP_HEIGHT, area); @@ -34,6 +34,7 @@ pub fn render(frame: &mut Frame, area: Rect, theme: &Theme) { "cycle panels (Chat/Skills/Memory/Resources/SubAgents)", ), keybind_row("a", "focus Sub-Agents panel"), + keybind_row("S", "settings: browse providers, MCP servers, agents"), keybind_row("?", "toggle this help"), Row::new([Cell::from(""), Cell::from("")]), Row::new([ @@ -52,6 +53,14 @@ pub fn render(frame: &mut Frame, area: Rect, theme: &Theme) { Cell::from(""), ]), keybind_row("Esc", "return to main conversation"), + Row::new([Cell::from(""), Cell::from("")]), + Row::new([ + Cell::from(Span::styled("Settings panel (focused)", theme.panel_title)), + Cell::from(""), + ]), + keybind_row("h / l", "switch tab (Providers/MCP/Agents)"), + keybind_row("j / k", "move selection"), + keybind_row("Esc", "close panel focus"), Row::new([ Cell::from(Span::styled("Insert mode", theme.panel_title)), Cell::from(""), @@ -63,6 +72,7 @@ pub fn render(frame: &mut Frame, area: Rect, theme: &Theme) { keybind_row("Ctrl+U", "clear input"), keybind_row("Ctrl+K", "clear queue"), keybind_row("Up / Down", "navigate history"), + keybind_row("Ctrl+F", "find in conversation (also works in Normal mode)"), Row::new([Cell::from(""), Cell::from("")]), Row::new([ Cell::from(Span::styled("Confirm mode", theme.panel_title)), diff --git a/crates/zeph-tui/src/widgets/mod.rs b/crates/zeph-tui/src/widgets/mod.rs index 82115d62a..701c497e7 100644 --- a/crates/zeph-tui/src/widgets/mod.rs +++ b/crates/zeph-tui/src/widgets/mod.rs @@ -18,6 +18,7 @@ pub mod plan_view; pub mod resources; pub mod reverse_search; pub mod security; +pub mod settings; pub mod skills; pub mod slash_autocomplete; pub mod spinner; @@ -28,4 +29,5 @@ pub mod subagents; pub mod task_registry; pub mod toast; pub mod tool_view; +pub mod transcript_search; pub mod wave; diff --git a/crates/zeph-tui/src/widgets/settings.rs b/crates/zeph-tui/src/widgets/settings.rs new file mode 100644 index 000000000..7ec5a7296 --- /dev/null +++ b/crates/zeph-tui/src/widgets/settings.rs @@ -0,0 +1,532 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Read-only TUI settings view: browse configured LLM providers, MCP servers, and +//! sub-agent definitions from a running session (issue #6024). +//! +//! Data is read exclusively from [`MetricsSnapshot`] — a pure snapshot read with no +//! background operation, since `providers`/`agent_definitions`/`mcp_servers` are already +//! kept current on the metrics watch channel (see `MetricsSnapshot::providers` docs). +//! Write/edit is explicitly out of scope for v1 (NFR-005 of +//! `/specs/061-tui-settings-editor-parity/spec.md`). + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, List, ListItem, ListState, Paragraph}; + +use crate::layout::truncate_to_width; +use crate::metrics::{ + AgentDefSummary, McpServerConnectionStatus, McpServerStatus, MetricsSnapshot, ProviderSummary, +}; +use crate::theme::Theme; + +/// Which configuration class the settings view is currently browsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsTab { + /// Configured `[[llm.providers]]` entries. + Providers, + /// Configured MCP servers and their live connection status. + Mcp, + /// Configured sub-agent definitions (templates), not runtime instances. + Agents, +} + +impl SettingsTab { + const ALL: [SettingsTab; 3] = [ + SettingsTab::Providers, + SettingsTab::Mcp, + SettingsTab::Agents, + ]; + + fn index(self) -> usize { + match self { + SettingsTab::Providers => 0, + SettingsTab::Mcp => 1, + SettingsTab::Agents => 2, + } + } + + fn label(self) -> &'static str { + match self { + SettingsTab::Providers => "Providers", + SettingsTab::Mcp => "MCP", + SettingsTab::Agents => "Agents", + } + } + + /// Cycle to the next tab, wrapping around. + #[must_use] + pub fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + /// Cycle to the previous tab, wrapping around. + #[must_use] + pub fn previous(self) -> Self { + Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +/// View-layer state for the read-only settings panel: active tab and a per-tab +/// selection index (so switching tabs and back preserves each tab's cursor). +#[derive(Debug, Clone)] +pub struct SettingsViewState { + /// Currently active tab. + pub tab: SettingsTab, + /// Selected row index per tab, indexed by [`SettingsTab::index`] (0=Providers, + /// 1=Mcp, 2=Agents). + selected: [usize; 3], +} + +impl Default for SettingsViewState { + fn default() -> Self { + Self { + tab: SettingsTab::Providers, + selected: [0; 3], + } + } +} + +impl SettingsViewState { + /// Switch to the next tab (wraps). + pub fn next_tab(&mut self) { + self.tab = self.tab.next(); + } + + /// Switch to the previous tab (wraps). + pub fn previous_tab(&mut self) { + self.tab = self.tab.previous(); + } + + /// Move the active tab's selection down by one, clamped to `count - 1`. + /// + /// # Examples + /// + /// ``` + /// use zeph_tui::widgets::settings::SettingsViewState; + /// + /// let mut state = SettingsViewState::default(); + /// state.select_next(3); + /// assert_eq!(state.selected_index(), 1); + /// state.select_next(3); + /// state.select_next(3); + /// assert_eq!(state.selected_index(), 2, "clamped at count - 1"); + /// ``` + pub fn select_next(&mut self, count: usize) { + if count == 0 { + return; + } + let idx = self.tab.index(); + self.selected[idx] = (self.selected[idx] + 1).min(count - 1); + } + + /// Move the active tab's selection up by one, clamped to `0`. + pub fn select_previous(&mut self, count: usize) { + let idx = self.tab.index(); + self.selected[idx] = self.selected[idx].saturating_sub(1); + if count == 0 { + self.selected[idx] = 0; + } + } + + /// Returns the selected row index for the currently active tab. + #[must_use] + pub fn selected_index(&self) -> usize { + self.selected[self.tab.index()] + } +} + +/// Render the settings view: tab header, entry list, and a detail block for the +/// selected entry. Overlays the subagents slot, mirroring the Fleet/Durable/Tasks +/// precedent (`render_subagents_slot`). +pub fn render( + metrics: &MetricsSnapshot, + state: &mut SettingsViewState, + frame: &mut Frame, + area: Rect, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + frame.render_widget(Clear, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(area); + + render_tab_header(state.tab, frame, chunks[0], theme); + + let body = chunks[1]; + match state.tab { + SettingsTab::Providers => render_providers(&metrics.providers, state, frame, body, theme), + SettingsTab::Mcp => render_mcp(&metrics.mcp_servers, state, frame, body, theme), + SettingsTab::Agents => { + render_agents(&metrics.agent_definitions, state, frame, body, theme); + } + } +} + +fn render_tab_header(active: SettingsTab, frame: &mut Frame, area: Rect, theme: &Theme) { + let mut spans = vec![Span::styled( + " Settings ", + theme.panel_title.add_modifier(Modifier::BOLD), + )]; + for tab in SettingsTab::ALL { + let style = if tab == active { + theme.highlight.add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + spans.push(Span::styled(format!("[{}] ", tab.label()), style)); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// Split the body area into a scrollable list (top) and a fixed detail block (bottom). +fn split_body(area: Rect) -> (Rect, Rect) { + const DETAIL_HEIGHT: u16 = 5; + if area.height <= DETAIL_HEIGHT { + return (area, Rect::default()); + } + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(DETAIL_HEIGHT)]) + .split(area); + (chunks[0], chunks[1]) +} + +fn render_empty(message: &str, frame: &mut Frame, area: Rect) { + let p = Paragraph::new(message).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(p, area); +} + +fn list_state_for(selected: usize, len: usize) -> ListState { + let mut ls = ListState::default(); + if len > 0 { + ls.select(Some(selected.min(len - 1))); + } + ls +} + +fn render_providers( + providers: &[ProviderSummary], + state: &SettingsViewState, + frame: &mut Frame, + area: Rect, + theme: &Theme, +) { + if providers.is_empty() { + render_empty( + "No LLM providers configured in [[llm.providers]].", + frame, + area, + ); + return; + } + let (list_area, detail_area) = split_body(area); + let selected = state.selected_index().min(providers.len() - 1); + + let items: Vec = providers + .iter() + .map(|p| { + let marker = if p.active { + "* " + } else if p.default { + "d " + } else { + " " + }; + let name = truncate_to_width(&p.name, 20); + let model = truncate_to_width(p.model.as_deref().unwrap_or("(default)"), 24); + let line = Line::from(vec![ + Span::styled(marker, theme.tool_success), + Span::styled(format!("{name:<20}"), theme.system_message), + Span::styled( + format!(" [{}] ", p.provider_type), + Style::default().fg(Color::DarkGray), + ), + Span::raw(model), + ]); + ListItem::new(line) + }) + .collect(); + let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut list_state = list_state_for(selected, providers.len()); + frame.render_stateful_widget(list, list_area, &mut list_state); + + if detail_area.height > 0 + && let Some(p) = providers.get(selected) + { + let lines = vec![ + Line::from(format!( + "name: {} type: {}{}{}", + p.name, + p.provider_type, + if p.default { " default" } else { "" }, + if p.active { " active" } else { "" }, + )), + Line::from(format!( + "model: {} base_url: {}", + p.model.as_deref().unwrap_or("—"), + p.base_url.as_deref().unwrap_or("—"), + )), + Line::from(format!( + "max_tokens: {} embedding_model: {} stt_model: {}", + p.max_tokens.map_or("—".to_owned(), |v| v.to_string()), + p.embedding_model.as_deref().unwrap_or("—"), + p.stt_model.as_deref().unwrap_or("—"), + )), + ]; + frame.render_widget( + Paragraph::new(lines).style(Style::default().fg(Color::Gray)), + detail_area, + ); + } +} + +fn render_mcp( + servers: &[McpServerStatus], + state: &SettingsViewState, + frame: &mut Frame, + area: Rect, + theme: &Theme, +) { + if servers.is_empty() { + render_empty("No MCP servers configured.", frame, area); + return; + } + let (list_area, detail_area) = split_body(area); + let selected = state.selected_index().min(servers.len() - 1); + + let items: Vec = servers + .iter() + .map(|s| { + let (status_text, status_style) = match s.status { + McpServerConnectionStatus::Connected => ("connected", theme.tool_success), + McpServerConnectionStatus::Failed => ("failed", theme.tool_failure), + // McpServerConnectionStatus is #[non_exhaustive]; treat unknown as transitional. + _ => ("connecting", Style::default().fg(Color::Yellow)), + }; + let id = truncate_to_width(&s.id, 24); + let line = Line::from(vec![ + Span::styled(format!("{id:<24} "), theme.system_message), + Span::styled(format!("{status_text:<11}"), status_style), + Span::raw(format!(" tools: {}", s.tool_count)), + ]); + ListItem::new(line) + }) + .collect(); + let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut list_state = list_state_for(selected, servers.len()); + frame.render_stateful_widget(list, list_area, &mut list_state); + + if detail_area.height > 0 + && let Some(s) = servers.get(selected) + { + let lines = vec![ + Line::from(format!( + "id: {} status: {:?} tools: {}", + s.id, s.status, s.tool_count + )), + Line::from(if s.error.is_empty() { + "error: —".to_owned() + } else { + format!("error: {}", s.error) + }), + Line::from(format!( + "input_schemas_dropped: {} output_schemas_dropped: {}", + s.input_schemas_dropped, s.output_schemas_dropped + )), + ]; + frame.render_widget( + Paragraph::new(lines).style(Style::default().fg(Color::Gray)), + detail_area, + ); + } +} + +fn render_agents( + defs: &[AgentDefSummary], + state: &SettingsViewState, + frame: &mut Frame, + area: Rect, + theme: &Theme, +) { + if defs.is_empty() { + render_empty("No sub-agent definitions found.", frame, area); + return; + } + let (list_area, detail_area) = split_body(area); + let selected = state.selected_index().min(defs.len() - 1); + + let items: Vec = defs + .iter() + .map(|d| { + let name = truncate_to_width(&d.name, 20); + let desc = truncate_to_width(&d.description, 40); + let line = Line::from(vec![ + Span::styled(format!("{name:<20} "), theme.system_message), + Span::raw(desc), + ]); + ListItem::new(line) + }) + .collect(); + let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut list_state = list_state_for(selected, defs.len()); + frame.render_stateful_widget(list, list_area, &mut list_state); + + if detail_area.height > 0 + && let Some(d) = defs.get(selected) + { + let lines = vec![ + Line::from(format!( + "name: {} model: {} source: {}", + d.name, + d.model.as_deref().unwrap_or("inherit"), + d.source.as_deref().unwrap_or("—"), + )), + Line::from(format!( + "memory: {}", + d.memory_scope.as_deref().unwrap_or("—") + )), + Line::from(format!("tools: {}", d.tools_summary)), + ]; + frame.render_widget( + Paragraph::new(lines).style(Style::default().fg(Color::Gray)), + detail_area, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::render_to_string; + + fn provider(name: &str, active: bool) -> ProviderSummary { + ProviderSummary { + name: name.to_owned(), + provider_type: "claude".to_owned(), + active, + ..ProviderSummary::default() + } + } + + #[test] + fn settings_tab_cycles_wrap() { + assert_eq!(SettingsTab::Providers.next(), SettingsTab::Mcp); + assert_eq!(SettingsTab::Mcp.next(), SettingsTab::Agents); + assert_eq!(SettingsTab::Agents.next(), SettingsTab::Providers); + assert_eq!(SettingsTab::Providers.previous(), SettingsTab::Agents); + } + + #[test] + fn per_tab_selection_is_independent() { + let mut state = SettingsViewState::default(); + state.select_next(5); + state.select_next(5); + assert_eq!(state.selected_index(), 2); + state.next_tab(); + assert_eq!( + state.selected_index(), + 0, + "switching tabs must not carry over the previous tab's selection" + ); + state.previous_tab(); + assert_eq!( + state.selected_index(), + 2, + "returning to a tab must restore its own selection" + ); + } + + #[test] + fn select_next_clamps_at_count_minus_one() { + let mut state = SettingsViewState::default(); + for _ in 0..10 { + state.select_next(3); + } + assert_eq!(state.selected_index(), 2); + } + + #[test] + fn select_previous_clamps_at_zero() { + let mut state = SettingsViewState::default(); + state.select_previous(3); + assert_eq!(state.selected_index(), 0); + } + + #[test] + fn render_empty_providers_shows_empty_state() { + let metrics = MetricsSnapshot::default(); + let mut state = SettingsViewState::default(); + let output = render_to_string(80, 24, |frame, area| { + render(&metrics, &mut state, frame, area, &Theme::default()); + }); + assert!(output.contains("No LLM providers configured")); + } + + #[test] + fn render_providers_never_shows_secret_values() { + // SC-003 (settings-view side): even if a caller mistakenly seeded a name/model + // containing what looks like a credential, ProviderSummary has no field that + // could carry api_key/cocoon_access_hash/hf_token — assert the rendered output + // never contains the sentinel value used across the crate's other secret tests. + let metrics = MetricsSnapshot { + providers: vec![provider("prod", true)].into(), + ..MetricsSnapshot::default() + }; + let mut state = SettingsViewState::default(); + let output = render_to_string(80, 24, |frame, area| { + render(&metrics, &mut state, frame, area, &Theme::default()); + }); + assert!(output.contains("prod")); + assert!(!output.contains("SUPERSECRET")); + } + + #[test] + fn render_mcp_tab_shows_empty_state() { + let metrics = MetricsSnapshot::default(); + let mut state = SettingsViewState { + tab: SettingsTab::Mcp, + ..SettingsViewState::default() + }; + let output = render_to_string(80, 24, |frame, area| { + render(&metrics, &mut state, frame, area, &Theme::default()); + }); + assert!(output.contains("No MCP servers configured")); + } + + #[test] + fn render_agents_tab_shows_empty_state() { + let metrics = MetricsSnapshot::default(); + let mut state = SettingsViewState { + tab: SettingsTab::Agents, + ..SettingsViewState::default() + }; + let output = render_to_string(80, 24, |frame, area| { + render(&metrics, &mut state, frame, area, &Theme::default()); + }); + assert!(output.contains("No sub-agent definitions found")); + } + + #[test] + fn render_zero_area_does_not_panic() { + let metrics = MetricsSnapshot::default(); + let mut state = SettingsViewState::default(); + let output = render_to_string(80, 24, |frame, _area| { + render( + &metrics, + &mut state, + frame, + Rect::default(), + &Theme::default(), + ); + }); + let _ = output; + } +} diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap index 208da501b..039ec0a0a 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap @@ -1,6 +1,5 @@ --- source: crates/zeph-tui/src/widgets/command_palette.rs -assertion_line: 272 expression: output --- @@ -19,9 +18,9 @@ expression: output │tasks Toggle task registry pan│ │fleet Fleet: show agent sessio│ │durable Durable: show durable ex│ + │settings Settings: browse provide│ + │search:transcript Find in conversation (Ct│ │session:new Start new conversation │ │session:history Browse session history [│ │session:next Switch to next session (│ - │session:prev Switch to previous sessi│ - │session:close Close current session (/│ ╰──────────────────────────────────────────────╯ diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap index 82f758451..6102e68ad 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__help__tests__help_default.snap @@ -15,6 +15,7 @@ expression: output │c compact tools │ │Tab cycle panels (Chat/Skills/Memory/R│ │a focus Sub-Agents panel │ + │S settings: browse providers, MCP se│ │? toggle this help │ │ │ │Sub-Agents panel (f │ @@ -24,11 +25,10 @@ expression: output │ │ │Subagent transcript │ │Esc return to main conversation │ + │ │ + │Settings panel (foc │ + │h / l switch tab (Providers/MCP/Agents) │ + │j / k move selection │ + │Esc close panel focus │ │Insert mode │ - │Enter send message │ - │Shift+Enter insert newline │ - │Ctrl+J insert newline │ - │Esc return to normal mode │ - │Ctrl+U clear input │ - │Ctrl+K clear queue │ ╰──────────────────────────────────────────────────────╯ diff --git a/crates/zeph-tui/src/widgets/transcript_search.rs b/crates/zeph-tui/src/widgets/transcript_search.rs new file mode 100644 index 000000000..d559b0d9a --- /dev/null +++ b/crates/zeph-tui/src/widgets/transcript_search.rs @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `Ctrl+F` in-transcript search overlay (issue #6023). +//! +//! Mirrors [`crate::widgets::reverse_search::ReverseSearchState`]'s +//! `push_char`/`pop_char`/`refilter`/next/prev pattern, but searches the currently visible +//! conversation transcript (`ChatMessage.content` + `tool_name`) instead of the input +//! history, and highlights-and-scrolls rather than replacing the input buffer. + +use ratatui::Frame; +use ratatui::layout::{Alignment, Rect}; +use ratatui::widgets::{Block, Borders, Clear}; + +use crate::theme::Theme; +use crate::types::ChatMessage; + +/// State for the `Ctrl+F` transcript search overlay. +/// +/// Holds the current query and the set of matching message indices within the +/// transcript passed to [`TranscriptSearchState::new`]/[`push_char`](Self::push_char)/ +/// [`pop_char`](Self::pop_char) — the corpus is **not** owned by this state, mirroring +/// how `ReverseSearchState` receives `history` on every mutation. +pub struct TranscriptSearchState { + /// The typed search query, as entered. + pub query: String, + /// Lowercased query, cached so matching never re-lowercases the query per message. + query_lower: String, + /// Indices into the transcript slice (`visible_messages()`) that match `query`. + pub matches: Vec, + /// Index into `matches` for the currently selected/highlighted match. + pub selected: usize, + /// `scroll_offset` captured when the overlay was opened, restored on Esc-cancel. + pub pre_search_scroll_offset: usize, +} + +impl TranscriptSearchState { + /// Create a new, empty search state. `matches` starts empty (FR-010: no query + /// typed yet SHALL show zero matches, unlike `ReverseSearchState::new` which + /// shows the full history — transcript search has no equivalent "browse + /// everything" default because messages are already fully visible). + #[must_use] + pub fn new(current_scroll: usize) -> Self { + Self { + query: String::new(), + query_lower: String::new(), + matches: Vec::new(), + selected: 0, + pre_search_scroll_offset: current_scroll, + } + } + + /// Append a character to the query and recompute matches. + pub fn push_char(&mut self, c: char, messages: &[ChatMessage]) { + self.query.push(c); + self.query_lower = self.query.to_lowercase(); + self.refilter(messages); + } + + /// Remove the last character from the query and recompute matches. + pub fn pop_char(&mut self, messages: &[ChatMessage]) { + self.query.pop(); + self.query_lower = self.query.to_lowercase(); + self.refilter(messages); + } + + /// Advance `selected` to the next match, wrapping at the end. + pub fn select_next(&mut self) { + if self.matches.is_empty() { + return; + } + self.selected = (self.selected + 1) % self.matches.len(); + } + + /// Move `selected` to the previous match, wrapping at the beginning. + pub fn select_previous(&mut self) { + if self.matches.is_empty() { + return; + } + self.selected = self + .selected + .checked_sub(1) + .unwrap_or(self.matches.len() - 1); + } + + /// Returns the transcript message index of the currently selected match, or `None` + /// when there are no matches. + #[must_use] + pub fn selected_message_index(&self) -> Option { + self.matches.get(self.selected).copied() + } + + /// Returns the cached lowercased query, for highlight span-splitting in the chat + /// renderer (avoids re-lowercasing the query on every rendered message). + #[must_use] + pub fn query_lower(&self) -> &str { + &self.query_lower + } + + fn refilter(&mut self, messages: &[ChatMessage]) { + self.matches = Self::compute_matches(&self.query_lower, messages); + self.selected = self.selected.min(self.matches.len().saturating_sub(1)); + } + + fn compute_matches(query_lower: &str, messages: &[ChatMessage]) -> Vec { + if query_lower.is_empty() { + return Vec::new(); + } + messages + .iter() + .enumerate() + .filter(|(_, msg)| message_matches(msg, query_lower)) + .map(|(i, _)| i) + .collect() + } +} + +/// Returns `true` if `msg.content` or `msg.tool_name` contains `query_lower` as a +/// case-insensitive substring (US-002: tool calls must be findable by name too). +fn message_matches(msg: &ChatMessage, query_lower: &str) -> bool { + if msg.content.to_lowercase().contains(query_lower) { + return true; + } + msg.tool_name + .as_ref() + .is_some_and(|name| name.as_str().to_lowercase().contains(query_lower)) +} + +/// Render the transcript-search bar anchored above `input_area`, mirroring +/// `reverse_search::render`'s popup placement and styling. +pub fn render(state: &TranscriptSearchState, frame: &mut Frame, input_area: Rect, theme: &Theme) { + let width: u16 = 60; + let height: u16 = 3; + let x = if input_area.width > width { + input_area.x + (input_area.width - width) / 2 + } else { + input_area.x + }; + let actual_width = width.min(input_area.width); + let y = input_area.y.saturating_sub(height); + + let popup = Rect { + x, + y, + width: actual_width, + height, + }; + + frame.render_widget(Clear, popup); + + let match_info = if state.query.is_empty() { + String::new() + } else if state.matches.is_empty() { + " no matches".to_owned() + } else { + format!(" {}/{}", state.selected + 1, state.matches.len()) + }; + + let title = format!(" Find: {}{} ", state.query, match_info); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.panel_border) + .title(title) + .title_alignment(Alignment::Center); + + frame.render_widget(block, popup); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::render_to_string; + use crate::types::MessageRole; + + fn messages(items: &[&str]) -> Vec { + items + .iter() + .map(|s| ChatMessage::new(MessageRole::Assistant, (*s).to_owned())) + .collect() + } + + #[test] + fn new_state_has_no_matches_before_any_query() { + let state = TranscriptSearchState::new(5); + assert!(state.matches.is_empty()); + assert_eq!(state.selected, 0); + assert_eq!(state.pre_search_scroll_offset, 5); + } + + #[test] + fn push_char_filters_case_insensitively() { + let msgs = messages(&["Hello World", "goodbye", "HELLO there"]); + let mut state = TranscriptSearchState::new(0); + state.push_char('h', &msgs); + state.push_char('e', &msgs); + state.push_char('l', &msgs); + state.push_char('l', &msgs); + assert_eq!(state.matches, vec![0, 2]); + } + + #[test] + fn pop_char_recomputes_wider_matches() { + let msgs = messages(&["hello", "help", "world"]); + let mut state = TranscriptSearchState::new(0); + state.push_char('h', &msgs); + state.push_char('e', &msgs); + state.push_char('l', &msgs); + state.push_char('p', &msgs); + assert_eq!(state.matches, vec![1]); + state.pop_char(&msgs); + assert_eq!(state.matches, vec![0, 1]); + } + + #[test] + fn empty_query_has_zero_matches_not_all_messages() { + // FR-010: differs from ReverseSearchState, which shows everything on an + // empty query — here an empty query means "nothing typed yet", not "browse all". + let msgs = messages(&["a", "b", "c"]); + let state = TranscriptSearchState::new(0); + assert!(state.matches.is_empty()); + let mut state2 = TranscriptSearchState::new(0); + state2.push_char('a', &msgs); + state2.pop_char(&msgs); + assert!(state2.matches.is_empty()); + } + + #[test] + fn matches_tool_name_not_just_content() { + let mut msgs = messages(&["some output"]); + msgs[0].tool_name = Some(zeph_common::ToolName::new("shell")); + let mut state = TranscriptSearchState::new(0); + for c in "shell".chars() { + state.push_char(c, &msgs); + } + assert_eq!(state.matches, vec![0]); + } + + #[test] + fn select_next_wraps() { + let msgs = messages(&["x", "x", "x"]); + let mut state = TranscriptSearchState::new(0); + state.push_char('x', &msgs); + assert_eq!(state.matches.len(), 3); + state.select_next(); + state.select_next(); + state.select_next(); + assert_eq!(state.selected, 0, "must wrap back to the first match"); + } + + #[test] + fn select_previous_wraps() { + let msgs = messages(&["x", "x"]); + let mut state = TranscriptSearchState::new(0); + state.push_char('x', &msgs); + state.select_previous(); + assert_eq!(state.selected, 1, "must wrap to the last match"); + } + + #[test] + fn select_next_noop_on_empty_matches() { + let mut state = TranscriptSearchState::new(0); + state.select_next(); + assert_eq!(state.selected, 0); + } + + #[test] + fn selected_message_index_reflects_current_selection() { + let msgs = messages(&["needle here", "no match", "needle again"]); + let mut state = TranscriptSearchState::new(0); + for c in "needle".chars() { + state.push_char(c, &msgs); + } + assert_eq!(state.selected_message_index(), Some(0)); + state.select_next(); + assert_eq!(state.selected_message_index(), Some(2)); + } + + #[test] + fn refilter_clamps_selected_when_matches_shrink() { + let msgs = messages(&["cat", "cats", "dog"]); + let mut state = TranscriptSearchState::new(0); + state.push_char('c', &msgs); + state.select_next(); // now at index 1 (of 2 matches: cat, cats) + assert_eq!(state.selected, 1); + for c in "atxyz".chars() { + state.push_char(c, &msgs); + } + // no message contains "catxyz" -> matches empty, selected clamped to 0 + assert!(state.matches.is_empty()); + assert_eq!(state.selected, 0); + } + + #[test] + fn render_search_bar_snapshot_shows_query_and_count() { + let msgs = messages(&["needle", "needle"]); + let mut state = TranscriptSearchState::new(0); + for c in "needle".chars() { + state.push_char(c, &msgs); + } + let output = render_to_string(80, 24, |frame, area| { + let theme = Theme::default(); + render(&state, frame, area, &theme); + }); + assert!(output.contains("needle")); + assert!(output.contains("1/2")); + } + + #[test] + fn render_no_matches_snapshot() { + let msgs = messages(&["hello"]); + let mut state = TranscriptSearchState::new(0); + for c in "zzz".chars() { + state.push_char(c, &msgs); + } + let output = render_to_string(80, 24, |frame, area| { + let theme = Theme::default(); + render(&state, frame, area, &theme); + }); + assert!(output.contains("no matches")); + } +} diff --git a/src/runner.rs b/src/runner.rs index be64812c5..a032e6626 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -3423,6 +3423,7 @@ pub(crate) async fn run(mut cli: Cli) -> anyhow::Result<()> { .with_static_metrics(static_metrics_init) .with_status_tx(agent_status_tx) .with_provider_pool(config.llm.providers.clone(), provider_config_snapshot) + .with_settings_metrics() .with_channel_identity( active_channel_name.clone(), config.session.provider_persistence,