submitをvec segmentを受け付ける形に変更
This commit is contained in:
+20
-7
@@ -1,4 +1,4 @@
|
||||
use protocol::{Event, Method, AlertLevel, AlertSource, RunResult};
|
||||
use protocol::{AlertLevel, AlertSource, Event, Method, RunResult, Segment};
|
||||
|
||||
use crate::block::{Block, CompactEvent, ToolCallBlock, ToolCallState};
|
||||
use crate::cache::FileCache;
|
||||
@@ -62,8 +62,8 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn submit_input(&mut self) -> Option<Method> {
|
||||
let text = self.input.submit_text().trim().to_owned();
|
||||
if text.is_empty() {
|
||||
let segments = self.input.submit_segments();
|
||||
if segments_are_blank(&segments) {
|
||||
// Empty Enter only does something meaningful when the Pod
|
||||
// is paused: resume the interrupted turn. Otherwise no-op.
|
||||
if self.paused {
|
||||
@@ -77,7 +77,7 @@ impl App {
|
||||
// client subscribed to the Pod). Locally we only clear the
|
||||
// input buffer and forward the method.
|
||||
self.input.clear();
|
||||
Some(Method::Run { input: text })
|
||||
Some(Method::Run { input: segments })
|
||||
}
|
||||
|
||||
pub fn push_error(&mut self, message: impl Into<String>) {
|
||||
@@ -90,12 +90,12 @@ impl App {
|
||||
|
||||
pub fn handle_pod_event(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::UserMessage { text } => {
|
||||
Event::UserMessage { segments } => {
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
});
|
||||
self.blocks.push(Block::UserMessage { text });
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
Event::TurnStart { .. } => {
|
||||
@@ -370,7 +370,9 @@ impl App {
|
||||
turn: self.turn_index,
|
||||
});
|
||||
if !text.is_empty() {
|
||||
self.blocks.push(Block::UserMessage { text });
|
||||
self.blocks.push(Block::UserMessage {
|
||||
segments: vec![Segment::text(text)],
|
||||
});
|
||||
}
|
||||
}
|
||||
"assistant" if !text.is_empty() => {
|
||||
@@ -488,6 +490,17 @@ fn strip_cat_n_prefix(formatted: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// True if the submitted segment list carries no user-visible content
|
||||
/// (only whitespace / newlines, no paste, no typed atoms). Used to
|
||||
/// decide whether an empty Enter should be a no-op or trigger a
|
||||
/// `Resume` when the Pod is paused.
|
||||
fn segments_are_blank(segments: &[Segment]) -> bool {
|
||||
segments.iter().all(|s| match s {
|
||||
Segment::Text { content } => content.trim().is_empty(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn alert_source_label(source: AlertSource) -> &'static str {
|
||||
match source {
|
||||
AlertSource::Pod => "pod",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
#![allow(dead_code)] // Phase 5 will consume `output` in detail mode.
|
||||
|
||||
use protocol::{Greeting, AlertLevel, AlertSource};
|
||||
use protocol::{AlertLevel, AlertSource, Greeting, Segment};
|
||||
|
||||
pub enum Block {
|
||||
Greeting(Greeting),
|
||||
@@ -15,7 +15,7 @@ pub enum Block {
|
||||
turn: usize,
|
||||
},
|
||||
UserMessage {
|
||||
text: String,
|
||||
segments: Vec<Segment>,
|
||||
},
|
||||
AssistantText {
|
||||
text: String,
|
||||
|
||||
+93
-6
@@ -190,16 +190,33 @@ impl InputBuffer {
|
||||
(start, self.cursor - start)
|
||||
}
|
||||
|
||||
/// Flatten atoms into the text sent to the Pod: paste atoms expand
|
||||
/// to their original content; no `[Clipboard ...]` labels survive.
|
||||
pub fn submit_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
/// 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`.
|
||||
pub fn submit_segments(&self) -> Vec<protocol::Segment> {
|
||||
let mut out = Vec::new();
|
||||
let mut buf = String::new();
|
||||
for a in &self.atoms {
|
||||
match a {
|
||||
Atom::Char(c) => out.push(*c),
|
||||
Atom::Paste(p) => out.push_str(&p.content),
|
||||
Atom::Char(c) => buf.push(*c),
|
||||
Atom::Paste(p) => {
|
||||
if !buf.is_empty() {
|
||||
out.push(protocol::Segment::text(std::mem::take(&mut buf)));
|
||||
}
|
||||
out.push(protocol::Segment::Paste {
|
||||
id: p.id,
|
||||
chars: p.chars as u32,
|
||||
lines: p.lines as u32,
|
||||
content: p.content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
out.push(protocol::Segment::text(buf));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -402,3 +419,73 @@ pub struct InputRender {
|
||||
pub cursor_row: u16,
|
||||
pub cursor_col: u16,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod submit_segments_tests {
|
||||
use super::*;
|
||||
use protocol::Segment;
|
||||
|
||||
#[test]
|
||||
fn pure_text_collapses_to_one_text_segment() {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in "hello".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 1);
|
||||
match &segs[0] {
|
||||
Segment::Text { content } => assert_eq!(content, "hello"),
|
||||
other => panic!("expected Text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_emits_separate_segment_with_metadata() {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in "see ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("line1\nline2".into());
|
||||
for c in " end".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 3);
|
||||
match &segs[0] {
|
||||
Segment::Text { content } => assert_eq!(content, "see "),
|
||||
other => panic!("expected Text, got {other:?}"),
|
||||
}
|
||||
match &segs[1] {
|
||||
Segment::Paste {
|
||||
chars,
|
||||
lines,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(content, "line1\nline2");
|
||||
assert_eq!(*chars, "line1\nline2".chars().count() as u32);
|
||||
assert_eq!(*lines, 2);
|
||||
}
|
||||
other => panic!("expected Paste, got {other:?}"),
|
||||
}
|
||||
match &segs[2] {
|
||||
Segment::Text { content } => assert_eq!(content, " end"),
|
||||
other => panic!("expected Text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_buffer_yields_empty_segments() {
|
||||
let buf = InputBuffer::new();
|
||||
assert!(buf.submit_segments().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_paste_does_not_emit_empty_text() {
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.insert_paste("X".into());
|
||||
let segs = buf.submit_segments();
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
+83
-8
@@ -20,7 +20,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use protocol::{Greeting, AlertLevel};
|
||||
use protocol::{AlertLevel, Greeting, Segment};
|
||||
|
||||
use crate::app::{App, fmt_tokens, alert_source_label};
|
||||
use crate::block::{Block, CompactEvent};
|
||||
@@ -299,13 +299,7 @@ fn render_block_into(
|
||||
kind_style(MessageKind::TurnHeader),
|
||||
)));
|
||||
}
|
||||
Block::UserMessage { text } => match mode {
|
||||
Mode::Overview => push_overview_line(lines, text, width, MessageKind::User, "> "),
|
||||
// User input and assistant prose are the primary readable
|
||||
// content of a turn — never compressed in detail / normal.
|
||||
// Only `overview` folds them to a single line.
|
||||
_ => push_padded_lines(lines, text, MessageKind::User),
|
||||
},
|
||||
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
|
||||
Block::AssistantText { text } => match mode {
|
||||
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
|
||||
_ => push_padded_lines(lines, text, MessageKind::Assistant),
|
||||
@@ -363,6 +357,87 @@ 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.
|
||||
fn render_user_message(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
segments: &[Segment],
|
||||
width: u16,
|
||||
mode: Mode,
|
||||
) {
|
||||
if matches!(mode, Mode::Overview) {
|
||||
let text = segments
|
||||
.iter()
|
||||
.map(segment_display_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
push_overview_line(lines, &text, width, MessageKind::User, "> ");
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
match seg {
|
||||
Segment::Text { content } => {
|
||||
let mut iter = content.split('\n').peekable();
|
||||
while let Some(line) = iter.next() {
|
||||
if !line.is_empty() {
|
||||
current.push(Span::styled(line.to_owned(), user_style));
|
||||
}
|
||||
if iter.peek().is_some() {
|
||||
lines.push(Line::from(std::mem::take(&mut current)));
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
lines.push(Line::from(current));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn segment_display_text(seg: &Segment) -> String {
|
||||
match seg {
|
||||
Segment::Text { content } => content.replace('\n', " "),
|
||||
Segment::Paste {
|
||||
id,
|
||||
chars,
|
||||
lines,
|
||||
..
|
||||
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
||||
Segment::FileRef { path } => format!("@{path}"),
|
||||
Segment::KnowledgeRef { slug } => format!("#{slug}"),
|
||||
Segment::WorkflowInvoke { slug } => format!("/{slug}"),
|
||||
Segment::Unknown => "[unknown segment]".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-line summary for overview mode. The output is clipped to
|
||||
/// exactly one rendered terminal row at `width` columns — the first
|
||||
/// non-empty logical line is truncated (with `…`) to fit alongside an
|
||||
|
||||
Reference in New Issue
Block a user