tuiの補完の実装

This commit is contained in:
Keisuke Hirata 2026-04-30 12:46:48 +09:00
parent f914ae235a
commit 623b54cefc
14 changed files with 1274 additions and 125 deletions

View File

@ -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<usize>,
/// Maximum number of lines. `None` means to the end of the file.
pub limit: Option<usize>,
}
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<usize>) -> 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::*;

View File

@ -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

323
crates/pod/src/fs_view.rs Normal file
View File

@ -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<usize>,
/// Maximum number of lines. `None` means to the end of the file.
pub limit: Option<usize>,
}
/// 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: <path>:<range>]\n<body>` 形式の system message に変換する。
/// 読み取り失敗NotFound / OutOfScope 等)は warn で記録してスキップする
/// — compact 全体を落とさないため。
pub fn render_auto_read(&self, requirements: &[ReadRequirement]) -> Vec<Item> {
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<FileCandidate> {
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<usize>) -> 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<usize>, limit: Option<usize>) -> 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"));
}
}

View File

@ -89,6 +89,33 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
// Client methods → handle or forward to controller
method = reader.next::<Method>() => {
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();

View File

@ -1,5 +1,6 @@
pub mod compact;
pub mod controller;
pub mod fs_view;
pub mod hook;
pub mod ipc;
pub mod prompt;

View File

@ -970,8 +970,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
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<C: LlmClient, St: Store> Pod<C, St> {
.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(|| {

View File

@ -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<Vec<Vec<Segment>>>,
/// 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<PodFsView>,
}
#[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<Vec<Segment>> {
self.user_segments
.read()

View File

@ -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<serde_json::Value>,
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<CompletionEntry>,
},
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 {

View File

@ -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<CompletionEntry>,
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<CompletionState>,
}
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<Method> {
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<Method> {
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

View File

@ -32,17 +32,73 @@ impl PasteRef {
}
}
/// `@<path>` 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)
}
}
/// `#<slug>` 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)
}
}
/// `/<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 `@<typed>` /
/// `#<typed>` / `/<typed>` 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 `@<typed>` / `#<typed>` /
/// `/<typed>` 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<Segment>` 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<protocol::Segment> {
let mut out = Vec::new();
let mut buf = String::new();
let flush_text = |buf: &mut String, out: &mut Vec<protocol::Segment>| {
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 `<P>`.
/// Render atoms as a string for assertions; chip atoms become `<P>`.
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("<P>"),
Atom::Paste(_)
| Atom::FileRef(_)
| Atom::KnowledgeRef(_)
| Atom::WorkflowInvoke(_) => out.push_str("<P>"),
}
}
out

View File

@ -391,6 +391,32 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
_ => {}
}
// 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<Method> {
}
}
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,
}

View File

@ -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<Line<'static>> = 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<Line<'static>>, 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<Line<'static>>,
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<Span<'static>> = 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.

View File

@ -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 側ファイル resolverauto-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 `/<slug>` の dispatch
`/clear` のような client 完結コマンドは Pod に送らず TUI 内で処理する。TUI 内に簡易な dispatch 表を持ち、未知の `/<slug>``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

View File

@ -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<PodFsView>`**: 周辺は `RwLock<T>` 系だが、用途が「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 補足やテスト整理で、ブロックすべきものではない。