Compare commits
3 Commits
9177ee8ef3
...
75c61bd3cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c61bd3cb | |||
| 6788db1ef2 | |||
| 4b09ff0234 |
1
.insomnia/.gitignore
vendored
Normal file
1
.insomnia/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
_memory
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
[memory]
|
||||
extract_threshold = 4000
|
||||
extract_threshold = 10000
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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
323
crates/pod/src/fs_view.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod compact;
|
||||
pub mod controller;
|
||||
pub mod fs_view;
|
||||
pub mod hook;
|
||||
pub mod ipc;
|
||||
pub mod prompt;
|
||||
|
|
|
|||
|
|
@ -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(|| {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,58 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -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,188 @@ 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;
|
||||
}
|
||||
|
||||
/// Tab path: insert the popup-selected entry's value (with a
|
||||
/// trailing `/` when it's a directory) as raw text replacing the
|
||||
/// in-flight `@<typed>` portion. The popup state is preserved so
|
||||
/// the re-evaluated trigger can fetch fresh candidates for the new
|
||||
/// prefix (drill-in for directories, narrow-to-one for files).
|
||||
/// Returns the follow-up `Method::ListCompletions` to send when
|
||||
/// the new prefix differs from the old one.
|
||||
pub fn apply_completion_text(&mut self) -> Option<Method> {
|
||||
let state = self.completion.as_ref()?;
|
||||
if state.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entry = &state.entries[state.selected];
|
||||
let text = if entry.is_dir {
|
||||
format!("{}/", entry.value)
|
||||
} else {
|
||||
entry.value.clone()
|
||||
};
|
||||
// `prefix_start` indexes the sigil atom; the text we want to
|
||||
// replace lives just after it (sigil itself stays).
|
||||
let typed_start = state.prefix_start + 1;
|
||||
self.input.replace_with_text_at(typed_start, &text);
|
||||
self.refresh_completion()
|
||||
}
|
||||
|
||||
/// Space path: replace the `@<typed>` range with a chip atom and
|
||||
/// clear the popup if `prefix` (= the text the user has typed
|
||||
/// after the sigil) resolves to a confirmable target. Three
|
||||
/// matching modes:
|
||||
///
|
||||
/// 1. **Direct value match**: some entry's `value` equals `prefix`
|
||||
/// (covers files and slash-less directory form).
|
||||
/// 2. **Slashed directory match**: some directory entry's
|
||||
/// `value + "/"` equals `prefix` (the form Tab inserts).
|
||||
/// 3. **Drilled-into-directory match**: `prefix` ends with `/`
|
||||
/// and at least one entry lives under it.
|
||||
///
|
||||
/// Directory chips always carry a trailing `/` so the rendered
|
||||
/// label reads `@crates/`.
|
||||
///
|
||||
/// `selected` is intentionally ignored — terminating with a
|
||||
/// space is a typed-based "I'm done with this token" signal,
|
||||
/// so a race-y top entry shouldn't block confirmation when the
|
||||
/// typed text matches another entry.
|
||||
pub fn chipify_completion_if_exact_match(&mut self) -> bool {
|
||||
let Some(state) = self.completion.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let direct = state
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| {
|
||||
state.prefix == e.value || (e.is_dir && state.prefix == format!("{}/", e.value))
|
||||
})
|
||||
.map(|e| {
|
||||
if e.is_dir {
|
||||
format!("{}/", e.value)
|
||||
} else {
|
||||
e.value.clone()
|
||||
}
|
||||
});
|
||||
let drilled = (direct.is_none() && state.prefix.ends_with('/'))
|
||||
.then(|| {
|
||||
state
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| e.value.starts_with(&state.prefix))
|
||||
.then(|| state.prefix.clone())
|
||||
})
|
||||
.flatten();
|
||||
let Some(value) = direct.or(drilled) else {
|
||||
return false;
|
||||
};
|
||||
let kind = state.kind;
|
||||
let start = state.prefix_start;
|
||||
match kind {
|
||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
||||
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value),
|
||||
}
|
||||
self.completion = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Enter path: commit the currently *selected* popup entry,
|
||||
/// regardless of how much of its value the user has typed. This
|
||||
/// is the popup-UI sense of "Enter accepts the highlighted
|
||||
/// suggestion" — partial typing like `@README.` followed by
|
||||
/// Enter should chip when the popup is on `README.md`.
|
||||
///
|
||||
/// Files (and Knowledge / Workflow entries, which have no dir
|
||||
/// concept) chipify here. Directory file entries return `false`
|
||||
/// so the caller can fall through to `apply_completion_text`
|
||||
/// for drill-in — chip-ifying a directory on Enter would strand
|
||||
/// the user with no way to inspect children.
|
||||
pub fn chipify_selected_completion_if_committable(&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];
|
||||
if state.kind == CompletionKind::File && entry.is_dir {
|
||||
return false;
|
||||
}
|
||||
let kind = state.kind;
|
||||
let start = state.prefix_start;
|
||||
let value = entry.value.clone();
|
||||
match kind {
|
||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
||||
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, 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 +501,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 +853,328 @@ 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 tab_inserts_entry_value_as_text_for_file() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@s".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "src/main.rs".into(),
|
||||
is_dir: false,
|
||||
}];
|
||||
// Tab path: text inserted, popup re-triggered with new prefix
|
||||
// (still File kind since the typed range stays after `@`).
|
||||
let _ = app.apply_completion_text();
|
||||
// The input now reads `@src/main.rs` as plain Char atoms; no
|
||||
// chip yet.
|
||||
let segs = app.input.submit_segments();
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert!(matches!(&segs[0], Segment::Text { content } if content == "@src/main.rs"));
|
||||
assert!(app.completion.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_appends_trailing_slash_for_directory() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@cr".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "crates".into(),
|
||||
is_dir: true,
|
||||
}];
|
||||
let _ = app.apply_completion_text();
|
||||
// Typed prefix advances to `crates/` so the next query can
|
||||
// descend into the directory.
|
||||
assert_eq!(app.completion.as_ref().unwrap().prefix, "crates/");
|
||||
let segs = app.input.submit_segments();
|
||||
assert!(matches!(&segs[0], Segment::Text { content } if content == "@crates/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_chipifies_on_exact_match() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@src/main.rs".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "src/main.rs".into(),
|
||||
is_dir: false,
|
||||
}];
|
||||
assert!(app.chipify_completion_if_exact_match());
|
||||
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 space_does_not_chipify_on_partial_match() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@s".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "src/main.rs".into(),
|
||||
is_dir: false,
|
||||
}];
|
||||
// typed = "s", expected = "src/main.rs" → no match, no chip.
|
||||
assert!(!app.chipify_completion_if_exact_match());
|
||||
let segs = app.input.submit_segments();
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert!(matches!(&segs[0], Segment::Text { content } if content == "@s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_chipifies_directory_with_or_without_trailing_slash() {
|
||||
// Slash-less typed form chipifies the directory; the chip's
|
||||
// path keeps a trailing slash so the rendered label is `@crates/`.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@crates".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "crates".into(),
|
||||
is_dir: true,
|
||||
}];
|
||||
assert!(app.chipify_completion_if_exact_match());
|
||||
let segs = app.input.submit_segments();
|
||||
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/"));
|
||||
|
||||
// Slashed typed form (the shape Tab inserts) — same chip.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@crates/".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "crates".into(),
|
||||
is_dir: true,
|
||||
}];
|
||||
assert!(app.chipify_completion_if_exact_match());
|
||||
let segs = app.input.submit_segments();
|
||||
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_chipifies_directory_when_popup_shows_its_children() {
|
||||
// `@crates/` is the form Tab leaves you in after picking a
|
||||
// directory; the popup is showing the children of `crates/`.
|
||||
// Hitting space at this point should chipify `crates`, not
|
||||
// require the user to back up and remove the trailing slash.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@crates/".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![
|
||||
CompletionEntry {
|
||||
value: "crates/daemon".into(),
|
||||
is_dir: true,
|
||||
},
|
||||
CompletionEntry {
|
||||
value: "crates/llm-worker".into(),
|
||||
is_dir: true,
|
||||
},
|
||||
];
|
||||
assert!(app.chipify_completion_if_exact_match());
|
||||
let segs = app.input.submit_segments();
|
||||
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_does_not_chipify_directory_so_drill_in_works() {
|
||||
// Enter on a selected directory entry must NOT chipify —
|
||||
// otherwise the user can never drill into the dir to see
|
||||
// its children.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@crates".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "crates".into(),
|
||||
is_dir: true,
|
||||
}];
|
||||
assert!(!app.chipify_selected_completion_if_committable());
|
||||
// Popup is still active so the caller can fall through to
|
||||
// apply_completion_text.
|
||||
assert!(app.completion.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_path_appends_trailing_space_after_file_chip() {
|
||||
// Mirrors the main.rs Enter handler sequence: chipify the
|
||||
// selected entry, then insert a space so the cursor is ready
|
||||
// for the next token without a manual separator.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@README.".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "README.md".into(),
|
||||
is_dir: false,
|
||||
}];
|
||||
assert!(app.chipify_selected_completion_if_committable());
|
||||
app.insert_char(' ');
|
||||
let segs = app.input.submit_segments();
|
||||
assert_eq!(segs.len(), 2);
|
||||
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "README.md"));
|
||||
assert!(matches!(&segs[1], Segment::Text { content } if content == " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_chipifies_selected_file_even_when_typed_is_partial() {
|
||||
// Enter respects the selected entry: typed text may be a
|
||||
// prefix of the entry's value, but the popup-highlighted
|
||||
// file should still chipify on Enter.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@README.".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "README.md".into(),
|
||||
is_dir: false,
|
||||
}];
|
||||
assert!(app.chipify_selected_completion_if_committable());
|
||||
assert!(app.completion.is_none());
|
||||
let segs = app.input.submit_segments();
|
||||
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "README.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_does_not_chipify_drilled_state_with_unrelated_entries() {
|
||||
// Stale entries that don't live under the typed prefix should
|
||||
// not satisfy the drilled-into-directory rule.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@xyz/".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "crates/daemon".into(),
|
||||
is_dir: true,
|
||||
}];
|
||||
assert!(!app.chipify_completion_if_exact_match());
|
||||
let segs = app.input.submit_segments();
|
||||
assert!(matches!(&segs[0], Segment::Text { content } if content == "@xyz/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chipify_finds_match_outside_selected_index() {
|
||||
// Regression guard for the race where a stale reply leaves a
|
||||
// non-matching entry at index 0 but an entry deeper in the
|
||||
// list does match the current typed text.
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@src/main.rs".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
app.completion.as_mut().unwrap().entries = vec![
|
||||
CompletionEntry {
|
||||
value: "src/main.rs.bak".into(),
|
||||
is_dir: false,
|
||||
},
|
||||
CompletionEntry {
|
||||
value: "src/main.rs".into(),
|
||||
is_dir: false,
|
||||
},
|
||||
];
|
||||
// selected stays at 0 (the non-matching one) but find() should
|
||||
// still locate the match.
|
||||
assert!(app.chipify_completion_if_exact_match());
|
||||
let segs = app.input.submit_segments();
|
||||
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_completion_text_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.apply_completion_text().is_none());
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,10 @@ 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 +192,96 @@ 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;
|
||||
}
|
||||
|
||||
/// Replace `atoms[start..self.cursor]` with the chars of `text`,
|
||||
/// leaving cursor at the end of the inserted run. Used by the Tab
|
||||
/// completion path: the popup-selected entry is inserted as raw
|
||||
/// text (not a chip) so the user can keep typing — e.g. drill into
|
||||
/// a directory whose value ends with `/`.
|
||||
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
|
||||
self.atoms.drain(start..self.cursor);
|
||||
let mut idx = start;
|
||||
for c in text.chars() {
|
||||
self.atoms.insert(idx, Atom::Char(c));
|
||||
idx += 1;
|
||||
}
|
||||
self.cursor = idx;
|
||||
}
|
||||
|
||||
/// 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 +422,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 +447,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 +478,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 +516,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 +563,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 +574,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 +740,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 +1073,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
|
||||
|
|
|
|||
|
|
@ -391,6 +391,51 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
_ => {}
|
||||
}
|
||||
|
||||
// Completion popup overrides — only when there's something to
|
||||
// navigate / commit. 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 if !alt => {
|
||||
// Insert the selected entry as raw text and let the
|
||||
// re-triggered popup fetch fresh candidates (drill-in
|
||||
// for directories, narrow-to-exact for files).
|
||||
return app.apply_completion_text();
|
||||
}
|
||||
KeyCode::Enter if !alt => {
|
||||
// While the popup has selectable entries, Enter
|
||||
// commits the selection rather than submitting the
|
||||
// message. The selected entry wins regardless of how
|
||||
// much of its value the user has typed — Enter on a
|
||||
// popup entry is "accept this suggestion". Directory
|
||||
// entries are the exception: they fall through to
|
||||
// text insertion so the popup re-fetches children
|
||||
// for drill-in. After a successful chip we append a
|
||||
// trailing space so the user can keep writing without
|
||||
// a manual separator (the Space path already has the
|
||||
// space the user typed, so it's not needed there).
|
||||
if app.chipify_selected_completion_if_committable() {
|
||||
app.insert_char(' ');
|
||||
return None;
|
||||
}
|
||||
return app.apply_completion_text();
|
||||
}
|
||||
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 +447,73 @@ 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) => {
|
||||
// Whitespace ends an in-flight completion token. Try the
|
||||
// auto-confirm path first so an exact match (e.g. typed
|
||||
// `@src/main.rs` matches the only popup entry) becomes a
|
||||
// chip on the way out. Directories also commit here —
|
||||
// ending with a space is an explicit "I want this dir"
|
||||
// signal, not a drill-in.
|
||||
if c.is_whitespace() {
|
||||
app.chipify_completion_if_exact_match();
|
||||
}
|
||||
app.insert_char(c);
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
182
docs/ref/claude-code-deferred-tools.md
Normal file
182
docs/ref/claude-code-deferred-tools.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# リファレンス: Claude Code の deferred tools 機構
|
||||
|
||||
調査日: 2026-04-30。Claude Code(このセッションのハーネス)が採用しているツール提示・遅延ロード方式について、自身の system prompt と system-reminder の内容から観察できた事実と、そこから合理的に推測される実装方針をまとめる。Pod / insomnia でハーネス側のツール抽象を設計する際の参考資料。
|
||||
|
||||
ハーネス内部の実装は確認していないため、観察事実と推測を分けて記載する。
|
||||
|
||||
---
|
||||
|
||||
## 1. 観察事実
|
||||
|
||||
### 1.1 ツール定義の表現
|
||||
|
||||
system prompt 冒頭に、ツール定義が以下の形式の **テキストブロック** として埋め込まれている:
|
||||
|
||||
```
|
||||
<functions>
|
||||
<function>{"description": "...", "name": "Read", "parameters": {...JSONSchema...}}</function>
|
||||
<function>{"description": "...", "name": "Edit", "parameters": {...}}</function>
|
||||
...
|
||||
</functions>
|
||||
```
|
||||
|
||||
各 `<function>` は JSONSchema を含む単行 JSON。Anthropic API の `tools` パラメータに渡る構造化データではなく、**プロンプトの一部としてレンダリングされたテキスト**である。
|
||||
|
||||
### 1.2 ツール呼び出しの表現
|
||||
|
||||
モデル側の出力も XML タグ列で行う(`function_calls` / `invoke` / `parameter` などのタグ)。ネイティブの tool_use content block ではない。ハーネスがこのテキストをパースし、対応するツールを実行する。
|
||||
|
||||
### 1.3 deferred tools の宣言
|
||||
|
||||
特定のツール群は最初の `<functions>` ブロックに含まれず、代わりに system-reminder で **名前リストだけ** が提示される:
|
||||
|
||||
```
|
||||
The following deferred tools are now available via ToolSearch.
|
||||
Their schemas are NOT loaded — calling them directly will fail with InputValidationError.
|
||||
Use ToolSearch with query "select:<name>" to load tool schemas before calling them:
|
||||
AskUserQuestion
|
||||
CronCreate
|
||||
...
|
||||
```
|
||||
|
||||
このリストには名前のみで schema は無い。
|
||||
|
||||
### 1.4 ToolSearch によるロード
|
||||
|
||||
`ToolSearch` 自体は最初から完全なスキーマで利用可能(通常のツールとして system prompt 冒頭の `<functions>` に含まれる)。`select:<name>` クエリを投げると、tool_result の本文として:
|
||||
|
||||
```
|
||||
<functions>
|
||||
<function>{"description": "...", "name": "AskUserQuestion", "parameters": {...}}</function>
|
||||
</functions>
|
||||
```
|
||||
|
||||
が返ってくる。「上のツール定義と同じエンコーディング」と明示されており、以降そのツールは通常通り呼べるようになる。
|
||||
|
||||
---
|
||||
|
||||
## 2. パラダイムの推測: prompted tool use
|
||||
|
||||
観察事実から、Claude Code は Anthropic API の **structured tool use ではなく prompted tool use** を採用している(あるいはハイブリッド)と判断できる。
|
||||
|
||||
| 項目 | structured | prompted |
|
||||
|---|---|---|
|
||||
| ツール定義 | API リクエストの `tools` 配列 | system prompt 内のテキスト |
|
||||
| ツール呼び出し | `tool_use` content block | テキスト中の特定タグ |
|
||||
| 検証 | API レイヤ(schema 強制) | ハーネスレイヤ(自前パース&検証) |
|
||||
| 拡張性 | API の制約に縛られる | 自由(XML/JSON/独自形式) |
|
||||
|
||||
Claude Code の振る舞いはすべて prompted 側に寄っている。API は単に「テキストを生成するモデル」として使われ、ツール抽象は完全にハーネスのレイヤにある。
|
||||
|
||||
---
|
||||
|
||||
## 3. deferred tools が成立する仕組み(推測)
|
||||
|
||||
prompted tool use 前提で考えると、deferred tools は素直に説明できる:
|
||||
|
||||
1. ハーネスは **全ツールのレジストリ** を内部に持つ
|
||||
2. リクエスト時、初期 `<functions>` ブロックには「コアツール + ToolSearch」だけを描画。残りは system-reminder で名前のみ列挙
|
||||
3. モデルが `ToolSearch` を呼ぶと、ハーネスはレジストリから該当 schema を引き、tool_result の **テキスト** として `<function>...</function>` を返す
|
||||
4. モデルはそのテキストを参照しつつ、対応する tool 呼び出しタグを生成
|
||||
5. ハーネスはタグをパースし、**レジストリで再度バリデーション**して実行
|
||||
|
||||
検証の真実は **レジストリ** であり、context にスキーマテキストが現れたかどうかではない。スキーマテキストは「モデルが正しい引数を生成するためのプロンプト材料」として機能する。
|
||||
|
||||
---
|
||||
|
||||
## 4. 設計上のメリット
|
||||
|
||||
### 4.1 prompt cache のプレフィックス安定化
|
||||
|
||||
Anthropic の prompt caching はプレフィックスマッチで効く。`tools` パラメータや system プロンプト前半が変わると、それ以降の cache が一括無効化される。
|
||||
|
||||
deferred tools 方式では:
|
||||
- API リクエストの `tools` パラメータは終始固定(あるいは空)
|
||||
- 初期 system prompt の `<functions>` ブロックも固定
|
||||
- ToolSearch の結果は **会話末尾の tool_result に積まれるだけ** → 前方プレフィックスは揺らがない
|
||||
|
||||
→ ツール群が大量にあってもプレフィックスキャッシュが安定する。
|
||||
|
||||
### 4.2 context 圧縮
|
||||
|
||||
全ツールの schema をいきなり system prompt に展開すると、肥大化して入力トークンを浪費する。MCP サーバが大量のツールを expose する世界では現実的でない。deferred 方式なら **そのセッションで実際に使うツールの schema だけ** が context に乗る。
|
||||
|
||||
### 4.3 ツール数のスケーラビリティ
|
||||
|
||||
レジストリに登録するだけなら理論上数百〜数千ツールでも扱える。モデルには「使えるツール名リスト」だけ見せ、必要に応じて schema を取り寄せる構造。
|
||||
|
||||
---
|
||||
|
||||
## 5. トレードオフ
|
||||
|
||||
- **1ターンの遅延**: ツールを呼ぶ前に ToolSearch が必要。初回だけだが UX 上のレイテンシは増える
|
||||
- **モデルの認知負荷**: 「使う前にロードする」を学習・指示する必要がある(system-reminder で明示している)
|
||||
- **ハルシネーション余地**: 名前を知っているが schema を知らない状態で呼ぼうとして InputValidationError を起こすケースが発生しうる
|
||||
- **ハーネス側の責務増**: パース・検証・レジストリ管理がすべてハーネス側に乗る。バグると安全性に直結
|
||||
|
||||
---
|
||||
|
||||
## 6. Pod / insomnia への示唆
|
||||
|
||||
Pod でローカル LLM をエージェントとして動かす場合、同様の課題が発生する:
|
||||
|
||||
- 提供したいツールが増えると context が肥大化
|
||||
- ローカルモデルは structured tool use の精度が API モデルに劣ることが多い → prompted 方式の方が安定する場合がある
|
||||
- KV cache の効きを最大化したい(ローカルだと特に prefill コストが重い)
|
||||
|
||||
deferred tools 方式は **prompted tool use を前提とする限り**、これらの課題への自然な解になる。具体的には:
|
||||
|
||||
1. ハーネス内にツールレジストリを持ち、`tools` メタデータと実装を分離
|
||||
2. system prompt には固定の core tools だけ展開、それ以外は名前で示唆
|
||||
3. `tool_search` 相当のツールで schema を引ける動線を用意
|
||||
4. パース・検証はハーネス側で完結、モデルへの API 呼び出しは text-in/text-out に統一
|
||||
|
||||
特に「prompt cache のプレフィックス安定化」は、ローカル推論でも KV cache 再利用に直接効く。
|
||||
|
||||
---
|
||||
|
||||
## 7. 未確認事項
|
||||
|
||||
- Anthropic API の `tools` パラメータが実際に空なのか、core tools だけ入っているのか、ハイブリッドなのかは確認できていない
|
||||
- ToolSearch の結果テキストが context に残り続けるのか、後続の compaction で削られるのか
|
||||
- レジストリのスコープ(セッション固定 / プラグインで動的追加 / MCP 経由など)の境界
|
||||
- system-reminder で名前リストが提示されるタイミングが固定なのか動的なのか
|
||||
|
||||
これらを確認するには Claude Code のソース(公開部分)か、API リクエストのキャプチャが必要。
|
||||
|
||||
---
|
||||
|
||||
## 8. Codex による Web 検証
|
||||
|
||||
検証日: 2026-04-30。Codex で Web 上の公開情報を確認した範囲では、本ドキュメントの「deferred tools の目的・メリット・トレードオフ」は概ね妥当。ただし、「Claude Code が structured tool use ではなく prompted tool use を採用している」という断定は、公開情報だけでは裏取りできない。
|
||||
|
||||
### 8.1 公式情報で確認できたこと
|
||||
|
||||
- Claude Code / Claude Agent SDK には `ToolSearch` / tool search が存在する。公式ドキュメントでは、すべての tool definition を upfront に context window へ入れる代わりに、必要な tool を動的に発見・ロードする仕組みとして説明されている。
|
||||
- https://code.claude.com/docs/en/agent-sdk/tool-search
|
||||
- tool search は大量ツール環境向けの context 効率化として説明されている。Claude Code docs では、50 tools で 10-20K tokens を消費しうること、30-50 tools を超えると tool selection accuracy が落ちることが述べられている。
|
||||
- Claude Code docs では、tool search はデフォルト有効で、`ENABLE_TOOL_SEARCH` により `true` / `auto` / `auto:N` / `false` を設定できるとされている。
|
||||
- tool search は MCP server 由来の tool や custom SDK MCP server 由来の tool にも適用される。
|
||||
- 初回 discovery には search step の追加 round-trip が発生する。ツール数が 10 未満程度なら、全 tool を upfront に読む方が速い場合がある。
|
||||
- Claude API docs には、tool definition property として `defer_loading` が記載されている。`defer_loading: true` は「初期 system prompt から tool を除外し、tool search が `tool_reference` を返した時に on demand でロードする」ものとして説明されている。
|
||||
- https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-reference
|
||||
- https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
|
||||
- prompt caching との関係も公式に説明されている。`defer_loading: true` の tools は rendered tools section から除外され、cache key 計算前の prefix に現れない。発見後の full definition は conversation body 側に展開されるため、prompt cache を保ちやすい。
|
||||
- Anthropic の engineering blog でも Tool Search Tool は紹介されている。そこでは「Tool Search Tool だけを upfront にロードし、3-5 個程度の relevant tools を on demand に発見する」設計として説明され、token 使用量削減と tool selection accuracy 改善が述べられている。
|
||||
- https://www.anthropic.com/engineering/advanced-tool-use
|
||||
|
||||
### 8.2 本ドキュメントの推測と食い違う可能性がある点
|
||||
|
||||
- 公開されている Claude API の説明では、tool search は `tools` 配列、`defer_loading: true`、`tool_reference`、`tool_use` block を使う structured tool use の仕組みとして説明されている。そのため、「API は単に text-in/text-out で、ツール抽象は完全にハーネスのレイヤにある」と断定するのは強すぎる。
|
||||
- 公式情報上は、deferred tool も API request の `tools` parameter に定義として渡し、その tool definition に `defer_loading: true` を付ける設計である。したがって「API リクエストの `tools` パラメータは終始固定(あるいは空)」という推測は、少なくとも公開 API の設計とは一致しない。
|
||||
- `ToolSearch` が `select:<name>` で schema text を返す、という観察は、このセッションのハーネス上の事実としては扱えるが、公式 API docs の表現とは異なる。公式 API では search tool が `tool_reference` を返し、それが conversation body 内で full tool definition に展開されると説明されている。
|
||||
- `<functions><function>...` や `function_calls` / `invoke` / `parameter` の XML タグ列は、観察対象のハーネスで見えている表現として記録できる。ただし Claude Code 内部 system prompt は公式に公開されていないため、Web 上の公式情報だけで Claude Code 全体の内部実装形式として確認することはできない。
|
||||
- https://code.claude.com/docs/en/configuration
|
||||
|
||||
### 8.3 現時点での整理
|
||||
|
||||
公開情報と観察事実を両立させるなら、次の程度に弱めて理解するのが安全:
|
||||
|
||||
> Claude Code の観察上は、ツール定義や呼び出しが prompt 内テキストとして見えている。ただし、公開されている Anthropic API の tool search は `tools` 配列、`defer_loading`、`tool_reference`、`tool_use` を使う structured tool use として説明されている。したがって、Claude Code 内部が完全な prompted tool use なのか、API の structured tool use を CLI / ハーネス側で別表現にレンダリングしているのか、あるいはそのハイブリッドなのかは未確認。
|
||||
|
||||
Pod / insomnia への示唆としては、deferred tools の設計目的である context 圧縮、tool selection accuracy の維持、prefix cache の安定化は公式情報でも裏付けられる。一方で、Anthropic API の現在の公開設計を参考にするなら、`tool_search` 相当の実装は「単なる schema text の注入」だけでなく、内部 registry 上の tool reference、ロード済み tool の状態管理、検証レイヤを明確に分けて設計する方がよい。
|
||||
|
|
@ -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 `/<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
|
||||
|
|
|
|||
48
tickets/submit-tui-completion.review.md
Normal file
48
tickets/submit-tui-completion.review.md
Normal 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 補足やテスト整理で、ブロックすべきものではない。
|
||||
Loading…
Reference in New Issue
Block a user