tuiの補完の実装
This commit is contained in:
parent
f914ae235a
commit
623b54cefc
|
|
@ -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,57 @@ mod tests {
|
|||
assert!(matches!(method, Method::GetHistory));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_list_completions_roundtrip() {
|
||||
let method = Method::ListCompletions {
|
||||
kind: CompletionKind::File,
|
||||
prefix: "src/".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["method"], "list_completions");
|
||||
assert_eq!(parsed["params"]["kind"], "file");
|
||||
assert_eq!(parsed["params"]["prefix"], "src/");
|
||||
|
||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Method::ListCompletions { kind, prefix } => {
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(prefix, "src/");
|
||||
}
|
||||
other => panic!("expected ListCompletions, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_completions_format_and_default_is_dir() {
|
||||
let event = Event::Completions {
|
||||
kind: CompletionKind::Workflow,
|
||||
entries: vec![CompletionEntry {
|
||||
value: "clear".into(),
|
||||
is_dir: false,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "completions");
|
||||
assert_eq!(parsed["data"]["kind"], "workflow");
|
||||
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
|
||||
|
||||
// is_dir defaults to false on inbound payloads that omit it.
|
||||
let inbound = r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#;
|
||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||
match decoded {
|
||||
Event::Completions { kind, entries } => {
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].value, "main.rs");
|
||||
assert!(!entries[0].is_dir);
|
||||
}
|
||||
other => panic!("expected Completions, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_history_format() {
|
||||
let event = Event::History {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use protocol::{AlertLevel, AlertSource, Event, Method, RunResult, Segment};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, RunResult, Segment,
|
||||
};
|
||||
|
||||
use crate::block::{
|
||||
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
|
||||
|
|
@ -10,6 +12,32 @@ use crate::input::InputBuffer;
|
|||
use crate::scroll::Scroll;
|
||||
use crate::ui::Mode;
|
||||
|
||||
/// In-flight completion popup state. Lives on `App` while the user is
|
||||
/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger
|
||||
/// is invalidated (cursor moved out, whitespace landed inside the
|
||||
/// token, the sigil was deleted, or the candidate was confirmed).
|
||||
pub struct CompletionState {
|
||||
pub kind: CompletionKind,
|
||||
/// Atom index of the leading sigil (`@` / `#` / `/`).
|
||||
pub prefix_start: usize,
|
||||
/// Text typed after the sigil (sigil itself excluded).
|
||||
pub prefix: String,
|
||||
/// Latest candidate set returned by the Pod for `(kind, prefix)`.
|
||||
/// Initially empty until `Event::Completions` lands.
|
||||
pub entries: Vec<CompletionEntry>,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
impl CompletionState {
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Maximum rows the popup ever renders. Caller can clip to fewer
|
||||
/// rows if vertical space is tight.
|
||||
pub const MAX_VISIBLE: usize = 6;
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub pod_name: String,
|
||||
pub connected: bool,
|
||||
|
|
@ -39,6 +67,9 @@ pub struct App {
|
|||
/// and future text deltas should append to it instead of starting a
|
||||
/// fresh block.
|
||||
assistant_streaming: bool,
|
||||
/// Completion popup state, when an `@` / `#` / `/` token is in
|
||||
/// flight. `None` whenever the trigger conditions don't hold.
|
||||
pub completion: Option<CompletionState>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -62,9 +93,93 @@ impl App {
|
|||
mode: Mode::Normal,
|
||||
cache: FileCache::new(),
|
||||
assistant_streaming: false,
|
||||
completion: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-evaluate the completion popup against the current input.
|
||||
/// Returns a `Method::ListCompletions` to send when the
|
||||
/// `(kind, prefix_start, prefix)` triple changed; otherwise `None`.
|
||||
/// Callers should invoke this after every input mutation that could
|
||||
/// move the cursor or change atoms.
|
||||
pub fn refresh_completion(&mut self) -> Option<Method> {
|
||||
match self.input.pending_completion_prefix() {
|
||||
Some((kind, start, prefix)) => {
|
||||
let need_query = match &self.completion {
|
||||
Some(c) => c.kind != kind || c.prefix_start != start || c.prefix != prefix,
|
||||
None => true,
|
||||
};
|
||||
let entries = match self.completion.take() {
|
||||
Some(c) if c.kind == kind && c.prefix_start == start => c.entries,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
self.completion = Some(CompletionState {
|
||||
kind,
|
||||
prefix_start: start,
|
||||
prefix: prefix.clone(),
|
||||
entries,
|
||||
selected: 0,
|
||||
});
|
||||
if need_query {
|
||||
Some(Method::ListCompletions { kind, prefix })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.completion = None;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_completion_up(&mut self) {
|
||||
if let Some(c) = self.completion.as_mut()
|
||||
&& !c.entries.is_empty()
|
||||
{
|
||||
c.selected = if c.selected == 0 {
|
||||
c.entries.len() - 1
|
||||
} else {
|
||||
c.selected - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_completion_down(&mut self) {
|
||||
if let Some(c) = self.completion.as_mut()
|
||||
&& !c.entries.is_empty()
|
||||
{
|
||||
c.selected = (c.selected + 1) % c.entries.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_completion(&mut self) {
|
||||
self.completion = None;
|
||||
}
|
||||
|
||||
/// Confirm the currently selected completion entry by replacing the
|
||||
/// in-flight token with a chip atom. Returns `true` when something
|
||||
/// was confirmed; `false` when there was no active candidate (so
|
||||
/// the caller can fall through to the default key behaviour).
|
||||
pub fn confirm_completion(&mut self) -> bool {
|
||||
let Some(state) = self.completion.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if state.entries.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let entry = state.entries[state.selected].clone();
|
||||
let kind = state.kind;
|
||||
let start = state.prefix_start;
|
||||
match kind {
|
||||
CompletionKind::File => self.input.replace_with_file_ref(start, entry.value),
|
||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, entry.value),
|
||||
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, entry.value),
|
||||
}
|
||||
self.completion = None;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn submit_input(&mut self) -> Option<Method> {
|
||||
let segments = self.input.submit_segments();
|
||||
if segments_are_blank(&segments) {
|
||||
|
|
@ -291,6 +406,17 @@ impl App {
|
|||
Event::History { items, greeting } => {
|
||||
self.restore_history(&items, greeting);
|
||||
}
|
||||
Event::Completions { kind, entries } => {
|
||||
// Apply only if the popup is still on the same
|
||||
// (kind, prefix) the request was issued for; an
|
||||
// out-of-date reply (the user typed past it) is dropped.
|
||||
if let Some(state) = self.completion.as_mut()
|
||||
&& state.kind == kind
|
||||
{
|
||||
state.entries = entries;
|
||||
state.selected = 0;
|
||||
}
|
||||
}
|
||||
Event::Shutdown => {
|
||||
self.quit = true;
|
||||
}
|
||||
|
|
@ -632,6 +758,105 @@ pub fn alert_source_label(source: AlertSource) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod completion_flow_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typing_at_creates_completion_state_and_emits_query() {
|
||||
let mut app = App::new("test".into());
|
||||
app.insert_char('@');
|
||||
let method = app.refresh_completion();
|
||||
match method {
|
||||
Some(Method::ListCompletions { kind, prefix }) => {
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(prefix, "");
|
||||
}
|
||||
other => panic!("expected ListCompletions, got {other:?}"),
|
||||
}
|
||||
assert!(app.completion.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_to_token_emits_updated_query() {
|
||||
let mut app = App::new("test".into());
|
||||
app.insert_char('@');
|
||||
let _ = app.refresh_completion();
|
||||
app.insert_char('s');
|
||||
let method = app.refresh_completion();
|
||||
match method {
|
||||
Some(Method::ListCompletions { kind, prefix }) => {
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(prefix, "s");
|
||||
}
|
||||
other => panic!("expected ListCompletions, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_after_token_clears_completion_state() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@x".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
assert!(app.completion.is_some());
|
||||
app.insert_char(' ');
|
||||
let method = app.refresh_completion();
|
||||
assert!(method.is_none());
|
||||
assert!(app.completion.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_replaces_token_with_chip_and_clears_popup() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@s".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
// Pretend the Pod replied with a single candidate.
|
||||
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
||||
value: "src/main.rs".into(),
|
||||
is_dir: false,
|
||||
}];
|
||||
assert!(app.confirm_completion());
|
||||
assert!(app.completion.is_none());
|
||||
let segs = app.input.submit_segments();
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_with_no_entries_is_a_noop() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@x".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
// No `Event::Completions` arrived yet — entries is still empty.
|
||||
assert!(!app.confirm_completion());
|
||||
assert!(app.completion.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdated_completions_event_is_dropped() {
|
||||
let mut app = App::new("test".into());
|
||||
for c in "@x".chars() {
|
||||
app.insert_char(c);
|
||||
}
|
||||
let _ = app.refresh_completion();
|
||||
// Reply for a different kind shouldn't overwrite state.
|
||||
app.handle_pod_event(Event::Completions {
|
||||
kind: CompletionKind::Workflow,
|
||||
entries: vec![CompletionEntry {
|
||||
value: "stale".into(),
|
||||
is_dir: false,
|
||||
}],
|
||||
});
|
||||
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed / mutate the file-content cache based on a completed tool call.
|
||||
///
|
||||
/// Each built-in file tool has its own rule: Read copies the result body
|
||||
|
|
|
|||
|
|
@ -32,17 +32,73 @@ impl PasteRef {
|
|||
}
|
||||
}
|
||||
|
||||
/// `@<path>` chip — confirmed completion of a file reference.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileRefAtom {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl FileRefAtom {
|
||||
pub fn label(&self) -> String {
|
||||
format!("@{}", self.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// `#<slug>` chip — confirmed completion of a Knowledge reference.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KnowledgeRefAtom {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
impl KnowledgeRefAtom {
|
||||
pub fn label(&self) -> String {
|
||||
format!("#{}", self.slug)
|
||||
}
|
||||
}
|
||||
|
||||
/// `/<slug>` chip — confirmed completion of a Workflow invocation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowInvokeAtom {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
impl WorkflowInvokeAtom {
|
||||
pub fn label(&self) -> String {
|
||||
format!("/{}", self.slug)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Atom {
|
||||
Char(char),
|
||||
Paste(PasteRef),
|
||||
FileRef(FileRefAtom),
|
||||
KnowledgeRef(KnowledgeRefAtom),
|
||||
WorkflowInvoke(WorkflowInvokeAtom),
|
||||
}
|
||||
|
||||
impl Atom {
|
||||
/// Style + visible label for atoms that render as a single
|
||||
/// indivisible chip. Returns `None` for `Atom::Char`.
|
||||
fn chip(&self) -> Option<(Style, String)> {
|
||||
match self {
|
||||
Atom::Char(_) => None,
|
||||
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
||||
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
|
||||
Atom::KnowledgeRef(r) => Some((Style::default().fg(Color::Green), r.label())),
|
||||
Atom::WorkflowInvoke(r) => Some((Style::default().fg(Color::Yellow), r.label())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AtomClass {
|
||||
Word(WordKind),
|
||||
Sep,
|
||||
Paste,
|
||||
/// Indivisible chip — paste / file ref / knowledge ref / workflow
|
||||
/// invocation. Word motion treats one chip as one unit; deletion
|
||||
/// removes the whole atom.
|
||||
Chip,
|
||||
}
|
||||
|
||||
/// Sub-classification of word atoms. A run of equal `WordKind` is one word;
|
||||
|
|
@ -59,8 +115,11 @@ enum WordKind {
|
|||
|
||||
fn atom_class(atom: &Atom) -> AtomClass {
|
||||
match atom {
|
||||
Atom::Paste(_) => AtomClass::Paste,
|
||||
Atom::Char(c) => char_class(*c),
|
||||
Atom::Paste(_)
|
||||
| Atom::FileRef(_)
|
||||
| Atom::KnowledgeRef(_)
|
||||
| Atom::WorkflowInvoke(_) => AtomClass::Chip,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +193,83 @@ impl InputBuffer {
|
|||
self.cursor += 1;
|
||||
}
|
||||
|
||||
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
|
||||
/// `#<typed>` / `/<typed>` token) with the corresponding chip atom
|
||||
/// and place the cursor right after the chip. Used by the completion
|
||||
/// confirm path.
|
||||
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
|
||||
self.atoms.drain(start..self.cursor);
|
||||
self.atoms
|
||||
.insert(start, Atom::FileRef(FileRefAtom { path }));
|
||||
self.cursor = start + 1;
|
||||
}
|
||||
|
||||
pub fn replace_with_knowledge_ref(&mut self, start: usize, slug: String) {
|
||||
self.atoms.drain(start..self.cursor);
|
||||
self.atoms
|
||||
.insert(start, Atom::KnowledgeRef(KnowledgeRefAtom { slug }));
|
||||
self.cursor = start + 1;
|
||||
}
|
||||
|
||||
pub fn replace_with_workflow_invoke(&mut self, start: usize, slug: String) {
|
||||
self.atoms.drain(start..self.cursor);
|
||||
self.atoms.insert(
|
||||
start,
|
||||
Atom::WorkflowInvoke(WorkflowInvokeAtom { slug }),
|
||||
);
|
||||
self.cursor = start + 1;
|
||||
}
|
||||
|
||||
/// If the cursor is currently inside a `@<typed>` / `#<typed>` /
|
||||
/// `/<typed>` token that satisfies the trigger rules, return the
|
||||
/// kind, the index of the leading sigil atom, and the typed text
|
||||
/// after the sigil (sigil itself excluded).
|
||||
///
|
||||
/// Trigger rules:
|
||||
/// - The sigil (`@` / `#` / `/`) must be preceded by start-of-input,
|
||||
/// whitespace, or another chip atom — otherwise this is normal
|
||||
/// text (e.g. the `/` in `src/main.rs` is not a workflow trigger).
|
||||
/// - Whitespace, newlines and chip atoms invalidate an in-flight
|
||||
/// token — `@foo /` closes the `@foo` candidate as soon as the
|
||||
/// space lands.
|
||||
pub fn pending_completion_prefix(&self) -> Option<(protocol::CompletionKind, usize, String)> {
|
||||
if self.cursor == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut typed = String::new();
|
||||
for i in (0..self.cursor).rev() {
|
||||
match &self.atoms[i] {
|
||||
Atom::Char(c) => {
|
||||
if c.is_whitespace() {
|
||||
return None;
|
||||
}
|
||||
let kind = match c {
|
||||
'@' => Some(protocol::CompletionKind::File),
|
||||
'#' => Some(protocol::CompletionKind::Knowledge),
|
||||
'/' => Some(protocol::CompletionKind::Workflow),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = kind {
|
||||
let leading_ok = match self.atoms.get(i.wrapping_sub(1)).filter(|_| i > 0) {
|
||||
None => true, // start of input
|
||||
Some(Atom::Char(prev)) => prev.is_whitespace(),
|
||||
Some(_) => true, // chip
|
||||
};
|
||||
if leading_ok {
|
||||
return Some((k, i, typed));
|
||||
}
|
||||
}
|
||||
typed.insert(0, *c);
|
||||
}
|
||||
_ => {
|
||||
// Chip atoms cannot appear inside a candidate token.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn delete_before(&mut self) {
|
||||
if self.cursor == 0 {
|
||||
return;
|
||||
|
|
@ -274,20 +410,24 @@ impl InputBuffer {
|
|||
}
|
||||
|
||||
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
||||
/// `Atom::Char`s are concatenated into a single `Segment::Text`;
|
||||
/// each `Atom::Paste` becomes a standalone `Segment::Paste` so the
|
||||
/// `[Clipboard #N | X chars, Y lines]` chip can be reconstructed by
|
||||
/// any client subscribed to the resulting `Event::UserMessage`.
|
||||
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
|
||||
/// chip atom (`Paste` / `FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
|
||||
/// becomes a standalone `Segment` so that clients re-rendering an
|
||||
/// `Event::UserMessage` see the same indivisible chip rather than a
|
||||
/// flattened string.
|
||||
pub fn submit_segments(&self) -> Vec<protocol::Segment> {
|
||||
let mut out = Vec::new();
|
||||
let mut buf = String::new();
|
||||
let flush_text = |buf: &mut String, out: &mut Vec<protocol::Segment>| {
|
||||
if !buf.is_empty() {
|
||||
out.push(protocol::Segment::text(std::mem::take(buf)));
|
||||
}
|
||||
};
|
||||
for a in &self.atoms {
|
||||
match a {
|
||||
Atom::Char(c) => buf.push(*c),
|
||||
Atom::Paste(p) => {
|
||||
if !buf.is_empty() {
|
||||
out.push(protocol::Segment::text(std::mem::take(&mut buf)));
|
||||
}
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::Paste {
|
||||
id: p.id,
|
||||
chars: p.chars as u32,
|
||||
|
|
@ -295,6 +435,24 @@ impl InputBuffer {
|
|||
content: p.content.clone(),
|
||||
});
|
||||
}
|
||||
Atom::FileRef(r) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::FileRef {
|
||||
path: r.path.clone(),
|
||||
});
|
||||
}
|
||||
Atom::KnowledgeRef(r) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::KnowledgeRef {
|
||||
slug: r.slug.clone(),
|
||||
});
|
||||
}
|
||||
Atom::WorkflowInvoke(r) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::WorkflowInvoke {
|
||||
slug: r.slug.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
|
|
@ -308,7 +466,6 @@ impl InputBuffer {
|
|||
/// within the wrapped layout.
|
||||
pub fn render(&self, content_width: u16) -> InputRender {
|
||||
let w = content_width.max(1) as usize;
|
||||
let paste_style = Style::default().fg(Color::Magenta);
|
||||
let text_style = Style::default();
|
||||
|
||||
// Row-builder state. `pending` + `pending_width` batch consecutive
|
||||
|
|
@ -347,10 +504,9 @@ impl InputBuffer {
|
|||
let leading = match atom {
|
||||
Atom::Char('\n') => 0,
|
||||
Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0),
|
||||
Atom::Paste(p) => p
|
||||
.label()
|
||||
.chars()
|
||||
.next()
|
||||
other => other
|
||||
.chip()
|
||||
.and_then(|(_, label)| label.chars().next())
|
||||
.and_then(UnicodeWidthChar::width)
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
|
@ -395,8 +551,9 @@ impl InputBuffer {
|
|||
w,
|
||||
);
|
||||
}
|
||||
Atom::Paste(p) => {
|
||||
if pending_style != paste_style && !pending.is_empty() {
|
||||
other => {
|
||||
let (chip_style, label) = other.chip().expect("non-char atom has a chip");
|
||||
if pending_style != chip_style && !pending.is_empty() {
|
||||
flush_pending(
|
||||
&mut pending,
|
||||
&mut pending_width,
|
||||
|
|
@ -405,8 +562,8 @@ impl InputBuffer {
|
|||
&mut row_width,
|
||||
);
|
||||
}
|
||||
pending_style = paste_style;
|
||||
for c in p.label().chars() {
|
||||
pending_style = chip_style;
|
||||
for c in label.chars() {
|
||||
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
|
||||
place_char(
|
||||
c,
|
||||
|
|
@ -571,6 +728,160 @@ mod submit_segments_tests {
|
|||
assert_eq!(segs.len(), 1);
|
||||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_ref_chip_emits_file_ref_segment() {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in "see @sr".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.replace_with_file_ref(4, "src/main.rs".into());
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 2);
|
||||
assert!(matches!(&segs[0], Segment::Text { content } if content == "see "));
|
||||
match &segs[1] {
|
||||
Segment::FileRef { path } => assert_eq!(path, "src/main.rs"),
|
||||
other => panic!("expected FileRef, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_with_file_ref_swallows_in_flight_token() {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in "see @sr".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
// pending_completion_prefix returns the sigil index (4 = '@').
|
||||
let (_, start, prefix) = buf.pending_completion_prefix().unwrap();
|
||||
assert_eq!(start, 4);
|
||||
assert_eq!(prefix, "sr");
|
||||
buf.replace_with_file_ref(start, "src/main.rs".into());
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 2);
|
||||
assert!(matches!(&segs[0], Segment::Text { content } if content == "see "));
|
||||
assert!(matches!(&segs[1], Segment::FileRef { path } if path == "src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_and_workflow_chips_emit_typed_segments() {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in "#r".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.replace_with_knowledge_ref(0, "rust-style".into());
|
||||
buf.insert_char(' ');
|
||||
for c in "/p".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.replace_with_workflow_invoke(2, "plan".into());
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 3);
|
||||
match &segs[0] {
|
||||
Segment::KnowledgeRef { slug } => assert_eq!(slug, "rust-style"),
|
||||
other => panic!("expected KnowledgeRef, got {other:?}"),
|
||||
}
|
||||
match &segs[1] {
|
||||
Segment::Text { content } => assert_eq!(content, " "),
|
||||
other => panic!("expected Text, got {other:?}"),
|
||||
}
|
||||
match &segs[2] {
|
||||
Segment::WorkflowInvoke { slug } => assert_eq!(slug, "plan"),
|
||||
other => panic!("expected WorkflowInvoke, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod completion_prefix_tests {
|
||||
use super::*;
|
||||
use protocol::CompletionKind;
|
||||
|
||||
fn buf_from(text: &str) -> InputBuffer {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in text.chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_sigil_at_start_triggers_file_completion() {
|
||||
let buf = buf_from("@sr");
|
||||
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(start, 0);
|
||||
assert_eq!(prefix, "sr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigil_after_space_triggers() {
|
||||
let buf = buf_from("see @x");
|
||||
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(start, 4);
|
||||
assert_eq!(prefix, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_inside_path_is_not_a_workflow_trigger() {
|
||||
// After `@src/m`, the only valid trigger is `@`, not the `/`.
|
||||
let buf = buf_from("@src/m");
|
||||
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(start, 0);
|
||||
assert_eq!(prefix, "src/m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_after_sigil_invalidates_token() {
|
||||
// `@x ` — once a space lands after the typed text, the candidate
|
||||
// is gone (until the user types another sigil).
|
||||
let buf = buf_from("@x ");
|
||||
assert!(buf.pending_completion_prefix().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigil_glued_to_word_is_not_a_trigger() {
|
||||
// `foo@bar` — `@` is preceded by a word char, so it stays plain
|
||||
// text (covers the case of email addresses and similar).
|
||||
let buf = buf_from("foo@bar");
|
||||
assert!(buf.pending_completion_prefix().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_after_chip_atom() {
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.insert_paste("X".into());
|
||||
for c in "@sr".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(start, 1); // chip at 0, sigil at 1
|
||||
assert_eq!(prefix, "sr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_sigil_triggers_knowledge_completion() {
|
||||
let buf = buf_from("#abc");
|
||||
let (kind, _, prefix) = buf.pending_completion_prefix().unwrap();
|
||||
assert_eq!(kind, CompletionKind::Knowledge);
|
||||
assert_eq!(prefix, "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_at_start_triggers_workflow_completion() {
|
||||
let buf = buf_from("/cl");
|
||||
let (kind, _, prefix) = buf.pending_completion_prefix().unwrap();
|
||||
assert_eq!(kind, CompletionKind::Workflow);
|
||||
assert_eq!(prefix, "cl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_before_cursor_invalidates_trigger() {
|
||||
let buf = buf_from("@a\nbc");
|
||||
assert!(buf.pending_completion_prefix().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -750,13 +1061,16 @@ mod word_motion_tests {
|
|||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
/// Render atoms as a string for assertions; pastes become `<P>`.
|
||||
/// Render atoms as a string for assertions; chip atoms become `<P>`.
|
||||
fn as_text(buf: &InputBuffer) -> String {
|
||||
let mut out = String::new();
|
||||
for a in &buf.atoms {
|
||||
match a {
|
||||
Atom::Char(c) => out.push(*c),
|
||||
Atom::Paste(_) => out.push_str("<P>"),
|
||||
Atom::Paste(_)
|
||||
| Atom::FileRef(_)
|
||||
| Atom::KnowledgeRef(_)
|
||||
| Atom::WorkflowInvoke(_) => out.push_str("<P>"),
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
|
|||
|
|
@ -391,6 +391,32 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
_ => {}
|
||||
}
|
||||
|
||||
// Completion popup overrides — only when there's something to
|
||||
// confirm / navigate. An empty popup (request in flight) falls
|
||||
// through to the default behaviour.
|
||||
if app.completion.as_ref().is_some_and(|c| c.is_active()) {
|
||||
match key.code {
|
||||
KeyCode::Tab | KeyCode::Enter if !alt => {
|
||||
if app.confirm_completion() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.move_completion_up();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.move_completion_down();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.cancel_completion();
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('c') if ctrl => handle_pause_or_quit(app),
|
||||
KeyCode::Char('x') if ctrl => {
|
||||
|
|
@ -402,58 +428,64 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
}
|
||||
}
|
||||
KeyCode::Char('d') if ctrl => handle_shutdown(app),
|
||||
KeyCode::Esc => {
|
||||
// Close the popup if it's still showing (covers the
|
||||
// request-in-flight case where `is_active()` was false).
|
||||
app.cancel_completion();
|
||||
None
|
||||
}
|
||||
KeyCode::Enter if alt => {
|
||||
app.insert_newline();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Enter => app.submit_input(),
|
||||
KeyCode::Backspace if ctrl => {
|
||||
app.delete_word_before();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.delete_char_before();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
app.delete_char_after();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Left if ctrl => {
|
||||
app.move_cursor_word_left();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Left => {
|
||||
app.move_cursor_left();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Right if ctrl => {
|
||||
app.move_cursor_word_right();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.move_cursor_right();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.move_cursor_up();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.move_cursor_down();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Home => {
|
||||
app.move_cursor_home();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::End => {
|
||||
app.move_cursor_end();
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.insert_char(c);
|
||||
None
|
||||
app.refresh_completion()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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