tuiの補完の実装
This commit is contained in:
+226
-1
@@ -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
|
||||
|
||||
+334
-20
@@ -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
|
||||
|
||||
+45
-13
@@ -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,
|
||||
}
|
||||
|
||||
+101
-24
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user