From 6788db1ef2b6da9f43d41c187ee0e83bcd84de84 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 30 Apr 2026 12:46:48 +0900 Subject: [PATCH] =?UTF-8?q?tui=E3=81=AE=E8=A3=9C=E5=AE=8C=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/pod/src/compact/worker.rs | 22 +- crates/pod/src/controller.rs | 18 +- crates/pod/src/fs_view.rs | 323 +++++++++++++++++++++ crates/pod/src/ipc/server.rs | 27 ++ crates/pod/src/lib.rs | 1 + crates/pod/src/pod.rs | 37 +-- crates/pod/src/shared_state.rs | 24 +- crates/protocol/src/lib.rs | 98 +++++++ crates/tui/src/app.rs | 227 ++++++++++++++- crates/tui/src/input.rs | 354 ++++++++++++++++++++++-- crates/tui/src/main.rs | 58 +++- crates/tui/src/ui.rs | 125 +++++++-- tickets/submit-tui-completion.md | 37 ++- tickets/submit-tui-completion.review.md | 48 ++++ 14 files changed, 1274 insertions(+), 125 deletions(-) create mode 100644 crates/pod/src/fs_view.rs create mode 100644 tickets/submit-tui-completion.review.md diff --git a/crates/pod/src/compact/worker.rs b/crates/pod/src/compact/worker.rs index f09ac021..c9c0dfad 100644 --- a/crates/pod/src/compact/worker.rs +++ b/crates/pod/src/compact/worker.rs @@ -28,15 +28,7 @@ use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use serde::Deserialize; use tools::ScopedFs; -/// A file the compact worker has marked for auto-read in the new session. -#[derive(Debug, Clone)] -pub(crate) struct ReadRequirement { - pub path: PathBuf, - /// 0-based line offset. `None` means from the start of the file. - pub offset: Option, - /// Maximum number of lines. `None` means to the end of the file. - pub limit: Option, -} +use crate::fs_view::{ReadRequirement, slice_lines}; /// Aggregated output of a compact worker run. #[derive(Debug, Default, Clone)] @@ -281,18 +273,6 @@ fn estimate_tokens(bytes: usize) -> u64 { (bytes as u64).div_ceil(4) } -/// Return the slice of `text` covered by `offset` (line index) and -/// optional `limit` (line count), preserving the original newline -/// separation. Returns the whole file when both defaults apply. -pub(crate) fn slice_lines(text: &str, offset: usize, limit: Option) -> String { - let lines: Vec<&str> = text.lines().collect(); - let start = offset.min(lines.len()); - let end = limit - .map(|n| start.saturating_add(n).min(lines.len())) - .unwrap_or(lines.len()); - lines[start..end].join("\n") -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 24a71fbc..775b8a16 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -108,6 +108,10 @@ impl PodController { // can emit typed lifecycle `Event`s (currently: compact progress). pod.attach_event_tx(event_tx.clone()); + // Stashed during tool registration below so we can attach a + // `PodFsView` to the shared state once the latter exists. + let fs_for_view: tools::ScopedFs; + // Register event bridge callbacks on the worker { let worker = pod.worker_mut(); @@ -226,6 +230,10 @@ impl PodController { // touching. let fs = tools::ScopedFs::new(scope_for_tools, pwd_for_tools.clone()); let tracker = tools::Tracker::new(); + // The same ScopedFs also powers the IPC `ListCompletions` + // query — keep a clone for the FS view we attach below, + // since the tools consume `fs` itself. + fs_for_view = fs.clone(); worker.register_tools(tools::builtin_tools(fs, tracker.clone())); // Memory subsystem opt-in. When `[memory]` is present in @@ -278,6 +286,7 @@ impl PodController { )); shared_state.update_history(pod.worker().history().to_vec()); shared_state.set_user_segments(pod.user_segments().to_vec()); + shared_state.set_fs_view(crate::fs_view::PodFsView::new(fs_for_view)); runtime_dir.write_manifest(&manifest_toml).await?; runtime_dir.write_status(&shared_state).await?; runtime_dir.write_history(&shared_state).await?; @@ -527,9 +536,10 @@ impl PodController { break; } - // GetHistory is handled at the socket layer (direct response). - // If it somehow reaches the controller, ignore it. - Method::GetHistory => {} + // GetHistory / ListCompletions are handled at the socket + // layer (direct response). If they somehow reach the + // controller, ignore them. + Method::GetHistory | Method::ListCompletions { .. } => {} Method::PodEvent(event) => { // (1) system side effects — idempotent and @@ -728,7 +738,7 @@ where // drain it at its next pre_llm_request. notify_buffer.push(message); } - Some(Method::GetHistory) => {} + Some(Method::GetHistory | Method::ListCompletions { .. }) => {} Some(Method::PodEvent(event)) => { // mpsc is consume-once, so we cannot defer this // to the next main-loop iteration — drop here diff --git a/crates/pod/src/fs_view.rs b/crates/pod/src/fs_view.rs new file mode 100644 index 00000000..1a02f6e8 --- /dev/null +++ b/crates/pod/src/fs_view.rs @@ -0,0 +1,323 @@ +//! Pod 視点のファイルシステム操作。 +//! +//! `ScopedFs` の上に「Pod が読み取りたい / 列挙したい」操作を集約する軽い wrapper。 +//! +//! - `ReadRequirement` と `render_auto_read` — compact worker が `mark_read_required` +//! で nominate したファイルを再読し、`[Auto-read file: ...]` system message に +//! 変換する経路。`Pod::compact` から呼ばれる。 +//! - `slice_lines` — 行 offset / limit でテキストを切り出す純粋ヘルパ。 +//! compact tool 側の `mark_read_required` でも使用。 +//! - `list_file_completions` — TUI 補完用、prefix マッチでファイル候補を列挙する経路。 +//! IPC `Method::ListCompletions` 経由で呼ばれる前提(Phase 2 で接続)。 + +use std::path::{Path, PathBuf}; + +use llm_worker::Item; +use tools::ScopedFs; +use tracing::warn; + +/// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。 +const COMPLETION_LIMIT: usize = 100; + +/// Compact worker が `mark_read_required` で nominate した「次セッション開始時に +/// 自動で再読すべきファイル」のエントリ。 +#[derive(Debug, Clone)] +pub struct ReadRequirement { + pub path: PathBuf, + /// 0-based line offset. `None` means from the start of the file. + pub offset: Option, + /// Maximum number of lines. `None` means to the end of the file. + pub limit: Option, +} + +/// Pod から見えるファイルシステム操作の入口。Clone は cheap(`ScopedFs` 内 `Arc`)。 +#[derive(Debug, Clone)] +pub struct PodFsView { + fs: ScopedFs, +} + +/// `list_file_completions` が返す候補1件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileCandidate { + /// 入力 prefix と整合する形のパス(prefix が absolute なら absolute、 + /// relative なら pwd 相対)。 + pub path: String, + pub is_dir: bool, +} + +impl PodFsView { + pub fn new(fs: ScopedFs) -> Self { + Self { fs } + } + + pub fn fs(&self) -> &ScopedFs { + &self.fs + } + + /// `requirements` の各エントリを `ScopedFs` 経由で再読し、 + /// `[Auto-read file: :]\n` 形式の system message に変換する。 + /// 読み取り失敗(NotFound / OutOfScope 等)は warn で記録してスキップする + /// — compact 全体を落とさないため。 + pub fn render_auto_read(&self, requirements: &[ReadRequirement]) -> Vec { + let mut out = Vec::with_capacity(requirements.len()); + for req in requirements { + match self.fs.read_bytes(&req.path) { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes).into_owned(); + let body = slice_lines(&text, req.offset.unwrap_or(0), req.limit); + let range = format_range(req.offset, req.limit); + out.push(Item::system_message(format!( + "[Auto-read file: {}{range}]\n{body}", + req.path.display() + ))); + } + Err(e) => { + warn!( + path = %req.path.display(), + error = %e, + "auto-read target could not be read; skipping", + ); + } + } + } + out + } + + /// `prefix` にマッチするファイル / ディレクトリを scope 内で浅く列挙する。 + /// + /// - `prefix` が空 or `pwd` 相対のときは pwd 直下を見る + /// - `prefix` が末尾 `/` のときはそのディレクトリ直下を全列挙 + /// - 末尾が名前部分のときは、その名前を starts_with でフィルタ + /// - scope 上 readable なエントリのみ返す + /// - ディレクトリ → ファイル の順、各グループ内は名前昇順 + /// - 上限 `COMPLETION_LIMIT` 件で打ち切り(深い列挙はしない) + pub fn list_file_completions(&self, prefix: &str) -> Vec { + let pwd = self.fs.pwd(); + let scope = self.fs.scope(); + let (dir, name_prefix, is_absolute) = split_prefix(prefix, pwd); + + let read_dir = match std::fs::read_dir(&dir) { + Ok(rd) => rd, + Err(_) => return Vec::new(), + }; + + let mut out = Vec::new(); + for entry in read_dir.flatten() { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + if !name.starts_with(&name_prefix) { + continue; + } + let path = entry.path(); + if !scope.is_readable(&path) { + continue; + } + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + let display = if is_absolute { + path.display().to_string() + } else { + path.strip_prefix(pwd) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| path.display().to_string()) + }; + out.push(FileCandidate { + path: display, + is_dir, + }); + } + + out.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.path.cmp(&b.path), + }); + out.truncate(COMPLETION_LIMIT); + out + } +} + +/// `text` の `offset` 行目から `limit` 行(None なら末尾まで)を、元の改行で繋いで返す。 +pub fn slice_lines(text: &str, offset: usize, limit: Option) -> String { + let lines: Vec<&str> = text.lines().collect(); + let start = offset.min(lines.len()); + let end = limit + .map(|n| start.saturating_add(n).min(lines.len())) + .unwrap_or(lines.len()); + lines[start..end].join("\n") +} + +fn format_range(offset: Option, limit: Option) -> String { + match (offset, limit) { + (None, None) => String::new(), + (Some(off), None) => format!(":{}-", off + 1), + (None, Some(lim)) => format!(":1-{lim}"), + (Some(off), Some(lim)) => format!(":{}-{}", off + 1, off.saturating_add(lim)), + } +} + +fn split_prefix(prefix: &str, pwd: &Path) -> (PathBuf, String, bool) { + let is_absolute = Path::new(prefix).is_absolute(); + let p = Path::new(prefix); + let (parent, name) = if prefix.is_empty() || prefix.ends_with('/') { + (p.to_path_buf(), String::new()) + } else { + let parent = p.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + let name = p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + (parent, name) + }; + let dir = if is_absolute { + parent + } else if parent.as_os_str().is_empty() { + pwd.to_path_buf() + } else { + pwd.join(parent) + }; + (dir, name, is_absolute) +} + +#[cfg(test)] +mod tests { + use super::*; + use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; + use tempfile::TempDir; + + fn fs_for(dir: &TempDir) -> ScopedFs { + ScopedFs::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + ) + } + + fn touch(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + #[test] + fn slice_lines_handles_offset_and_limit() { + let text = "a\nb\nc\nd"; + assert_eq!(slice_lines(text, 0, None), "a\nb\nc\nd"); + assert_eq!(slice_lines(text, 1, Some(2)), "b\nc"); + assert_eq!(slice_lines(text, 10, None), ""); + } + + #[test] + fn render_auto_read_emits_system_messages_with_range_label() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("hello.txt"); + std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap(); + + let view = PodFsView::new(fs_for(&dir)); + let items = view.render_auto_read(&[ReadRequirement { + path: file.clone(), + offset: Some(1), + limit: Some(1), + }]); + + assert_eq!(items.len(), 1); + let rendered = format!("{:?}", items[0]); + assert!(rendered.contains("Auto-read file")); + assert!(rendered.contains(":2-2")); + assert!(rendered.contains("beta")); + assert!(!rendered.contains("alpha")); + } + + #[test] + fn render_auto_read_skips_unreadable_targets() { + let dir = TempDir::new().unwrap(); + let view = PodFsView::new(fs_for(&dir)); + let items = view.render_auto_read(&[ReadRequirement { + path: dir.path().join("missing.txt"), + offset: None, + limit: None, + }]); + assert!(items.is_empty()); + } + + #[test] + fn list_file_completions_lists_pwd_when_prefix_empty() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("alpha.rs"), ""); + touch(&dir.path().join("beta.rs"), ""); + std::fs::create_dir(dir.path().join("subdir")).unwrap(); + let view = PodFsView::new(fs_for(&dir)); + + let cands = view.list_file_completions(""); + // ディレクトリ first + let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect(); + assert_eq!(names, vec!["subdir", "alpha.rs", "beta.rs"]); + assert!(cands[0].is_dir); + } + + #[test] + fn list_file_completions_filters_by_name_prefix() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("alpha.rs"), ""); + touch(&dir.path().join("beta.rs"), ""); + let view = PodFsView::new(fs_for(&dir)); + + let cands = view.list_file_completions("al"); + assert_eq!(cands.len(), 1); + assert_eq!(cands[0].path, "alpha.rs"); + } + + #[test] + fn list_file_completions_descends_into_subdir_with_trailing_slash() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("sub/x.rs"), ""); + touch(&dir.path().join("sub/y.rs"), ""); + let view = PodFsView::new(fs_for(&dir)); + + let cands = view.list_file_completions("sub/"); + let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect(); + assert_eq!(names, vec!["sub/x.rs", "sub/y.rs"]); + } + + #[test] + fn list_file_completions_filters_out_non_readable_under_scope() { + let dir = TempDir::new().unwrap(); + let secret = dir.path().join("secret"); + std::fs::create_dir(&secret).unwrap(); + touch(&dir.path().join("visible.rs"), ""); + touch(&secret.join("hidden.rs"), ""); + + let cfg = ScopeConfig { + allow: vec![ScopeRule { + target: dir.path().to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: vec![ScopeRule { + target: secret.clone(), + permission: Permission::Read, + recursive: true, + }], + }; + let scope = Scope::from_config(&cfg).unwrap(); + let fs = ScopedFs::new(scope, dir.path().to_path_buf()); + let view = PodFsView::new(fs); + + let cands = view.list_file_completions(""); + let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect(); + assert!(names.contains(&"visible.rs")); + assert!(!names.contains(&"secret")); + } + + #[test] + fn list_file_completions_supports_absolute_prefix() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("a.rs"), ""); + let view = PodFsView::new(fs_for(&dir)); + + let prefix = format!("{}/", dir.path().display()); + let cands = view.list_file_completions(&prefix); + assert_eq!(cands.len(), 1); + assert!(cands[0].path.starts_with('/')); + assert!(cands[0].path.ends_with("a.rs")); + } +} diff --git a/crates/pod/src/ipc/server.rs b/crates/pod/src/ipc/server.rs index 1cec9d12..d9e5d307 100644 --- a/crates/pod/src/ipc/server.rs +++ b/crates/pod/src/ipc/server.rs @@ -89,6 +89,33 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) { // Client methods → handle or forward to controller method = reader.next::() => { match method { + Ok(Some(Method::ListCompletions { kind, prefix })) => { + let entries = match kind { + protocol::CompletionKind::File => handle + .shared_state + .fs_view() + .map(|view| view.list_file_completions(&prefix)) + .unwrap_or_default() + .into_iter() + .map(|c| protocol::CompletionEntry { + value: c.path, + is_dir: c.is_dir, + }) + .collect(), + // Knowledge / Workflow resolvers are not wired + // up yet — reply empty so the TUI sees a + // consistent shape regardless of kind. + protocol::CompletionKind::Knowledge + | protocol::CompletionKind::Workflow => Vec::new(), + }; + if writer + .write(&Event::Completions { kind, entries }) + .await + .is_err() + { + break; + } + } Ok(Some(Method::GetHistory)) => { let items = handle.shared_state.history(); let segments_per_user = handle.shared_state.user_segments(); diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index 62cb146f..3b1e592c 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -1,5 +1,6 @@ pub mod compact; pub mod controller; +pub mod fs_view; pub mod hook; pub mod ipc; pub mod prompt; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 6ea790d2..4d200382 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -970,8 +970,9 @@ impl Pod { use crate::compact::worker::{ CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool, - mark_read_required_tool, slice_lines, write_summary_tool, + mark_read_required_tool, write_summary_tool, }; + use crate::fs_view::PodFsView; // Decide the cut point by projecting the UsageRecord timeline onto // the current history: keep the tail whose estimated token count is @@ -1097,38 +1098,12 @@ impl Pod { .clone() .ok_or(PodError::CompactSummaryMissing)?; - // Re-read each auto-read target through ScopedFs and render the - // requested slice. Errors are logged and skipped rather than + // Re-read each auto-read target via the Pod FS view. Errors are + // logged and skipped inside `render_auto_read` rather than // aborting compaction — a missing / moved file should not fail // the whole compact. - let mut auto_read_messages = Vec::new(); - for req in &final_ctx.read_required { - match scoped_fs.read_bytes(&req.path) { - Ok(bytes) => { - let text = String::from_utf8_lossy(&bytes).into_owned(); - let body = slice_lines(&text, req.offset.unwrap_or(0), req.limit); - let range = match (req.offset, req.limit) { - (None, None) => String::new(), - (Some(off), None) => format!(":{}-", off + 1), - (None, Some(lim)) => format!(":1-{lim}"), - (Some(off), Some(lim)) => { - format!(":{}-{}", off + 1, off.saturating_add(lim)) - } - }; - auto_read_messages.push(Item::system_message(format!( - "[Auto-read file: {}{range}]\n{body}", - req.path.display() - ))); - } - Err(e) => { - warn!( - path = %req.path.display(), - error = %e, - "auto-read target could not be read; skipping", - ); - } - } - } + let auto_read_messages = + PodFsView::new(scoped_fs.clone()).render_auto_read(&final_ctx.read_required); // Reference list as a single system message; omitted when empty. let reference_message = (!final_ctx.references.is_empty()).then(|| { diff --git a/crates/pod/src/shared_state.rs b/crates/pod/src/shared_state.rs index 9160d1d3..216a695f 100644 --- a/crates/pod/src/shared_state.rs +++ b/crates/pod/src/shared_state.rs @@ -1,10 +1,12 @@ -use std::sync::RwLock; +use std::sync::{OnceLock, RwLock}; use llm_worker::llm_client::types::Item; use protocol::Segment; use serde::{Deserialize, Serialize}; use session_store::SessionId; +use crate::fs_view::PodFsView; + /// Shared state between PodController and runtime directory. /// /// Controller updates this in-memory; RuntimeDir writes it to disk. @@ -22,6 +24,13 @@ pub struct PodSharedState { /// segments are not preserved). Surfaced via `Event::History` so /// clients can re-render typed atoms on session restore. pub user_segments: RwLock>>, + /// Pod-from-the-inside view of the filesystem. Set once in + /// `PodController::start` after the `ScopedFs` is materialised, and + /// read from the IPC server layer to answer `ListCompletions` + /// queries without going through the controller. `None` until set + /// (only relevant for unit tests that build a `PodSharedState` + /// directly without spinning up a controller). + fs_view: OnceLock, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -47,9 +56,22 @@ impl PodSharedState { status: RwLock::new(PodStatus::Idle), history: RwLock::new(Vec::new()), user_segments: RwLock::new(Vec::new()), + fs_view: OnceLock::new(), } } + /// Attach the Pod's filesystem view. Called once during controller + /// startup. Subsequent calls are silently ignored (`OnceLock`). + pub fn set_fs_view(&self, view: PodFsView) { + let _ = self.fs_view.set(view); + } + + /// Borrow the attached `PodFsView`, if any. Returns `None` for unit + /// tests that didn't wire one up. + pub fn fs_view(&self) -> Option<&PodFsView> { + self.fs_view.get() + } + pub fn user_segments(&self) -> Vec> { self.user_segments .read() diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 5caabf5e..3fa8d64b 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -33,6 +33,17 @@ pub enum Method { Pause, Shutdown, GetHistory, + /// Request a list of completion candidates from the Pod. + /// + /// Reply is sent on the same socket as `Event::Completions` (not + /// broadcast). Same shape as `GetHistory` / `Event::History`: + /// the IPC server handles this directly and writes the response + /// straight back to the requesting socket. Empty results for + /// resolvers that are not yet wired up (Knowledge / Workflow). + ListCompletions { + kind: CompletionKind, + prefix: String, + }, } /// Typed lifecycle events sent from a child Pod to its parent. @@ -264,6 +275,14 @@ pub enum Event { items: Vec, greeting: Greeting, }, + /// Reply to `Method::ListCompletions`. Delivered only to the + /// requesting socket (not broadcast). `entries` is empty when no + /// candidates match or when the requested kind has no resolver + /// wired up. + Completions { + kind: CompletionKind, + entries: Vec, + }, Alert(Alert), /// Pod has started compacting the current session. /// @@ -316,6 +335,34 @@ pub enum AlertSource { AgentsMd, } +/// Kind of completion requested by `Method::ListCompletions`. +/// +/// Mirrors the TUI prefix sigils: `@` → `File`, `#` → `Knowledge`, +/// `/` → `Workflow`. Knowledge and Workflow resolvers are currently +/// stubs (always reply with empty `entries`); the wire shape is +/// nailed down here so the TUI side can ship without waiting for +/// the memory / workflow tickets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionKind { + File, + Knowledge, + Workflow, +} + +/// One candidate returned in `Event::Completions::entries`. +/// +/// `value` is a path (file kind) or a slug (knowledge / workflow). +/// `is_dir` is meaningful only for the file kind — it lets the TUI +/// keep a trailing `/` after a directory selection so the user can +/// drill in without re-typing the prefix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionEntry { + pub value: String, + #[serde(default)] + pub is_dir: bool, +} + /// Pod self-description rendered by the TUI when a session starts empty. /// /// Built once in the Pod controller from the resolved manifest and @@ -572,6 +619,57 @@ mod tests { assert!(matches!(method, Method::GetHistory)); } + #[test] + fn method_list_completions_roundtrip() { + let method = Method::ListCompletions { + kind: CompletionKind::File, + prefix: "src/".into(), + }; + let json = serde_json::to_string(&method).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["method"], "list_completions"); + assert_eq!(parsed["params"]["kind"], "file"); + assert_eq!(parsed["params"]["prefix"], "src/"); + + let decoded: Method = serde_json::from_str(&json).unwrap(); + match decoded { + Method::ListCompletions { kind, prefix } => { + assert_eq!(kind, CompletionKind::File); + assert_eq!(prefix, "src/"); + } + other => panic!("expected ListCompletions, got {other:?}"), + } + } + + #[test] + fn event_completions_format_and_default_is_dir() { + let event = Event::Completions { + kind: CompletionKind::Workflow, + entries: vec![CompletionEntry { + value: "clear".into(), + is_dir: false, + }], + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["event"], "completions"); + assert_eq!(parsed["data"]["kind"], "workflow"); + assert_eq!(parsed["data"]["entries"][0]["value"], "clear"); + + // is_dir defaults to false on inbound payloads that omit it. + let inbound = r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#; + let decoded: Event = serde_json::from_str(inbound).unwrap(); + match decoded { + Event::Completions { kind, entries } => { + assert_eq!(kind, CompletionKind::File); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].value, "main.rs"); + assert!(!entries[0].is_dir); + } + other => panic!("expected Completions, got {other:?}"), + } + } + #[test] fn event_history_format() { let event = Event::History { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index c29b5e90..3ae2cfc7 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,6 +1,8 @@ use std::time::Instant; -use protocol::{AlertLevel, AlertSource, Event, Method, RunResult, Segment}; +use protocol::{ + AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, RunResult, Segment, +}; use crate::block::{ Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState, @@ -10,6 +12,32 @@ use crate::input::InputBuffer; use crate::scroll::Scroll; use crate::ui::Mode; +/// In-flight completion popup state. Lives on `App` while the user is +/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger +/// is invalidated (cursor moved out, whitespace landed inside the +/// token, the sigil was deleted, or the candidate was confirmed). +pub struct CompletionState { + pub kind: CompletionKind, + /// Atom index of the leading sigil (`@` / `#` / `/`). + pub prefix_start: usize, + /// Text typed after the sigil (sigil itself excluded). + pub prefix: String, + /// Latest candidate set returned by the Pod for `(kind, prefix)`. + /// Initially empty until `Event::Completions` lands. + pub entries: Vec, + pub selected: usize, +} + +impl CompletionState { + pub fn is_active(&self) -> bool { + !self.entries.is_empty() + } + + /// Maximum rows the popup ever renders. Caller can clip to fewer + /// rows if vertical space is tight. + pub const MAX_VISIBLE: usize = 6; +} + pub struct App { pub pod_name: String, pub connected: bool, @@ -39,6 +67,9 @@ pub struct App { /// and future text deltas should append to it instead of starting a /// fresh block. assistant_streaming: bool, + /// Completion popup state, when an `@` / `#` / `/` token is in + /// flight. `None` whenever the trigger conditions don't hold. + pub completion: Option, } impl App { @@ -62,9 +93,93 @@ impl App { mode: Mode::Normal, cache: FileCache::new(), assistant_streaming: false, + completion: None, } } + /// Re-evaluate the completion popup against the current input. + /// Returns a `Method::ListCompletions` to send when the + /// `(kind, prefix_start, prefix)` triple changed; otherwise `None`. + /// Callers should invoke this after every input mutation that could + /// move the cursor or change atoms. + pub fn refresh_completion(&mut self) -> Option { + match self.input.pending_completion_prefix() { + Some((kind, start, prefix)) => { + let need_query = match &self.completion { + Some(c) => c.kind != kind || c.prefix_start != start || c.prefix != prefix, + None => true, + }; + let entries = match self.completion.take() { + Some(c) if c.kind == kind && c.prefix_start == start => c.entries, + _ => Vec::new(), + }; + self.completion = Some(CompletionState { + kind, + prefix_start: start, + prefix: prefix.clone(), + entries, + selected: 0, + }); + if need_query { + Some(Method::ListCompletions { kind, prefix }) + } else { + None + } + } + None => { + self.completion = None; + None + } + } + } + + pub fn move_completion_up(&mut self) { + if let Some(c) = self.completion.as_mut() + && !c.entries.is_empty() + { + c.selected = if c.selected == 0 { + c.entries.len() - 1 + } else { + c.selected - 1 + }; + } + } + + pub fn move_completion_down(&mut self) { + if let Some(c) = self.completion.as_mut() + && !c.entries.is_empty() + { + c.selected = (c.selected + 1) % c.entries.len(); + } + } + + pub fn cancel_completion(&mut self) { + self.completion = None; + } + + /// Confirm the currently selected completion entry by replacing the + /// in-flight token with a chip atom. Returns `true` when something + /// was confirmed; `false` when there was no active candidate (so + /// the caller can fall through to the default key behaviour). + pub fn confirm_completion(&mut self) -> bool { + let Some(state) = self.completion.as_ref() else { + return false; + }; + if state.entries.is_empty() { + return false; + } + let entry = state.entries[state.selected].clone(); + let kind = state.kind; + let start = state.prefix_start; + match kind { + CompletionKind::File => self.input.replace_with_file_ref(start, entry.value), + CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, entry.value), + CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, entry.value), + } + self.completion = None; + true + } + pub fn submit_input(&mut self) -> Option { let segments = self.input.submit_segments(); if segments_are_blank(&segments) { @@ -291,6 +406,17 @@ impl App { Event::History { items, greeting } => { self.restore_history(&items, greeting); } + Event::Completions { kind, entries } => { + // Apply only if the popup is still on the same + // (kind, prefix) the request was issued for; an + // out-of-date reply (the user typed past it) is dropped. + if let Some(state) = self.completion.as_mut() + && state.kind == kind + { + state.entries = entries; + state.selected = 0; + } + } Event::Shutdown => { self.quit = true; } @@ -632,6 +758,105 @@ pub fn alert_source_label(source: AlertSource) -> &'static str { } } +#[cfg(test)] +mod completion_flow_tests { + use super::*; + + #[test] + fn typing_at_creates_completion_state_and_emits_query() { + let mut app = App::new("test".into()); + app.insert_char('@'); + let method = app.refresh_completion(); + match method { + Some(Method::ListCompletions { kind, prefix }) => { + assert_eq!(kind, CompletionKind::File); + assert_eq!(prefix, ""); + } + other => panic!("expected ListCompletions, got {other:?}"), + } + assert!(app.completion.is_some()); + } + + #[test] + fn appending_to_token_emits_updated_query() { + let mut app = App::new("test".into()); + app.insert_char('@'); + let _ = app.refresh_completion(); + app.insert_char('s'); + let method = app.refresh_completion(); + match method { + Some(Method::ListCompletions { kind, prefix }) => { + assert_eq!(kind, CompletionKind::File); + assert_eq!(prefix, "s"); + } + other => panic!("expected ListCompletions, got {other:?}"), + } + } + + #[test] + fn space_after_token_clears_completion_state() { + let mut app = App::new("test".into()); + for c in "@x".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + assert!(app.completion.is_some()); + app.insert_char(' '); + let method = app.refresh_completion(); + assert!(method.is_none()); + assert!(app.completion.is_none()); + } + + #[test] + fn confirm_replaces_token_with_chip_and_clears_popup() { + let mut app = App::new("test".into()); + for c in "@s".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + // Pretend the Pod replied with a single candidate. + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "src/main.rs".into(), + is_dir: false, + }]; + assert!(app.confirm_completion()); + assert!(app.completion.is_none()); + let segs = app.input.submit_segments(); + assert_eq!(segs.len(), 1); + assert!(matches!(&segs[0], Segment::FileRef { path } if path == "src/main.rs")); + } + + #[test] + fn confirm_with_no_entries_is_a_noop() { + let mut app = App::new("test".into()); + for c in "@x".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + // No `Event::Completions` arrived yet — entries is still empty. + assert!(!app.confirm_completion()); + assert!(app.completion.is_some()); + } + + #[test] + fn outdated_completions_event_is_dropped() { + let mut app = App::new("test".into()); + for c in "@x".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + // Reply for a different kind shouldn't overwrite state. + app.handle_pod_event(Event::Completions { + kind: CompletionKind::Workflow, + entries: vec![CompletionEntry { + value: "stale".into(), + is_dir: false, + }], + }); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + } +} + /// Seed / mutate the file-content cache based on a completed tool call. /// /// Each built-in file tool has its own rule: Read copies the result body diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 44b912d0..1671eb57 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -32,17 +32,73 @@ impl PasteRef { } } +/// `@` chip — confirmed completion of a file reference. +#[derive(Debug, Clone)] +pub struct FileRefAtom { + pub path: String, +} + +impl FileRefAtom { + pub fn label(&self) -> String { + format!("@{}", self.path) + } +} + +/// `#` chip — confirmed completion of a Knowledge reference. +#[derive(Debug, Clone)] +pub struct KnowledgeRefAtom { + pub slug: String, +} + +impl KnowledgeRefAtom { + pub fn label(&self) -> String { + format!("#{}", self.slug) + } +} + +/// `/` chip — confirmed completion of a Workflow invocation. +#[derive(Debug, Clone)] +pub struct WorkflowInvokeAtom { + pub slug: String, +} + +impl WorkflowInvokeAtom { + pub fn label(&self) -> String { + format!("/{}", self.slug) + } +} + #[derive(Debug, Clone)] pub enum Atom { Char(char), Paste(PasteRef), + FileRef(FileRefAtom), + KnowledgeRef(KnowledgeRefAtom), + WorkflowInvoke(WorkflowInvokeAtom), +} + +impl Atom { + /// Style + visible label for atoms that render as a single + /// indivisible chip. Returns `None` for `Atom::Char`. + fn chip(&self) -> Option<(Style, String)> { + match self { + Atom::Char(_) => None, + Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())), + Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())), + Atom::KnowledgeRef(r) => Some((Style::default().fg(Color::Green), r.label())), + Atom::WorkflowInvoke(r) => Some((Style::default().fg(Color::Yellow), r.label())), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AtomClass { Word(WordKind), Sep, - Paste, + /// Indivisible chip — paste / file ref / knowledge ref / workflow + /// invocation. Word motion treats one chip as one unit; deletion + /// removes the whole atom. + Chip, } /// Sub-classification of word atoms. A run of equal `WordKind` is one word; @@ -59,8 +115,11 @@ enum WordKind { fn atom_class(atom: &Atom) -> AtomClass { match atom { - Atom::Paste(_) => AtomClass::Paste, Atom::Char(c) => char_class(*c), + Atom::Paste(_) + | Atom::FileRef(_) + | Atom::KnowledgeRef(_) + | Atom::WorkflowInvoke(_) => AtomClass::Chip, } } @@ -134,6 +193,83 @@ impl InputBuffer { self.cursor += 1; } + /// Replace `atoms[start..self.cursor]` (the in-flight `@` / + /// `#` / `/` token) with the corresponding chip atom + /// and place the cursor right after the chip. Used by the completion + /// confirm path. + pub fn replace_with_file_ref(&mut self, start: usize, path: String) { + self.atoms.drain(start..self.cursor); + self.atoms + .insert(start, Atom::FileRef(FileRefAtom { path })); + self.cursor = start + 1; + } + + pub fn replace_with_knowledge_ref(&mut self, start: usize, slug: String) { + self.atoms.drain(start..self.cursor); + self.atoms + .insert(start, Atom::KnowledgeRef(KnowledgeRefAtom { slug })); + self.cursor = start + 1; + } + + pub fn replace_with_workflow_invoke(&mut self, start: usize, slug: String) { + self.atoms.drain(start..self.cursor); + self.atoms.insert( + start, + Atom::WorkflowInvoke(WorkflowInvokeAtom { slug }), + ); + self.cursor = start + 1; + } + + /// If the cursor is currently inside a `@` / `#` / + /// `/` token that satisfies the trigger rules, return the + /// kind, the index of the leading sigil atom, and the typed text + /// after the sigil (sigil itself excluded). + /// + /// Trigger rules: + /// - The sigil (`@` / `#` / `/`) must be preceded by start-of-input, + /// whitespace, or another chip atom — otherwise this is normal + /// text (e.g. the `/` in `src/main.rs` is not a workflow trigger). + /// - Whitespace, newlines and chip atoms invalidate an in-flight + /// token — `@foo /` closes the `@foo` candidate as soon as the + /// space lands. + pub fn pending_completion_prefix(&self) -> Option<(protocol::CompletionKind, usize, String)> { + if self.cursor == 0 { + return None; + } + let mut typed = String::new(); + for i in (0..self.cursor).rev() { + match &self.atoms[i] { + Atom::Char(c) => { + if c.is_whitespace() { + return None; + } + let kind = match c { + '@' => Some(protocol::CompletionKind::File), + '#' => Some(protocol::CompletionKind::Knowledge), + '/' => Some(protocol::CompletionKind::Workflow), + _ => None, + }; + if let Some(k) = kind { + let leading_ok = match self.atoms.get(i.wrapping_sub(1)).filter(|_| i > 0) { + None => true, // start of input + Some(Atom::Char(prev)) => prev.is_whitespace(), + Some(_) => true, // chip + }; + if leading_ok { + return Some((k, i, typed)); + } + } + typed.insert(0, *c); + } + _ => { + // Chip atoms cannot appear inside a candidate token. + return None; + } + } + } + None + } + pub fn delete_before(&mut self) { if self.cursor == 0 { return; @@ -274,20 +410,24 @@ impl InputBuffer { } /// Build the typed `Vec` sent over the protocol. Adjacent - /// `Atom::Char`s are concatenated into a single `Segment::Text`; - /// each `Atom::Paste` becomes a standalone `Segment::Paste` so the - /// `[Clipboard #N | X chars, Y lines]` chip can be reconstructed by - /// any client subscribed to the resulting `Event::UserMessage`. + /// `Atom::Char`s are concatenated into a single `Segment::Text`; each + /// chip atom (`Paste` / `FileRef` / `KnowledgeRef` / `WorkflowInvoke`) + /// becomes a standalone `Segment` so that clients re-rendering an + /// `Event::UserMessage` see the same indivisible chip rather than a + /// flattened string. pub fn submit_segments(&self) -> Vec { let mut out = Vec::new(); let mut buf = String::new(); + let flush_text = |buf: &mut String, out: &mut Vec| { + if !buf.is_empty() { + out.push(protocol::Segment::text(std::mem::take(buf))); + } + }; for a in &self.atoms { match a { Atom::Char(c) => buf.push(*c), Atom::Paste(p) => { - if !buf.is_empty() { - out.push(protocol::Segment::text(std::mem::take(&mut buf))); - } + flush_text(&mut buf, &mut out); out.push(protocol::Segment::Paste { id: p.id, chars: p.chars as u32, @@ -295,6 +435,24 @@ impl InputBuffer { content: p.content.clone(), }); } + Atom::FileRef(r) => { + flush_text(&mut buf, &mut out); + out.push(protocol::Segment::FileRef { + path: r.path.clone(), + }); + } + Atom::KnowledgeRef(r) => { + flush_text(&mut buf, &mut out); + out.push(protocol::Segment::KnowledgeRef { + slug: r.slug.clone(), + }); + } + Atom::WorkflowInvoke(r) => { + flush_text(&mut buf, &mut out); + out.push(protocol::Segment::WorkflowInvoke { + slug: r.slug.clone(), + }); + } } } if !buf.is_empty() { @@ -308,7 +466,6 @@ impl InputBuffer { /// within the wrapped layout. pub fn render(&self, content_width: u16) -> InputRender { let w = content_width.max(1) as usize; - let paste_style = Style::default().fg(Color::Magenta); let text_style = Style::default(); // Row-builder state. `pending` + `pending_width` batch consecutive @@ -347,10 +504,9 @@ impl InputBuffer { let leading = match atom { Atom::Char('\n') => 0, Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0), - Atom::Paste(p) => p - .label() - .chars() - .next() + other => other + .chip() + .and_then(|(_, label)| label.chars().next()) .and_then(UnicodeWidthChar::width) .unwrap_or(0), }; @@ -395,8 +551,9 @@ impl InputBuffer { w, ); } - Atom::Paste(p) => { - if pending_style != paste_style && !pending.is_empty() { + other => { + let (chip_style, label) = other.chip().expect("non-char atom has a chip"); + if pending_style != chip_style && !pending.is_empty() { flush_pending( &mut pending, &mut pending_width, @@ -405,8 +562,8 @@ impl InputBuffer { &mut row_width, ); } - pending_style = paste_style; - for c in p.label().chars() { + pending_style = chip_style; + for c in label.chars() { let cw = UnicodeWidthChar::width(c).unwrap_or(0); place_char( c, @@ -571,6 +728,160 @@ mod submit_segments_tests { assert_eq!(segs.len(), 1); assert!(matches!(segs[0], Segment::Paste { .. })); } + + #[test] + fn file_ref_chip_emits_file_ref_segment() { + let mut buf = InputBuffer::new(); + for c in "see @sr".chars() { + buf.insert_char(c); + } + buf.replace_with_file_ref(4, "src/main.rs".into()); + let segs = buf.submit_segments(); + assert_eq!(segs.len(), 2); + assert!(matches!(&segs[0], Segment::Text { content } if content == "see ")); + match &segs[1] { + Segment::FileRef { path } => assert_eq!(path, "src/main.rs"), + other => panic!("expected FileRef, got {other:?}"), + } + } + + #[test] + fn replace_with_file_ref_swallows_in_flight_token() { + let mut buf = InputBuffer::new(); + for c in "see @sr".chars() { + buf.insert_char(c); + } + // pending_completion_prefix returns the sigil index (4 = '@'). + let (_, start, prefix) = buf.pending_completion_prefix().unwrap(); + assert_eq!(start, 4); + assert_eq!(prefix, "sr"); + buf.replace_with_file_ref(start, "src/main.rs".into()); + let segs = buf.submit_segments(); + assert_eq!(segs.len(), 2); + assert!(matches!(&segs[0], Segment::Text { content } if content == "see ")); + assert!(matches!(&segs[1], Segment::FileRef { path } if path == "src/main.rs")); + } + + #[test] + fn knowledge_and_workflow_chips_emit_typed_segments() { + let mut buf = InputBuffer::new(); + for c in "#r".chars() { + buf.insert_char(c); + } + buf.replace_with_knowledge_ref(0, "rust-style".into()); + buf.insert_char(' '); + for c in "/p".chars() { + buf.insert_char(c); + } + buf.replace_with_workflow_invoke(2, "plan".into()); + let segs = buf.submit_segments(); + assert_eq!(segs.len(), 3); + match &segs[0] { + Segment::KnowledgeRef { slug } => assert_eq!(slug, "rust-style"), + other => panic!("expected KnowledgeRef, got {other:?}"), + } + match &segs[1] { + Segment::Text { content } => assert_eq!(content, " "), + other => panic!("expected Text, got {other:?}"), + } + match &segs[2] { + Segment::WorkflowInvoke { slug } => assert_eq!(slug, "plan"), + other => panic!("expected WorkflowInvoke, got {other:?}"), + } + } +} + +#[cfg(test)] +mod completion_prefix_tests { + use super::*; + use protocol::CompletionKind; + + fn buf_from(text: &str) -> InputBuffer { + let mut buf = InputBuffer::new(); + for c in text.chars() { + buf.insert_char(c); + } + buf + } + + #[test] + fn at_sigil_at_start_triggers_file_completion() { + let buf = buf_from("@sr"); + let (kind, start, prefix) = buf.pending_completion_prefix().unwrap(); + assert_eq!(kind, CompletionKind::File); + assert_eq!(start, 0); + assert_eq!(prefix, "sr"); + } + + #[test] + fn sigil_after_space_triggers() { + let buf = buf_from("see @x"); + let (kind, start, prefix) = buf.pending_completion_prefix().unwrap(); + assert_eq!(kind, CompletionKind::File); + assert_eq!(start, 4); + assert_eq!(prefix, "x"); + } + + #[test] + fn slash_inside_path_is_not_a_workflow_trigger() { + // After `@src/m`, the only valid trigger is `@`, not the `/`. + let buf = buf_from("@src/m"); + let (kind, start, prefix) = buf.pending_completion_prefix().unwrap(); + assert_eq!(kind, CompletionKind::File); + assert_eq!(start, 0); + assert_eq!(prefix, "src/m"); + } + + #[test] + fn space_after_sigil_invalidates_token() { + // `@x ` — once a space lands after the typed text, the candidate + // is gone (until the user types another sigil). + let buf = buf_from("@x "); + assert!(buf.pending_completion_prefix().is_none()); + } + + #[test] + fn sigil_glued_to_word_is_not_a_trigger() { + // `foo@bar` — `@` is preceded by a word char, so it stays plain + // text (covers the case of email addresses and similar). + let buf = buf_from("foo@bar"); + assert!(buf.pending_completion_prefix().is_none()); + } + + #[test] + fn trigger_after_chip_atom() { + let mut buf = InputBuffer::new(); + buf.insert_paste("X".into()); + for c in "@sr".chars() { + buf.insert_char(c); + } + let (kind, start, prefix) = buf.pending_completion_prefix().unwrap(); + assert_eq!(kind, CompletionKind::File); + assert_eq!(start, 1); // chip at 0, sigil at 1 + assert_eq!(prefix, "sr"); + } + + #[test] + fn hash_sigil_triggers_knowledge_completion() { + let buf = buf_from("#abc"); + let (kind, _, prefix) = buf.pending_completion_prefix().unwrap(); + assert_eq!(kind, CompletionKind::Knowledge); + assert_eq!(prefix, "abc"); + } + + #[test] + fn slash_at_start_triggers_workflow_completion() { + let buf = buf_from("/cl"); + let (kind, _, prefix) = buf.pending_completion_prefix().unwrap(); + assert_eq!(kind, CompletionKind::Workflow); + assert_eq!(prefix, "cl"); + } + + #[test] + fn newline_before_cursor_invalidates_trigger() { + let buf = buf_from("@a\nbc"); + assert!(buf.pending_completion_prefix().is_none()); + } } #[cfg(test)] @@ -750,13 +1061,16 @@ mod word_motion_tests { assert_eq!(cursor(&buf), 0); } - /// Render atoms as a string for assertions; pastes become `

`. + /// Render atoms as a string for assertions; chip atoms become `

`. fn as_text(buf: &InputBuffer) -> String { let mut out = String::new(); for a in &buf.atoms { match a { Atom::Char(c) => out.push(*c), - Atom::Paste(_) => out.push_str("

"), + Atom::Paste(_) + | Atom::FileRef(_) + | Atom::KnowledgeRef(_) + | Atom::WorkflowInvoke(_) => out.push_str("

"), } } out diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 4b5ba8a4..54c10703 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -391,6 +391,32 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { _ => {} } + // Completion popup overrides — only when there's something to + // confirm / navigate. An empty popup (request in flight) falls + // through to the default behaviour. + if app.completion.as_ref().is_some_and(|c| c.is_active()) { + match key.code { + KeyCode::Tab | KeyCode::Enter if !alt => { + if app.confirm_completion() { + return None; + } + } + KeyCode::Up => { + app.move_completion_up(); + return None; + } + KeyCode::Down => { + app.move_completion_down(); + return None; + } + KeyCode::Esc => { + app.cancel_completion(); + return None; + } + _ => {} + } + } + match key.code { KeyCode::Char('c') if ctrl => handle_pause_or_quit(app), KeyCode::Char('x') if ctrl => { @@ -402,58 +428,64 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { } } KeyCode::Char('d') if ctrl => handle_shutdown(app), + KeyCode::Esc => { + // Close the popup if it's still showing (covers the + // request-in-flight case where `is_active()` was false). + app.cancel_completion(); + None + } KeyCode::Enter if alt => { app.insert_newline(); - None + app.refresh_completion() } KeyCode::Enter => app.submit_input(), KeyCode::Backspace if ctrl => { app.delete_word_before(); - None + app.refresh_completion() } KeyCode::Backspace => { app.delete_char_before(); - None + app.refresh_completion() } KeyCode::Delete => { app.delete_char_after(); - None + app.refresh_completion() } KeyCode::Left if ctrl => { app.move_cursor_word_left(); - None + app.refresh_completion() } KeyCode::Left => { app.move_cursor_left(); - None + app.refresh_completion() } KeyCode::Right if ctrl => { app.move_cursor_word_right(); - None + app.refresh_completion() } KeyCode::Right => { app.move_cursor_right(); - None + app.refresh_completion() } KeyCode::Up => { app.move_cursor_up(); - None + app.refresh_completion() } KeyCode::Down => { app.move_cursor_down(); - None + app.refresh_completion() } KeyCode::Home => { app.move_cursor_home(); - None + app.refresh_completion() } KeyCode::End => { app.move_cursor_end(); - None + app.refresh_completion() } KeyCode::Char(c) => { app.insert_char(c); - None + app.refresh_completion() } _ => None, } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 8de4032e..1275ee1c 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -17,12 +17,14 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap}; +use ratatui::widgets::{ + Block as UiBlock, BorderType, Borders, Clear, Padding, Paragraph, Widget, Wrap, +}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use protocol::{AlertLevel, Greeting, Segment}; +use protocol::{AlertLevel, CompletionEntry, Greeting, Segment}; -use crate::app::{App, alert_source_label, fmt_tokens}; +use crate::app::{App, CompletionState, alert_source_label, fmt_tokens}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; /// Display density for the history view. @@ -75,6 +77,71 @@ pub fn draw(frame: &mut Frame, app: &mut App) { draw_separator(frame, chunks[1]); draw_status(frame, app, chunks[2]); draw_input(frame, &input_render, chunks[3]); + if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) { + draw_completion_popup(frame, state, chunks[3]); + } +} + +/// Render the candidate list directly above the input area. The popup +/// overlays the status row (and history's bottom rows when it grows +/// taller than that single row); `Clear` blanks the cells first so +/// underlying text doesn't bleed through. The popup width matches the +/// widest visible label, capped at the input-area width. +fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area: Rect) { + let entries = &state.entries; + if entries.is_empty() || input_area.y == 0 { + return; + } + let visible = entries.len().min(CompletionState::MAX_VISIBLE); + // Scroll window keeps the selected item in view. + let view_start = if state.selected + 1 <= visible { + 0 + } else { + state.selected + 1 - visible + }; + let view_end = (view_start + visible).min(entries.len()); + + let label_for = |entry: &CompletionEntry| { + let mut s = entry.value.clone(); + if entry.is_dir { + s.push('/'); + } + s + }; + let max_label = entries[view_start..view_end] + .iter() + .map(|e| label_for(e).chars().count() as u16) + .max() + .unwrap_or(0); + let popup_w = max_label.saturating_add(2).min(input_area.width).max(1); + let popup_h = (visible as u16).min(input_area.y); + let popup_area = Rect::new( + input_area.x, + input_area.y.saturating_sub(popup_h), + popup_w, + popup_h, + ); + + let highlight = Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD); + let dir_style = Style::default().fg(Color::Cyan); + let plain = Style::default(); + + let mut lines: Vec> = Vec::with_capacity(popup_h as usize); + for (i, entry) in entries[view_start..view_end].iter().enumerate() { + let abs = view_start + i; + let text = label_for(entry); + let base = if entry.is_dir { dir_style } else { plain }; + let style = if abs == state.selected { + highlight.patch(base) + } else { + base + }; + lines.push(Line::from(Span::styled(text, style))); + } + frame.render_widget(Clear, popup_area); + frame.render_widget(Paragraph::new(lines), popup_area); } /// Cap the input area so it doesn't eat the history view: grows with the @@ -352,14 +419,11 @@ fn push_padded_lines(lines: &mut Vec>, text: &str, kind: MessageKi } } -/// Render `Block::UserMessage` from typed segments. Paste atoms are -/// reconstructed as `[Clipboard #N | X chars, Y lines]` chips in -/// magenta — matching the input-area presentation — so the user can -/// recognise their own paste in the scrollback. User-entered text uses -/// the standard `MessageKind::User` style; other segment kinds (file / -/// knowledge / workflow refs, unknown variants) render as inline -/// identifiers in the user style and are expected to be rare until the -/// completion ticket lands. +/// Render `Block::UserMessage` from typed segments. Each non-text +/// segment renders as a one-piece chip whose colour matches the input +/// area's chip presentation (paste = magenta, `@` file = cyan, +/// `#` knowledge = green, `/` workflow = yellow), so the user +/// recognises their own typed atoms in the scrollback. fn render_user_message( lines: &mut Vec>, segments: &[Segment], @@ -377,7 +441,6 @@ fn render_user_message( } let user_style = kind_style(MessageKind::User); - let paste_style = Style::default().fg(Color::Magenta); let mut current: Vec> = Vec::new(); for seg in segments { @@ -393,19 +456,9 @@ fn render_user_message( } } } - Segment::Paste { - id, - chars, - lines: line_count, - .. - } => { - current.push(Span::styled( - format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"), - paste_style, - )); - } other => { - current.push(Span::styled(segment_display_text(other), user_style)); + let (style, text) = chip_span_for(other, user_style); + current.push(Span::styled(text, style)); } } } @@ -414,6 +467,30 @@ fn render_user_message( } } +/// Style + display text for a single chip-style `Segment`. `fallback` +/// is used for `Segment::Text` (which the caller handles inline) and +/// for `Segment::Unknown` so future variants degrade gracefully. +fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) { + match seg { + Segment::Text { content } => (fallback, content.clone()), + Segment::Paste { + id, + chars, + lines: line_count, + .. + } => ( + Style::default().fg(Color::Magenta), + format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"), + ), + Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")), + Segment::KnowledgeRef { slug } => (Style::default().fg(Color::Green), format!("#{slug}")), + Segment::WorkflowInvoke { slug } => { + (Style::default().fg(Color::Yellow), format!("/{slug}")) + } + Segment::Unknown => (fallback, "[unknown segment]".to_owned()), + } +} + /// One-line textual rendering of a segment, used by `Mode::Overview` /// (which collapses everything to a single string) and as the fallback /// inline rendering for non-paste, non-text segments. diff --git a/tickets/submit-tui-completion.md b/tickets/submit-tui-completion.md index 9e9da91d..fe1caa90 100644 --- a/tickets/submit-tui-completion.md +++ b/tickets/submit-tui-completion.md @@ -16,28 +16,38 @@ - `#<部分slug>` — Knowledge slug - `/<部分slug>` — Workflow slug + client-side コマンド(`/clear` など) +トリガー条件: + +- prefix 記号の直前が「行頭 or 空白 or paste 等の indivisible atom」のときのみ候補ポップアップを開く +- 開いた後に prefix 直後がスペースになった時点(例: `@ `, `/ `)で候補を閉じ、通常テキストに戻す + 確定(Tab / Enter 等、入力 UX の詳細は実装で)で対象範囲を `Atom::FileRef` / `Atom::KnowledgeRef` / `Atom::WorkflowInvoke` の indivisible atom に置き換える。挙動は既存の `Atom::Paste` と同等(cursor は中に入れない、Backspace で塊ごと削除)。submit 時に対応する `Segment` 変種に変換して送る。 ### 候補列挙のための protocol query -補完用に Pod へ問い合わせる軽量経路を追加: +補完用に Pod へ問い合わせる軽量経路を追加。`Method::GetHistory` と同じパターンを踏襲する: 専用 `Method` variant を受信した IPC server 層 (`crates/pod/src/ipc/server.rs`) で Controller を介さず直接処理し、対応する `Event` 変種を同ソケットに write back する(broadcast には流さない)。 -- ファイル候補(scope 内、prefix マッチ) -- Knowledge / Workflow slug 候補(kind 指定 + prefix マッチ) +扱う query: -`Event` ストリームに載せる性質ではないため、request/response 形式を新設する(具体形式は実装で判断、既存 `Method` の枠に増やすか別経路を作るかも実装側で決める)。 +- ファイル候補(Pod scope 内、prefix マッチ)— 本チケットで実装 +- Knowledge / Workflow slug 候補(kind 指定 + prefix マッチ)— wire の枠だけ用意し、resolver 未登録時は空応答。実体はそれぞれのチケット側 + +### Pod 側ファイル resolver(auto-read 切り出し) + +現状 `crates/pod/src/compact/worker.rs` の `mark_read_required` ツールと `crates/pod/src/pod.rs` の再読ロジックに散らばっている auto-read 機構を、「Pod から見たファイルシステム操作」を担う独立モジュール(または新規クレート)に集約する: + +- 既存の auto-read(`ScopedFs` 経由の読み込み + budget 管理)をこのモジュールに移動 +- 補完候補の prefix マッチ列挙を同モジュールに新設 + +このモジュールは Pod の Interceptor / Hook 経路から呼び出される。compact からの利用も新モジュール経由に切り替える。memory / workflow チケットの resolver はここには含めない。 ### 表示 確定後の atom は paste と同じ「indivisible chip」スタイルで描画する。`@` / `#` / `/` ごとに色を変える程度の差異化を入れる。`Block::UserMessage` 側でも同一スタイルで再描画する(`Event::UserMessage` が typed segment で来る前提)。 -### client-side `/` の dispatch - -`/clear` のような client 完結コマンドは Pod に送らず TUI 内で処理する。TUI 内に簡易な dispatch 表を持ち、未知の `/` は `Segment::WorkflowInvoke` として送る。初期 dispatch 表は `/clear` 程度で良く、拡張は別途。 - ## 範囲外 -- Pod 側の resolver 実装(memory / workflow チケット) +- Knowledge / Workflow slug の Pod 側 resolver 実装(それぞれ memory / workflow チケット側で実装。本チケットでは wire の枠と空応答のみ) - 候補スコアリング、fuzzy search、preview 等の高度な補完体験 - リッチクライアント(GUI / web)の同等 UX @@ -46,7 +56,6 @@ - `@` / `#` / `/` を打鍵すると候補が出て、確定で chip 化される - chip 化された atom が対応する `Segment` として Pod に送出され、`Event::UserMessage` で戻ってきた typed segment が同じ見た目で再描画される - 候補列挙の query / response が wire を通る -- `/clear` が client-side で処理され、Pod には届かない - 既存ビルド・テストを壊さない ## 依存 @@ -57,5 +66,13 @@ - `crates/tui/src/input.rs`(`Atom` 体系の拡張) - `crates/tui/src/app.rs`(`submit_input`、`Block::UserMessage` 描画) +- `crates/pod/src/ipc/server.rs`(`GetHistory` パターン: Method 受信 → 同ソケットに Event を直接 write back) +- `crates/pod/src/compact/worker.rs` / `crates/pod/src/pod.rs`(切り出し対象の auto-read 機構) - `docs/plan/memory.md` §retrieval 経路(slug 補完対象) - `docs/plan/workflow.md` §呼び出しと依存 + +## Review + +- 状態: Approve +- レビュー詳細: [./submit-tui-completion.review.md](./submit-tui-completion.review.md) +- 日付: 2026-04-30 diff --git a/tickets/submit-tui-completion.review.md b/tickets/submit-tui-completion.review.md new file mode 100644 index 00000000..b747e9d0 --- /dev/null +++ b/tickets/submit-tui-completion.review.md @@ -0,0 +1,48 @@ +# Review: サブミット入力 — TUI 補完 + 型付き atom 化 + +レビュー対象: develop ブランチ作業ツリー(未コミット差分含む)。 +スコープ: チケット `tickets/submit-tui-completion.md` の Phase 1〜4 全体。 + +## 前提・要件の確認 + +- **`@` / `#` / `/` 入力中の候補ポップアップ**: `crates/tui/src/input.rs:235-271` の `pending_completion_prefix` がトリガー検出を担い、`crates/tui/src/main.rs:397-417` で popup active 時のキーがオーバーライドされる。要件通り。 +- **トリガー条件 (sigil 直前 = 行頭/空白/chip atom のみ、sigil 直後スペースで閉じる)**: `pending_completion_prefix` 内 `leading_ok` で判定し、`completion_prefix_tests` (input.rs:794-885) で 9 ケースカバー。`@src/main.rs` の `/` 誤検出回避、`@x ` での閉鎖、`foo@bar` 非トリガー等、要件のエッジを押さえている。 +- **確定で indivisible chip に置換 (Backspace で塊削除)**: `replace_with_*` (input.rs:200-221) で `drain` + 単一 atom 挿入し cursor を chip 直後に置く。chip atom は `AtomClass::Chip` として既存 word motion / delete_word_before の単位扱いになっており (`atom_class`、`paste_counts_as_one_word` テスト)、Paste と同等の挙動。 +- **submit 時に対応 `Segment` 変換**: `submit_segments` (input.rs:418-462) が FileRef/KnowledgeRef/WorkflowInvoke を専用 Segment に分けて出す。`knowledge_and_workflow_chips_emit_typed_segments` でカバー。 +- **候補列挙の protocol query (GetHistory パターン踏襲)**: `Method::ListCompletions` / `Event::Completions` を `crates/protocol/src/lib.rs` に追加、`crates/pod/src/ipc/server.rs:91-118` で同ソケットに直接 reply、broadcast 経路なし。`event_completions_format_and_default_is_dir` / `method_list_completions_roundtrip` で wire 形を固定。 +- **ファイル resolver = auto-read 切り出し**: `crates/pod/src/fs_view.rs` を新設し、旧 `compact/worker.rs` の `slice_lines` / `ReadRequirement` と旧 `pod.rs` の自動再読ロジックを `PodFsView::render_auto_read` に集約。compact 側は再エクスポート経由で利用 (compact/worker.rs:31)。`Pod::compact` も `PodFsView::new(scoped_fs.clone()).render_auto_read(...)` で同経路を通る (pod.rs:1106)。 +- **Knowledge / Workflow resolver は wire のみ・空応答**: ipc/server.rs:108-110 で `CompletionKind::Knowledge | Workflow => Vec::new()` と分岐済み。 +- **chip 表示**: 入力エリアは `Atom::chip()` で全 chip 共通 render (input.rs:83-92)、`Block::UserMessage` は `chip_span_for` で同色 (ui.rs:473-492)。Paste=Magenta, `@`=Cyan, `#`=Green, `/`=Yellow を input area / Block で一致。 +- **Event::UserMessage の typed segment 経路**: 既存 `submit-segment-protocol.md` が完結済みで、本チケットは Atom 拡張を載せるのみ。 + +完了条件 4 項目すべて満たしている。`/clear` の client-side 分岐は仕様変更でチケット側から削除済み。 + +## アーキテクチャ・スコープ + +- **`fs_view` モジュールの境界**: 「Pod から見たファイルシステム操作」という単一の責務に絞れており、auto-read と補完列挙が同居しているのは妥当 (どちらも ScopedFs + pwd + scope 上の readable 判定の薄い wrapper)。Knowledge/Workflow resolver は別スロットに将来追加される設計で、`PodFsView` に詰め込む流れにはなっていない (server.rs の kind マッチで分岐済み)。`crates/pod/src/lib.rs` 直下に置く粒度も現状の他モジュール (compact, hook, prompt) と整合。 +- **`PodSharedState::fs_view: OnceLock`**: 周辺は `RwLock` 系だが、用途が「controller 起動時に1回 attach、以降 read-only」なので `OnceLock` の方が意図に合う。`set_fs_view` が `set` の戻り値を捨てているのは「unit test が直接生成した state にも噛み合う」コメント通りで適切。Controller 側 (controller.rs:113, 236, 289) で `fs_for_view: tools::ScopedFs;` を宣言してブロック内で代入し、ブロック後に attach する流れも冗長ではない (`fs.clone()` 1回のみ)。 +- **不必要な抽象化**: 見当たらない。`FileCandidate`, `CompletionEntry` の二重定義は protocol 層と pod 内部層で意図的に分離されており (protocol は wire、pod 内部は path 構造)、`server.rs` の map で繋がっている。 +- **将来用 dead code**: なし。`CompletionKind::Knowledge | Workflow` は ipc/server.rs で実際にヒットする分岐 (空応答)、`is_dir` フィールドは popup の見た目に使われている。 +- **コードベースを歪めていないか**: pod 側は単純な抽出、TUI 側は既存 `Atom::Paste` の扱いをそのまま FileRef/KnowledgeRef/WorkflowInvoke に拡張する素直な拡張で、新規の概念導入は最小限。LLM provider policy / cargo add / クレート命名等の方針には影響しない。 + +## 指摘事項 + +### Non-blocking / Follow-up + +- **`is_dir` 候補の確定挙動が popup の hint と乖離している** — `crates/protocol/src/lib.rs` の `CompletionEntry` doc コメント (約 357 行目付近): + > `is_dir` is meaningful only for the file kind — it lets the TUI keep a trailing `/` after a directory selection so the user can drill in without re-typing the prefix. + と書かれているが、`App::confirm_completion` (`crates/tui/src/app.rs:164-181`) は `entry.value` をそのまま `replace_with_file_ref` に渡すだけで、ディレクトリの場合に `/` を保持して入力継続させる経路がない。popup 側 (`ui.rs:104-110`) は `entry.is_dir` のとき表示に `/` を足しているので、ユーザーには「Tab で drill in できそう」に見えるが実際は chip として閉じる。 + 対応案 (どちらも本チケット範囲外で良い): + - doc コメントを実装に合わせて「`is_dir` は popup 表示のヒントのみ」に書き換える + - もしくは「ディレクトリ確定時は chip にせず `@subdir/` のテキストを残してカーソルを末尾に置く」挙動を追加する +- **Paste 後に `refresh_completion()` が呼ばれない** — `crates/tui/src/main.rs:305-307` の `TermEvent::Paste(s) => app.insert_paste(s);` には refresh 呼び出しがない。`@s` の途中でクリップボード貼付した場合、popup state は古い `(kind, prefix_start, prefix)` のまま残り、次のキーストロークで refresh されるまで 1 フレーム不整合な popup が見える可能性がある。`insert_char` 系と同様に `app.refresh_completion()` を呼んで Method を返す形にしておくのが整合的。 +- **`compact/worker.rs:358-364` の `slice_lines_handles_offset_and_limit` テストが `fs_view.rs:201-207` と重複** — 関数自体は `fs_view` に移動し compact 側は `use` のみなので、テストもこの移動に合わせて削除して良い (fs_view 側で同一カバレッジが取れている)。 + +### Nits + +- `pending_completion_prefix` 内の `self.atoms.get(i.wrapping_sub(1)).filter(|_| i > 0)` (input.rs:253) は意図通り動くが、`if i == 0 { None } else { self.atoms.get(i - 1) }` の方が読みやすい。 +- `controller.rs:113` の `let fs_for_view: tools::ScopedFs;` 宣言-後代入パターンは正しいが、`fs` 自身を最後に消費しているのを `fs.clone()` を `fs_for_view = fs.clone();` で先に取ってから `register_tools(tools::builtin_tools(fs, ...))` の方が見た目が単純。ただし現状でもコメントで意図が説明されており、可読性の優劣は微差。 + +## 判断 + +**Approve** — 完了条件 4 項目を満たし、アーキテクチャ・スコープともに歪みなし。フォローアップ事項はいずれも UX 補足やテスト整理で、ブロックすべきものではない。