Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- `zeph-tui`: inline, non-modal `@` mention picker (issues #6647, #6648), replacing the
old modal file picker. Typing `@` at word-start inserts the character and opens a popup
with `All | Files | Skills | Agents` category tabs (Left/Right to cycle, Up/Down to
select, Tab/Enter to accept without submitting, Esc to dismiss without touching the
buffer). Typing never gets captured away — every keystroke lands in the input buffer,
and the popup only reflects/filters it. Accepting a File inserts the bare repo-relative
path, a Skill inserts the plain name, and an Agent retains the `@` sigil for
slash-command dispatch. The Files category reuses the existing background-built file
index; Skills are delivered via a new `Channel::send_skill_catalog` event emitted at
agent startup and on skill hot-reload; Agents read directly from already-available
runtime metrics.
- `zeph-core`: new `Channel::send_skill_catalog` method (default no-op) and
`AgentEvent::SkillCatalog`, forwarded by `AnyChannel`, `GatewayChannel`, and
`AppChannel`, overridden by `TuiChannel` to feed the mention picker's Skills tab.

### Changed

- `zeph-tui`: moved the busy spinner and humanized activity verb from the bottom status
Expand Down
8 changes: 6 additions & 2 deletions crates/zeph-channels/src/any.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
//! that expands each trait method into a `match` over the active variant.

use zeph_core::channel::{
Channel, ChannelError, ChannelMessage, ElicitationRequest, ElicitationResponse, StopHint,
ToolOutputEvent, ToolStartEvent,
Channel, ChannelError, ChannelMessage, ElicitationRequest, ElicitationResponse,
SkillCatalogItem, StopHint, ToolOutputEvent, ToolStartEvent,
};

use crate::cli::CliChannel;
Expand Down Expand Up @@ -148,6 +148,10 @@ impl Channel for AnyChannel {
dispatch_channel!(self, send_status, text)
}

async fn send_skill_catalog(&mut self, items: &[SkillCatalogItem]) -> Result<(), ChannelError> {
dispatch_channel!(self, send_skill_catalog, items)
}

async fn send_queue_count(&mut self, count: usize) -> Result<(), ChannelError> {
dispatch_channel!(self, send_queue_count, count)
}
Expand Down
12 changes: 12 additions & 0 deletions crates/zeph-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,18 @@ impl<C: Channel> Agent<C> {
self.load_and_cache_session_digest().await;
self.maybe_send_resume_recap().await;

// Emit the initial skill catalog once at startup (spec 084 §6, issue #6648) so a
// TUI mention picker opened before the first hot-reload still sees real skill
// names/descriptions instead of an indefinite "loading skills…" placeholder.
let initial_skill_catalog = self.skill_catalog_items().await;
if let Err(e) = self
.channel
.send_skill_catalog(&initial_skill_catalog)
.await
{
tracing::warn!("failed to emit initial skill catalog: {e}");
}

// AutoSkill A6: start periodic heuristic promotion task at session startup so it runs
// even when the main loop exits early due to an error (spec 061). The function guards
// against double-spawn via a heuristic_promotion_handle.is_some() check.
Expand Down
47 changes: 47 additions & 0 deletions crates/zeph-core/src/agent/skill_reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,45 @@ use zeph_skills::matcher::{SkillMatcher, SkillMatcherBackend};
use zeph_skills::registry::SkillRegistry;

impl<C: Channel> Agent<C> {
/// Builds the current skill catalog (name + description) for
/// [`Channel::send_skill_catalog`], excluding blocked skills.
///
/// Shared by the startup emit (`Agent::run`) and the hot-reload emit below so both
/// apply the same [`zeph_common::SkillTrustLevel::Blocked`] filter — reading
/// `registry.read().all_meta()` raw at only one of the two sites would let blocked
/// skills appear in the mention picker until the first hot-reload, then silently
/// vanish (M2).
pub(super) async fn skill_catalog_items(&mut self) -> Vec<crate::channel::SkillCatalogItem> {
let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
.services
.skill
.registry
.read()
.all_meta()
.into_iter()
.cloned()
.collect();
let trust_map = match self.build_skill_trust_map().await {
crate::agent::trust_commands::SkillTrustMapLoad::Fresh(map) => map,
crate::agent::trust_commands::SkillTrustMapLoad::LoadFailed => {
self.services.skill.trust_snapshot.read().clone()
}
};
all_meta
.into_iter()
.filter(|m| {
!matches!(
trust_map.get(&m.name),
Some(snap) if snap.trust_level == zeph_common::SkillTrustLevel::Blocked
)
})
.map(|m| crate::channel::SkillCatalogItem {
name: m.name,
description: m.description,
})
.collect()
}

/// Update trust DB records for all reloaded skills.
async fn update_trust_for_reloaded_skills(
&mut self,
Expand Down Expand Up @@ -287,6 +326,14 @@ impl<C: Channel> Agent<C> {
// next turn's `rebuild_system_prompt` overwrites it (#6413).
self.recompute_prompt_tokens();

// Re-emit the catalog so an open TUI mention picker's Skills tab (spec 084 §6)
// picks up additions/removals/blocked-status changes on hot-reload, not just at
// startup.
let catalog_items = self.skill_catalog_items().await;
if let Err(e) = self.channel.send_skill_catalog(&catalog_items).await {
tracing::warn!("failed to re-emit skill catalog after reload: {e}");
}

self.channel.send_status_best_effort("").await;
tracing::info!(
"reloaded {} skill(s)",
Expand Down
42 changes: 42 additions & 0 deletions crates/zeph-core/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,33 @@ pub struct ChannelMessage {
pub owner_key: Option<String>,
}

/// One entry in the skill catalog delivered to channels via
/// [`Channel::send_skill_catalog`] (spec 084 §6, issue #6648).
///
/// Carries just enough to populate a discovery UI (e.g. the TUI's inline `@` mention
/// picker Skills tab) — name plus a human-readable description — without pulling in
/// `zeph-skills`' full `SkillMeta` (which also carries filesystem paths, trust
/// metadata, and resource lists that channels have no business seeing).
///
/// # Examples
///
/// ```
/// use zeph_core::channel::SkillCatalogItem;
///
/// let item = SkillCatalogItem {
/// name: "web_search".to_owned(),
/// description: "Search the web for current information".to_owned(),
/// };
/// assert_eq!(item.name, "web_search");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillCatalogItem {
/// Skill name, as registered in `SKILL.md` frontmatter.
pub name: String,
/// Human-readable description from the skill's frontmatter.
pub description: String,
}

/// Upper bound on [`Channel::send_status_best_effort`]. Status sends are a UX nicety, not a
/// value the agent turn depends on, so a slow or rate-limited channel (see issue #6094 — Discord
/// and Slack's 429 retry loop can otherwise take minutes) must never stall the turn loop past
Expand Down Expand Up @@ -332,6 +359,21 @@ pub trait Channel: Send {
async { Ok(()) }
}

/// Send the full skill catalog (name + description), delivered once at agent
/// startup and re-emitted on skill hot-reload (spec 084 §6, issue #6648). No-op by
/// default — most channels have no discovery UI to populate. `TuiChannel` overrides
/// this to feed the inline `@` mention picker's Skills tab.
///
/// # Errors
///
/// Returns an error if the underlying I/O fails.
fn send_skill_catalog(
&mut self,
_items: &[SkillCatalogItem],
) -> impl Future<Output = Result<(), ChannelError>> + Send {
async { Ok(()) }
}

/// Send a bounded transcript slice for `/history` backfill (spec-068 §13.6-§13.7).
///
/// Default: renders `entries` into one flat string via
Expand Down
76 changes: 65 additions & 11 deletions crates/zeph-tui/src/app/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ pub(crate) enum VertDir {
Down,
}

/// Horizontal tab-cycling direction for the mention picker (FR-004).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) enum HorizDir {
Left,
Right,
}

/// Sub-edits for the command-palette text field.
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
Expand Down Expand Up @@ -147,17 +155,19 @@ pub(crate) enum Action {
/// Accept the currently selected palette entry.
PaletteAccept,

// ── File picker ────────────────────────────────────────────────────────────
/// Open the file picker.
OpenFilePicker,
/// Close the file picker without selecting.
CloseFilePicker,
/// Move the file picker selection.
FilePickerMove(VertDir),
/// Type in the file picker filter field.
FilePickerInput(PaletteEdit),
/// Accept the currently selected file.
FilePickerAccept,
// ── Mention picker (#6647) ──────────────────────────────────────────────────
// No `OpenMentionPicker`/`MentionPickerInput` — opening is a side effect of
// `InsertChar('@')`, and typing continues to flow through the existing
// `InsertChar`/`Delete*`/`MoveCursor` arms (the query is derived from the buffer,
// never mirrored into a second string; see `reducer::mention_picker_query`).
/// Close the mention picker without accepting.
CloseMentionPicker,
/// Move the mention picker selection within the active tab.
MentionPickerMove(VertDir),
/// Cycle the active category tab (FR-004).
MentionPickerTabChange(HorizDir),
/// Accept the currently selected entry (Tab or Enter).
MentionPickerAccept,

// ── Slash autocomplete ─────────────────────────────────────────────────────
/// Move the autocomplete selection.
Expand Down Expand Up @@ -237,6 +247,50 @@ pub(crate) enum Action {
Dispatch(TuiCommand),
}

impl Action {
/// Returns `true` only for actions that provably cannot mutate the input buffer,
/// the cursor position, or the active session — i.e. actions the mention-picker
/// resync (`reducer::sync_mention_picker`) can safely skip.
///
/// This is a **fail-closed denylist**, not an allowlist of "buffer-touching"
/// actions: everything not listed here defaults to `false` (resync runs), so a
/// future `Action` variant added by someone unaware of the mention picker is safe
/// by construction. An allowlist shape was tried first and missed `Dispatch`
/// (reaches `prefill_input`/`TuiCommand::PrefillVerbatim`, both buffer-mutating)
/// and session switch (`mention_picker` is a global `App` field that must not
/// survive into a different session's buffer) — see spec 084 R1 S1/S2.
#[must_use]
pub(crate) fn preserves_mention_span(&self) -> bool {
matches!(
self,
Self::ScrollLines(_)
| Self::ScrollPage(_)
| Self::ScrollToTop
| Self::ScrollToBottom
| Self::ToggleToolExpanded
| Self::CycleToolDensity
| Self::ToggleSidePanels
| Self::ToggleHelp
| Self::SetHelp(_)
| Self::CyclePanelFocus
| Self::SetActivePanel(_)
| Self::TogglePanelCollapse(_)
| Self::ToggleTaskPanel
| Self::TogglePlanView
| Self::SetViewTarget(_)
| Self::SettingsTabNext
| Self::SettingsTabPrev
| Self::SettingsSelectMove(_)
| Self::SetMouse(_)
| Self::CopyLastAssistant
| Self::CopyLastCodeBlock(_)
| Self::CloseMentionPicker
| Self::MentionPickerMove(_)
| Self::MentionPickerTabChange(_)
)
}
}

/// Cursor movement kinds for [`Action::MoveCursor`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
Expand Down
4 changes: 2 additions & 2 deletions crates/zeph-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ impl App {
widgets::input::render(self, frame, layout.input, busy, spinner_idx, motion);
widgets::status::render(self, &self.metrics, frame, layout.status);

if let Some(state) = &self.file_picker_state {
widgets::file_picker::render(state, frame, layout.input, &self.theme);
if let Some(state) = &self.mention_picker {
widgets::mention_picker::render(self, state, frame, layout.input, &self.theme);
}

if let Some(state) = &self.slash_autocomplete {
Expand Down
22 changes: 21 additions & 1 deletion crates/zeph-tui/src/app/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@ impl App {
self.sessions.current_mut().render_cache.clear();
}
AppEvent::Agent(agent_event) => self.handle_agent_event(agent_event),
AppEvent::Paste(text) => self.handle_paste(&text),
// Routed through `reduce` (S1, spec 084) rather than calling `handle_paste`
// directly: this is behaviour-identical (the `InsertText` reducer arm just
// calls `handle_paste`) but gives paste the mention-picker resync for free —
// without it, pasting before the `@` shifts the buffer while `at_char_index`
// stays fixed, which can invert `MentionPickerAccept`'s replacement range.
AppEvent::Paste(text) => {
let effects =
crate::app::reducer::reduce(self, crate::app::action::Action::InsertText(text));
crate::app::reducer::run_effects(self, effects);
}
AppEvent::Mouse(m) => self.handle_mouse(m),
}
}
Expand Down Expand Up @@ -304,6 +313,17 @@ impl App {
AgentEvent::HistoryBackfill(entries) => {
self.backfill_history_display_only(&entries);
}
AgentEvent::SkillCatalog(items) => {
self.skill_catalog = Some(items);
if self.mention_picker.is_some() {
let query = crate::app::reducer::mention_picker_query(self);
let skills = self.skill_catalog.clone();
if let Some(picker) = self.mention_picker.as_mut() {
picker.catalog.skills = skills;
picker.refilter(&query);
}
}
}
}
}

Expand Down
Loading
Loading