submitをvec segmentを受け付ける形に変更

This commit is contained in:
2026-04-27 11:03:58 +09:00
parent c9a7d652dc
commit 0a3af686f7
19 changed files with 663 additions and 97 deletions
+20 -7
View File
@@ -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",
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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