tuiの補完の実装
This commit is contained in:
parent
4b09ff0234
commit
6788db1ef2
|
|
@ -28,15 +28,7 @@ use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tools::ScopedFs;
|
use tools::ScopedFs;
|
||||||
|
|
||||||
/// A file the compact worker has marked for auto-read in the new session.
|
use crate::fs_view::{ReadRequirement, slice_lines};
|
||||||
#[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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregated output of a compact worker run.
|
/// Aggregated output of a compact worker run.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
|
|
@ -281,18 +273,6 @@ fn estimate_tokens(bytes: usize) -> u64 {
|
||||||
(bytes as u64).div_ceil(4)
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,10 @@ impl PodController {
|
||||||
// can emit typed lifecycle `Event`s (currently: compact progress).
|
// can emit typed lifecycle `Event`s (currently: compact progress).
|
||||||
pod.attach_event_tx(event_tx.clone());
|
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
|
// Register event bridge callbacks on the worker
|
||||||
{
|
{
|
||||||
let worker = pod.worker_mut();
|
let worker = pod.worker_mut();
|
||||||
|
|
@ -226,6 +230,10 @@ impl PodController {
|
||||||
// touching.
|
// touching.
|
||||||
let fs = tools::ScopedFs::new(scope_for_tools, pwd_for_tools.clone());
|
let fs = tools::ScopedFs::new(scope_for_tools, pwd_for_tools.clone());
|
||||||
let tracker = tools::Tracker::new();
|
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()));
|
worker.register_tools(tools::builtin_tools(fs, tracker.clone()));
|
||||||
|
|
||||||
// Memory subsystem opt-in. When `[memory]` is present in
|
// 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.update_history(pod.worker().history().to_vec());
|
||||||
shared_state.set_user_segments(pod.user_segments().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_manifest(&manifest_toml).await?;
|
||||||
runtime_dir.write_status(&shared_state).await?;
|
runtime_dir.write_status(&shared_state).await?;
|
||||||
runtime_dir.write_history(&shared_state).await?;
|
runtime_dir.write_history(&shared_state).await?;
|
||||||
|
|
@ -527,9 +536,10 @@ impl PodController {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHistory is handled at the socket layer (direct response).
|
// GetHistory / ListCompletions are handled at the socket
|
||||||
// If it somehow reaches the controller, ignore it.
|
// layer (direct response). If they somehow reach the
|
||||||
Method::GetHistory => {}
|
// controller, ignore them.
|
||||||
|
Method::GetHistory | Method::ListCompletions { .. } => {}
|
||||||
|
|
||||||
Method::PodEvent(event) => {
|
Method::PodEvent(event) => {
|
||||||
// (1) system side effects — idempotent and
|
// (1) system side effects — idempotent and
|
||||||
|
|
@ -728,7 +738,7 @@ where
|
||||||
// drain it at its next pre_llm_request.
|
// drain it at its next pre_llm_request.
|
||||||
notify_buffer.push(message);
|
notify_buffer.push(message);
|
||||||
}
|
}
|
||||||
Some(Method::GetHistory) => {}
|
Some(Method::GetHistory | Method::ListCompletions { .. }) => {}
|
||||||
Some(Method::PodEvent(event)) => {
|
Some(Method::PodEvent(event)) => {
|
||||||
// mpsc is consume-once, so we cannot defer this
|
// mpsc is consume-once, so we cannot defer this
|
||||||
// to the next main-loop iteration — drop here
|
// 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
|
// Client methods → handle or forward to controller
|
||||||
method = reader.next::<Method>() => {
|
method = reader.next::<Method>() => {
|
||||||
match 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)) => {
|
Ok(Some(Method::GetHistory)) => {
|
||||||
let items = handle.shared_state.history();
|
let items = handle.shared_state.history();
|
||||||
let segments_per_user = handle.shared_state.user_segments();
|
let segments_per_user = handle.shared_state.user_segments();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod compact;
|
pub mod compact;
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
|
pub mod fs_view;
|
||||||
pub mod hook;
|
pub mod hook;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
|
|
||||||
|
|
@ -970,8 +970,9 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
||||||
use crate::compact::worker::{
|
use crate::compact::worker::{
|
||||||
CompactWorkerContext, CompactWorkerInterceptor, add_reference_tool,
|
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
|
// Decide the cut point by projecting the UsageRecord timeline onto
|
||||||
// the current history: keep the tail whose estimated token count is
|
// the current history: keep the tail whose estimated token count is
|
||||||
|
|
@ -1097,38 +1098,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or(PodError::CompactSummaryMissing)?;
|
.ok_or(PodError::CompactSummaryMissing)?;
|
||||||
|
|
||||||
// Re-read each auto-read target through ScopedFs and render the
|
// Re-read each auto-read target via the Pod FS view. Errors are
|
||||||
// requested slice. Errors are logged and skipped rather than
|
// logged and skipped inside `render_auto_read` rather than
|
||||||
// aborting compaction — a missing / moved file should not fail
|
// aborting compaction — a missing / moved file should not fail
|
||||||
// the whole compact.
|
// the whole compact.
|
||||||
let mut auto_read_messages = Vec::new();
|
let auto_read_messages =
|
||||||
for req in &final_ctx.read_required {
|
PodFsView::new(scoped_fs.clone()).render_auto_read(&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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reference list as a single system message; omitted when empty.
|
// Reference list as a single system message; omitted when empty.
|
||||||
let reference_message = (!final_ctx.references.is_empty()).then(|| {
|
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 llm_worker::llm_client::types::Item;
|
||||||
use protocol::Segment;
|
use protocol::Segment;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session_store::SessionId;
|
use session_store::SessionId;
|
||||||
|
|
||||||
|
use crate::fs_view::PodFsView;
|
||||||
|
|
||||||
/// Shared state between PodController and runtime directory.
|
/// Shared state between PodController and runtime directory.
|
||||||
///
|
///
|
||||||
/// Controller updates this in-memory; RuntimeDir writes it to disk.
|
/// 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
|
/// segments are not preserved). Surfaced via `Event::History` so
|
||||||
/// clients can re-render typed atoms on session restore.
|
/// clients can re-render typed atoms on session restore.
|
||||||
pub user_segments: RwLock<Vec<Vec<Segment>>>,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -47,9 +56,22 @@ impl PodSharedState {
|
||||||
status: RwLock::new(PodStatus::Idle),
|
status: RwLock::new(PodStatus::Idle),
|
||||||
history: RwLock::new(Vec::new()),
|
history: RwLock::new(Vec::new()),
|
||||||
user_segments: 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>> {
|
pub fn user_segments(&self) -> Vec<Vec<Segment>> {
|
||||||
self.user_segments
|
self.user_segments
|
||||||
.read()
|
.read()
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,17 @@ pub enum Method {
|
||||||
Pause,
|
Pause,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
GetHistory,
|
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.
|
/// Typed lifecycle events sent from a child Pod to its parent.
|
||||||
|
|
@ -264,6 +275,14 @@ pub enum Event {
|
||||||
items: Vec<serde_json::Value>,
|
items: Vec<serde_json::Value>,
|
||||||
greeting: Greeting,
|
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),
|
Alert(Alert),
|
||||||
/// Pod has started compacting the current session.
|
/// Pod has started compacting the current session.
|
||||||
///
|
///
|
||||||
|
|
@ -316,6 +335,34 @@ pub enum AlertSource {
|
||||||
AgentsMd,
|
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.
|
/// Pod self-description rendered by the TUI when a session starts empty.
|
||||||
///
|
///
|
||||||
/// Built once in the Pod controller from the resolved manifest and
|
/// Built once in the Pod controller from the resolved manifest and
|
||||||
|
|
@ -572,6 +619,57 @@ mod tests {
|
||||||
assert!(matches!(method, Method::GetHistory));
|
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]
|
#[test]
|
||||||
fn event_history_format() {
|
fn event_history_format() {
|
||||||
let event = Event::History {
|
let event = Event::History {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use std::time::Instant;
|
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::{
|
use crate::block::{
|
||||||
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
|
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
|
||||||
|
|
@ -10,6 +12,32 @@ use crate::input::InputBuffer;
|
||||||
use crate::scroll::Scroll;
|
use crate::scroll::Scroll;
|
||||||
use crate::ui::Mode;
|
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 struct App {
|
||||||
pub pod_name: String,
|
pub pod_name: String,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
|
|
@ -39,6 +67,9 @@ pub struct App {
|
||||||
/// and future text deltas should append to it instead of starting a
|
/// and future text deltas should append to it instead of starting a
|
||||||
/// fresh block.
|
/// fresh block.
|
||||||
assistant_streaming: bool,
|
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 {
|
impl App {
|
||||||
|
|
@ -62,9 +93,93 @@ impl App {
|
||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
cache: FileCache::new(),
|
cache: FileCache::new(),
|
||||||
assistant_streaming: false,
|
assistant_streaming: false,
|
||||||
|
completion: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate the completion popup against the current input.
|
||||||
|
/// Returns a `Method::ListCompletions` to send when the
|
||||||
|
/// `(kind, prefix_start, prefix)` triple changed; otherwise `None`.
|
||||||
|
/// Callers should invoke this after every input mutation that could
|
||||||
|
/// move the cursor or change atoms.
|
||||||
|
pub fn refresh_completion(&mut self) -> Option<Method> {
|
||||||
|
match self.input.pending_completion_prefix() {
|
||||||
|
Some((kind, start, prefix)) => {
|
||||||
|
let need_query = match &self.completion {
|
||||||
|
Some(c) => c.kind != kind || c.prefix_start != start || c.prefix != prefix,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
let entries = match self.completion.take() {
|
||||||
|
Some(c) if c.kind == kind && c.prefix_start == start => c.entries,
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
self.completion = Some(CompletionState {
|
||||||
|
kind,
|
||||||
|
prefix_start: start,
|
||||||
|
prefix: prefix.clone(),
|
||||||
|
entries,
|
||||||
|
selected: 0,
|
||||||
|
});
|
||||||
|
if need_query {
|
||||||
|
Some(Method::ListCompletions { kind, prefix })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.completion = None;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_completion_up(&mut self) {
|
||||||
|
if let Some(c) = self.completion.as_mut()
|
||||||
|
&& !c.entries.is_empty()
|
||||||
|
{
|
||||||
|
c.selected = if c.selected == 0 {
|
||||||
|
c.entries.len() - 1
|
||||||
|
} else {
|
||||||
|
c.selected - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_completion_down(&mut self) {
|
||||||
|
if let Some(c) = self.completion.as_mut()
|
||||||
|
&& !c.entries.is_empty()
|
||||||
|
{
|
||||||
|
c.selected = (c.selected + 1) % c.entries.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_completion(&mut self) {
|
||||||
|
self.completion = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm the currently selected completion entry by replacing the
|
||||||
|
/// in-flight token with a chip atom. Returns `true` when something
|
||||||
|
/// was confirmed; `false` when there was no active candidate (so
|
||||||
|
/// the caller can fall through to the default key behaviour).
|
||||||
|
pub fn confirm_completion(&mut self) -> bool {
|
||||||
|
let Some(state) = self.completion.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if state.entries.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let entry = state.entries[state.selected].clone();
|
||||||
|
let kind = state.kind;
|
||||||
|
let start = state.prefix_start;
|
||||||
|
match kind {
|
||||||
|
CompletionKind::File => self.input.replace_with_file_ref(start, entry.value),
|
||||||
|
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, entry.value),
|
||||||
|
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, entry.value),
|
||||||
|
}
|
||||||
|
self.completion = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn submit_input(&mut self) -> Option<Method> {
|
pub fn submit_input(&mut self) -> Option<Method> {
|
||||||
let segments = self.input.submit_segments();
|
let segments = self.input.submit_segments();
|
||||||
if segments_are_blank(&segments) {
|
if segments_are_blank(&segments) {
|
||||||
|
|
@ -291,6 +406,17 @@ impl App {
|
||||||
Event::History { items, greeting } => {
|
Event::History { items, greeting } => {
|
||||||
self.restore_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 => {
|
Event::Shutdown => {
|
||||||
self.quit = true;
|
self.quit = true;
|
||||||
}
|
}
|
||||||
|
|
@ -632,6 +758,105 @@ pub fn alert_source_label(source: AlertSource) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod completion_flow_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_at_creates_completion_state_and_emits_query() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.insert_char('@');
|
||||||
|
let method = app.refresh_completion();
|
||||||
|
match method {
|
||||||
|
Some(Method::ListCompletions { kind, prefix }) => {
|
||||||
|
assert_eq!(kind, CompletionKind::File);
|
||||||
|
assert_eq!(prefix, "");
|
||||||
|
}
|
||||||
|
other => panic!("expected ListCompletions, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(app.completion.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appending_to_token_emits_updated_query() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.insert_char('@');
|
||||||
|
let _ = app.refresh_completion();
|
||||||
|
app.insert_char('s');
|
||||||
|
let method = app.refresh_completion();
|
||||||
|
match method {
|
||||||
|
Some(Method::ListCompletions { kind, prefix }) => {
|
||||||
|
assert_eq!(kind, CompletionKind::File);
|
||||||
|
assert_eq!(prefix, "s");
|
||||||
|
}
|
||||||
|
other => panic!("expected ListCompletions, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_after_token_clears_completion_state() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "@x".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
let _ = app.refresh_completion();
|
||||||
|
assert!(app.completion.is_some());
|
||||||
|
app.insert_char(' ');
|
||||||
|
let method = app.refresh_completion();
|
||||||
|
assert!(method.is_none());
|
||||||
|
assert!(app.completion.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_replaces_token_with_chip_and_clears_popup() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "@s".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
let _ = app.refresh_completion();
|
||||||
|
// Pretend the Pod replied with a single candidate.
|
||||||
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||||
|
value: "src/main.rs".into(),
|
||||||
|
is_dir: false,
|
||||||
|
}];
|
||||||
|
assert!(app.confirm_completion());
|
||||||
|
assert!(app.completion.is_none());
|
||||||
|
let segs = app.input.submit_segments();
|
||||||
|
assert_eq!(segs.len(), 1);
|
||||||
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "src/main.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirm_with_no_entries_is_a_noop() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "@x".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
let _ = app.refresh_completion();
|
||||||
|
// No `Event::Completions` arrived yet — entries is still empty.
|
||||||
|
assert!(!app.confirm_completion());
|
||||||
|
assert!(app.completion.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outdated_completions_event_is_dropped() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "@x".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
let _ = app.refresh_completion();
|
||||||
|
// Reply for a different kind shouldn't overwrite state.
|
||||||
|
app.handle_pod_event(Event::Completions {
|
||||||
|
kind: CompletionKind::Workflow,
|
||||||
|
entries: vec![CompletionEntry {
|
||||||
|
value: "stale".into(),
|
||||||
|
is_dir: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Seed / mutate the file-content cache based on a completed tool call.
|
/// 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
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Atom {
|
pub enum Atom {
|
||||||
Char(char),
|
Char(char),
|
||||||
Paste(PasteRef),
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum AtomClass {
|
enum AtomClass {
|
||||||
Word(WordKind),
|
Word(WordKind),
|
||||||
Sep,
|
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;
|
/// Sub-classification of word atoms. A run of equal `WordKind` is one word;
|
||||||
|
|
@ -59,8 +115,11 @@ enum WordKind {
|
||||||
|
|
||||||
fn atom_class(atom: &Atom) -> AtomClass {
|
fn atom_class(atom: &Atom) -> AtomClass {
|
||||||
match atom {
|
match atom {
|
||||||
Atom::Paste(_) => AtomClass::Paste,
|
|
||||||
Atom::Char(c) => char_class(*c),
|
Atom::Char(c) => char_class(*c),
|
||||||
|
Atom::Paste(_)
|
||||||
|
| Atom::FileRef(_)
|
||||||
|
| Atom::KnowledgeRef(_)
|
||||||
|
| Atom::WorkflowInvoke(_) => AtomClass::Chip,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +193,83 @@ impl InputBuffer {
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
|
||||||
|
/// `#<typed>` / `/<typed>` token) with the corresponding chip atom
|
||||||
|
/// and place the cursor right after the chip. Used by the completion
|
||||||
|
/// confirm path.
|
||||||
|
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
|
||||||
|
self.atoms.drain(start..self.cursor);
|
||||||
|
self.atoms
|
||||||
|
.insert(start, Atom::FileRef(FileRefAtom { path }));
|
||||||
|
self.cursor = start + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_with_knowledge_ref(&mut self, start: usize, slug: String) {
|
||||||
|
self.atoms.drain(start..self.cursor);
|
||||||
|
self.atoms
|
||||||
|
.insert(start, Atom::KnowledgeRef(KnowledgeRefAtom { slug }));
|
||||||
|
self.cursor = start + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_with_workflow_invoke(&mut self, start: usize, slug: String) {
|
||||||
|
self.atoms.drain(start..self.cursor);
|
||||||
|
self.atoms.insert(
|
||||||
|
start,
|
||||||
|
Atom::WorkflowInvoke(WorkflowInvokeAtom { slug }),
|
||||||
|
);
|
||||||
|
self.cursor = start + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the cursor is currently inside a `@<typed>` / `#<typed>` /
|
||||||
|
/// `/<typed>` token that satisfies the trigger rules, return the
|
||||||
|
/// kind, the index of the leading sigil atom, and the typed text
|
||||||
|
/// after the sigil (sigil itself excluded).
|
||||||
|
///
|
||||||
|
/// Trigger rules:
|
||||||
|
/// - The sigil (`@` / `#` / `/`) must be preceded by start-of-input,
|
||||||
|
/// whitespace, or another chip atom — otherwise this is normal
|
||||||
|
/// text (e.g. the `/` in `src/main.rs` is not a workflow trigger).
|
||||||
|
/// - Whitespace, newlines and chip atoms invalidate an in-flight
|
||||||
|
/// token — `@foo /` closes the `@foo` candidate as soon as the
|
||||||
|
/// space lands.
|
||||||
|
pub fn pending_completion_prefix(&self) -> Option<(protocol::CompletionKind, usize, String)> {
|
||||||
|
if self.cursor == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut typed = String::new();
|
||||||
|
for i in (0..self.cursor).rev() {
|
||||||
|
match &self.atoms[i] {
|
||||||
|
Atom::Char(c) => {
|
||||||
|
if c.is_whitespace() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let kind = match c {
|
||||||
|
'@' => Some(protocol::CompletionKind::File),
|
||||||
|
'#' => Some(protocol::CompletionKind::Knowledge),
|
||||||
|
'/' => Some(protocol::CompletionKind::Workflow),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(k) = kind {
|
||||||
|
let leading_ok = match self.atoms.get(i.wrapping_sub(1)).filter(|_| i > 0) {
|
||||||
|
None => true, // start of input
|
||||||
|
Some(Atom::Char(prev)) => prev.is_whitespace(),
|
||||||
|
Some(_) => true, // chip
|
||||||
|
};
|
||||||
|
if leading_ok {
|
||||||
|
return Some((k, i, typed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typed.insert(0, *c);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Chip atoms cannot appear inside a candidate token.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_before(&mut self) {
|
pub fn delete_before(&mut self) {
|
||||||
if self.cursor == 0 {
|
if self.cursor == 0 {
|
||||||
return;
|
return;
|
||||||
|
|
@ -274,20 +410,24 @@ impl InputBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
||||||
/// `Atom::Char`s are concatenated into a single `Segment::Text`;
|
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
|
||||||
/// each `Atom::Paste` becomes a standalone `Segment::Paste` so the
|
/// chip atom (`Paste` / `FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
||||||
/// `[Clipboard #N | X chars, Y lines]` chip can be reconstructed by
|
/// becomes a standalone `Segment` so that clients re-rendering an
|
||||||
/// any client subscribed to the resulting `Event::UserMessage`.
|
/// `Event::UserMessage` see the same indivisible chip rather than a
|
||||||
|
/// flattened string.
|
||||||
pub fn submit_segments(&self) -> Vec<protocol::Segment> {
|
pub fn submit_segments(&self) -> Vec<protocol::Segment> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut buf = String::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 {
|
for a in &self.atoms {
|
||||||
match a {
|
match a {
|
||||||
Atom::Char(c) => buf.push(*c),
|
Atom::Char(c) => buf.push(*c),
|
||||||
Atom::Paste(p) => {
|
Atom::Paste(p) => {
|
||||||
if !buf.is_empty() {
|
flush_text(&mut buf, &mut out);
|
||||||
out.push(protocol::Segment::text(std::mem::take(&mut buf)));
|
|
||||||
}
|
|
||||||
out.push(protocol::Segment::Paste {
|
out.push(protocol::Segment::Paste {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
chars: p.chars as u32,
|
chars: p.chars as u32,
|
||||||
|
|
@ -295,6 +435,24 @@ impl InputBuffer {
|
||||||
content: p.content.clone(),
|
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() {
|
if !buf.is_empty() {
|
||||||
|
|
@ -308,7 +466,6 @@ impl InputBuffer {
|
||||||
/// within the wrapped layout.
|
/// within the wrapped layout.
|
||||||
pub fn render(&self, content_width: u16) -> InputRender {
|
pub fn render(&self, content_width: u16) -> InputRender {
|
||||||
let w = content_width.max(1) as usize;
|
let w = content_width.max(1) as usize;
|
||||||
let paste_style = Style::default().fg(Color::Magenta);
|
|
||||||
let text_style = Style::default();
|
let text_style = Style::default();
|
||||||
|
|
||||||
// Row-builder state. `pending` + `pending_width` batch consecutive
|
// Row-builder state. `pending` + `pending_width` batch consecutive
|
||||||
|
|
@ -347,10 +504,9 @@ impl InputBuffer {
|
||||||
let leading = match atom {
|
let leading = match atom {
|
||||||
Atom::Char('\n') => 0,
|
Atom::Char('\n') => 0,
|
||||||
Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0),
|
Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0),
|
||||||
Atom::Paste(p) => p
|
other => other
|
||||||
.label()
|
.chip()
|
||||||
.chars()
|
.and_then(|(_, label)| label.chars().next())
|
||||||
.next()
|
|
||||||
.and_then(UnicodeWidthChar::width)
|
.and_then(UnicodeWidthChar::width)
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
};
|
};
|
||||||
|
|
@ -395,8 +551,9 @@ impl InputBuffer {
|
||||||
w,
|
w,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Atom::Paste(p) => {
|
other => {
|
||||||
if pending_style != paste_style && !pending.is_empty() {
|
let (chip_style, label) = other.chip().expect("non-char atom has a chip");
|
||||||
|
if pending_style != chip_style && !pending.is_empty() {
|
||||||
flush_pending(
|
flush_pending(
|
||||||
&mut pending,
|
&mut pending,
|
||||||
&mut pending_width,
|
&mut pending_width,
|
||||||
|
|
@ -405,8 +562,8 @@ impl InputBuffer {
|
||||||
&mut row_width,
|
&mut row_width,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
pending_style = paste_style;
|
pending_style = chip_style;
|
||||||
for c in p.label().chars() {
|
for c in label.chars() {
|
||||||
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
|
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
|
||||||
place_char(
|
place_char(
|
||||||
c,
|
c,
|
||||||
|
|
@ -571,6 +728,160 @@ mod submit_segments_tests {
|
||||||
assert_eq!(segs.len(), 1);
|
assert_eq!(segs.len(), 1);
|
||||||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -750,13 +1061,16 @@ mod word_motion_tests {
|
||||||
assert_eq!(cursor(&buf), 0);
|
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 {
|
fn as_text(buf: &InputBuffer) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for a in &buf.atoms {
|
for a in &buf.atoms {
|
||||||
match a {
|
match a {
|
||||||
Atom::Char(c) => out.push(*c),
|
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
|
out
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,32 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Completion popup overrides — only when there's something to
|
||||||
|
// confirm / navigate. An empty popup (request in flight) falls
|
||||||
|
// through to the default behaviour.
|
||||||
|
if app.completion.as_ref().is_some_and(|c| c.is_active()) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab | KeyCode::Enter if !alt => {
|
||||||
|
if app.confirm_completion() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.move_completion_up();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.move_completion_down();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.cancel_completion();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('c') if ctrl => handle_pause_or_quit(app),
|
KeyCode::Char('c') if ctrl => handle_pause_or_quit(app),
|
||||||
KeyCode::Char('x') if ctrl => {
|
KeyCode::Char('x') if ctrl => {
|
||||||
|
|
@ -402,58 +428,64 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') if ctrl => handle_shutdown(app),
|
KeyCode::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 => {
|
KeyCode::Enter if alt => {
|
||||||
app.insert_newline();
|
app.insert_newline();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Enter => app.submit_input(),
|
KeyCode::Enter => app.submit_input(),
|
||||||
KeyCode::Backspace if ctrl => {
|
KeyCode::Backspace if ctrl => {
|
||||||
app.delete_word_before();
|
app.delete_word_before();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
app.delete_char_before();
|
app.delete_char_before();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
app.delete_char_after();
|
app.delete_char_after();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Left if ctrl => {
|
KeyCode::Left if ctrl => {
|
||||||
app.move_cursor_word_left();
|
app.move_cursor_word_left();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
app.move_cursor_left();
|
app.move_cursor_left();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Right if ctrl => {
|
KeyCode::Right if ctrl => {
|
||||||
app.move_cursor_word_right();
|
app.move_cursor_word_right();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
app.move_cursor_right();
|
app.move_cursor_right();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
app.move_cursor_up();
|
app.move_cursor_up();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
app.move_cursor_down();
|
app.move_cursor_down();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
app.move_cursor_home();
|
app.move_cursor_home();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::End => {
|
KeyCode::End => {
|
||||||
app.move_cursor_end();
|
app.move_cursor_end();
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
app.insert_char(c);
|
app.insert_char(c);
|
||||||
None
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,14 @@ use ratatui::Frame;
|
||||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
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 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};
|
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
||||||
|
|
||||||
/// Display density for the history view.
|
/// 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_separator(frame, chunks[1]);
|
||||||
draw_status(frame, app, chunks[2]);
|
draw_status(frame, app, chunks[2]);
|
||||||
draw_input(frame, &input_render, chunks[3]);
|
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
|
/// 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
|
/// Render `Block::UserMessage` from typed segments. Each non-text
|
||||||
/// reconstructed as `[Clipboard #N | X chars, Y lines]` chips in
|
/// segment renders as a one-piece chip whose colour matches the input
|
||||||
/// magenta — matching the input-area presentation — so the user can
|
/// area's chip presentation (paste = magenta, `@` file = cyan,
|
||||||
/// recognise their own paste in the scrollback. User-entered text uses
|
/// `#` knowledge = green, `/` workflow = yellow), so the user
|
||||||
/// the standard `MessageKind::User` style; other segment kinds (file /
|
/// recognises their own typed atoms in the scrollback.
|
||||||
/// knowledge / workflow refs, unknown variants) render as inline
|
|
||||||
/// identifiers in the user style and are expected to be rare until the
|
|
||||||
/// completion ticket lands.
|
|
||||||
fn render_user_message(
|
fn render_user_message(
|
||||||
lines: &mut Vec<Line<'static>>,
|
lines: &mut Vec<Line<'static>>,
|
||||||
segments: &[Segment],
|
segments: &[Segment],
|
||||||
|
|
@ -377,7 +441,6 @@ fn render_user_message(
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_style = kind_style(MessageKind::User);
|
let user_style = kind_style(MessageKind::User);
|
||||||
let paste_style = Style::default().fg(Color::Magenta);
|
|
||||||
let mut current: Vec<Span<'static>> = Vec::new();
|
let mut current: Vec<Span<'static>> = Vec::new();
|
||||||
|
|
||||||
for seg in segments {
|
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 => {
|
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`
|
/// One-line textual rendering of a segment, used by `Mode::Overview`
|
||||||
/// (which collapses everything to a single string) and as the fallback
|
/// (which collapses everything to a single string) and as the fallback
|
||||||
/// inline rendering for non-paste, non-text segments.
|
/// inline rendering for non-paste, non-text segments.
|
||||||
|
|
|
||||||
|
|
@ -16,28 +16,38 @@
|
||||||
- `#<部分slug>` — Knowledge slug
|
- `#<部分slug>` — Knowledge slug
|
||||||
- `/<部分slug>` — Workflow slug + client-side コマンド(`/clear` など)
|
- `/<部分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` 変種に変換して送る。
|
確定(Tab / Enter 等、入力 UX の詳細は実装で)で対象範囲を `Atom::FileRef` / `Atom::KnowledgeRef` / `Atom::WorkflowInvoke` の indivisible atom に置き換える。挙動は既存の `Atom::Paste` と同等(cursor は中に入れない、Backspace で塊ごと削除)。submit 時に対応する `Segment` 変種に変換して送る。
|
||||||
|
|
||||||
### 候補列挙のための protocol query
|
### 候補列挙のための protocol query
|
||||||
|
|
||||||
補完用に Pod へ問い合わせる軽量経路を追加:
|
補完用に Pod へ問い合わせる軽量経路を追加。`Method::GetHistory` と同じパターンを踏襲する: 専用 `Method` variant を受信した IPC server 層 (`crates/pod/src/ipc/server.rs`) で Controller を介さず直接処理し、対応する `Event` 変種を同ソケットに write back する(broadcast には流さない)。
|
||||||
|
|
||||||
- ファイル候補(scope 内、prefix マッチ)
|
扱う query:
|
||||||
- Knowledge / Workflow slug 候補(kind 指定 + prefix マッチ)
|
|
||||||
|
|
||||||
`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 で来る前提)。
|
確定後の 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 等の高度な補完体験
|
- 候補スコアリング、fuzzy search、preview 等の高度な補完体験
|
||||||
- リッチクライアント(GUI / web)の同等 UX
|
- リッチクライアント(GUI / web)の同等 UX
|
||||||
|
|
||||||
|
|
@ -46,7 +56,6 @@
|
||||||
- `@` / `#` / `/` を打鍵すると候補が出て、確定で chip 化される
|
- `@` / `#` / `/` を打鍵すると候補が出て、確定で chip 化される
|
||||||
- chip 化された atom が対応する `Segment` として Pod に送出され、`Event::UserMessage` で戻ってきた typed segment が同じ見た目で再描画される
|
- chip 化された atom が対応する `Segment` として Pod に送出され、`Event::UserMessage` で戻ってきた typed segment が同じ見た目で再描画される
|
||||||
- 候補列挙の query / response が wire を通る
|
- 候補列挙の query / response が wire を通る
|
||||||
- `/clear` が client-side で処理され、Pod には届かない
|
|
||||||
- 既存ビルド・テストを壊さない
|
- 既存ビルド・テストを壊さない
|
||||||
|
|
||||||
## 依存
|
## 依存
|
||||||
|
|
@ -57,5 +66,13 @@
|
||||||
|
|
||||||
- `crates/tui/src/input.rs`(`Atom` 体系の拡張)
|
- `crates/tui/src/input.rs`(`Atom` 体系の拡張)
|
||||||
- `crates/tui/src/app.rs`(`submit_input`、`Block::UserMessage` 描画)
|
- `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/memory.md` §retrieval 経路(slug 補完対象)
|
||||||
- `docs/plan/workflow.md` §呼び出しと依存
|
- `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