feat: chunked-replay token streaming for subconscious
Mirrors the primary's pattern at `src/server/turn.rs:445-471` — bifrost returns the full response in one shot, we chop it into 10-char chunks and emit each as a `SubconsciousToken` with a 20ms sleep between, so the subconscious appears to be typing in real time. The TUI side needed a small refit so the chunks don't each become their own line in the stream Vec (a 10-char-wide vertical waterfall): - `ChatState.subconscious_current: String` — the live-building line. Token chunks `push_str` here. The render path shows it as the brightest bottom line, ahead of finalized history lines above. - `SubconsciousToolCall`/`SubconsciousToolResult` flush the buffer to `subconscious_stream` (as a finalized line), clear it, then push their own line. New `SubconsciousPass(active=true)` clears both. - Render packs the visible window from newest backward, including the live line as the bottom entry; old history lines above fade upward toward `agent_dim` and gain the `DIM` modifier in the upper third. If the stream receiver is dropped mid-chunk, the inner loop bails out but the round itself completes — bifrost is already done, only the UX narration is interrupted.
This commit is contained in:
parent
2e1f913acc
commit
d8707611da
4 changed files with 78 additions and 22 deletions
|
|
@ -782,11 +782,24 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
"subconscious LLM call returned"
|
||||
);
|
||||
|
||||
// Emit streaming events for live TUI visibility
|
||||
// Chunked-replay streaming for live TUI visibility — mirrors the
|
||||
// primary's pattern at `src/server/turn.rs:445-471`. Bifrost
|
||||
// returns the full response in one shot; we slice it into small
|
||||
// pieces and emit them with a small inter-chunk delay so the
|
||||
// subconscious appears to be typing in real time.
|
||||
if let Some(tx) = stream_tx {
|
||||
let content = response.content.trim();
|
||||
if !content.is_empty() {
|
||||
let _ = tx.send(Ok(BackendEvent::SubconsciousToken(content.to_string()))).await;
|
||||
let trimmed = response.content.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let chars: Vec<char> = trimmed.chars().collect();
|
||||
for chunk in chars.chunks(10) {
|
||||
let s: String = chunk.iter().collect();
|
||||
if tx.send(Ok(BackendEvent::SubconsciousToken(s))).await.is_err() {
|
||||
// Receiver dropped — bail out of the streaming
|
||||
// emission; the round itself still completes.
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
for tc in &response.tool_calls {
|
||||
let _ = tx.send(Ok(BackendEvent::SubconsciousToolCall {
|
||||
|
|
|
|||
|
|
@ -326,17 +326,28 @@ impl ChatState {
|
|||
BackendEvent::SubconsciousPass(active) => {
|
||||
if active {
|
||||
self.subconscious_stream.clear();
|
||||
self.subconscious_current.clear();
|
||||
}
|
||||
self.pending_consciousness.push(BackendEvent::SubconsciousPass(active));
|
||||
}
|
||||
BackendEvent::SubconsciousToken(content) => {
|
||||
self.subconscious_stream.push(content);
|
||||
// Chunked-replay: each event is a slice of the LLM's text
|
||||
// response. Append to the live-building line; the render
|
||||
// path shows this as the brightest bottom line.
|
||||
self.subconscious_current.push_str(&content);
|
||||
}
|
||||
BackendEvent::SubconsciousToolCall { name, arguments } => {
|
||||
if !self.subconscious_current.is_empty() {
|
||||
let line = std::mem::take(&mut self.subconscious_current);
|
||||
self.subconscious_stream.push(line);
|
||||
}
|
||||
self.subconscious_stream.push(format!("⚙ {} — {}", name, arguments));
|
||||
}
|
||||
BackendEvent::SubconsciousToolResult { name, output, .. } => {
|
||||
// Only show first line of result in the live stream
|
||||
if !self.subconscious_current.is_empty() {
|
||||
let line = std::mem::take(&mut self.subconscious_current);
|
||||
self.subconscious_stream.push(line);
|
||||
}
|
||||
let snippet = output.lines().next().unwrap_or(&output);
|
||||
let clipped = if snippet.len() > 80 {
|
||||
format!("{}…", &snippet[..snippet.floor_char_boundary(77)])
|
||||
|
|
|
|||
|
|
@ -351,12 +351,19 @@ pub struct ChatState {
|
|||
pub palette: ChatPalette,
|
||||
pub stream_buffer: String,
|
||||
|
||||
/// Live subconscious reasoning stream — ephemeral lines from the N+1 pass.
|
||||
/// Pushed to by SubconsciousToken/ToolCall/ToolResult events in events.rs.
|
||||
/// Cleared on each new pass start. Rendered as a fading block below the
|
||||
/// phase bar during TurnPhase::Subconscious.
|
||||
/// Live subconscious reasoning stream — completed lines from the N+1 pass.
|
||||
/// SubconsciousToken chunks build up in `subconscious_current` (below);
|
||||
/// when a tool call/result event arrives, that buffer is flushed here as
|
||||
/// a finalized line and cleared. Cleared on each new pass start.
|
||||
pub subconscious_stream: Vec<String>,
|
||||
|
||||
/// The currently-streaming subconscious line — token chunks append here as
|
||||
/// they arrive (chunked-replay pattern, mirroring `turn.rs` for the
|
||||
/// primary). Rendered as the bottom-most, brightest line below the
|
||||
/// fading history in `subconscious_stream`. Promoted to the history Vec
|
||||
/// when a non-token event (tool call/result, or a new pass) arrives.
|
||||
pub subconscious_current: String,
|
||||
|
||||
/// Current itinerary route-line for the header strip.
|
||||
/// Empty string means no active itinerary.
|
||||
pub itinerary_line: String,
|
||||
|
|
@ -510,6 +517,7 @@ impl ChatState {
|
|||
palette: ChatPalette::default(),
|
||||
stream_buffer: String::new(),
|
||||
subconscious_stream: Vec::new(),
|
||||
subconscious_current: String::new(),
|
||||
itinerary_line: String::new(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ pub fn draw(f: &mut Frame, state: &ChatState) {
|
|||
let input_height = (input_visual_lines.min(max_input_lines) as u16) + 2;
|
||||
|
||||
let subconscious_stream_lines = if state.phase == TurnPhase::Subconscious {
|
||||
let n = state.subconscious_stream.len();
|
||||
let history = state.subconscious_stream.len();
|
||||
let live = if state.subconscious_current.is_empty() { 0 } else { 1 };
|
||||
let n = history + live;
|
||||
if n > 0 { (n as u16).min(5) } else { 0 }
|
||||
} else { 0 };
|
||||
let phase_height: u16 = if state.busy
|
||||
|
|
@ -168,21 +170,40 @@ fn draw_phase(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
/// lines above fade upward toward `agent_dim`. Capped at 5 visible lines;
|
||||
/// scrolls as new events arrive.
|
||||
fn draw_subconscious_stream(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let stream = &state.subconscious_stream;
|
||||
if stream.is_empty() || area.height == 0 {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let history = &state.subconscious_stream;
|
||||
let live = &state.subconscious_current;
|
||||
let has_live = !live.is_empty();
|
||||
let total = history.len() + if has_live { 1 } else { 0 };
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let cap = (area.height as usize).min(5);
|
||||
let visible = stream.len().min(cap);
|
||||
let start = stream.len() - visible;
|
||||
let visible = total.min(cap);
|
||||
let inner_width = (area.width as usize).saturating_sub(2).max(1);
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(visible);
|
||||
for (idx, i) in (start..stream.len()).enumerate() {
|
||||
let is_newest = i == stream.len() - 1;
|
||||
let t = if visible <= 1 {
|
||||
|
||||
// Build the visible window from newest backward, then reverse for render
|
||||
// so newest sits at the bottom of `area`. `slot_idx` indexes the visible
|
||||
// window from 0 (top, dimmest) to visible-1 (bottom, brightest).
|
||||
let mut entries: Vec<&str> = Vec::with_capacity(visible);
|
||||
if has_live {
|
||||
entries.push(live.as_str());
|
||||
}
|
||||
let history_take = visible - entries.len();
|
||||
for s in history.iter().rev().take(history_take) {
|
||||
entries.push(s.as_str());
|
||||
}
|
||||
entries.reverse();
|
||||
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(entries.len());
|
||||
for (slot_idx, body) in entries.iter().enumerate() {
|
||||
let is_newest = slot_idx + 1 == entries.len();
|
||||
let t = if entries.len() <= 1 {
|
||||
1.0
|
||||
} else {
|
||||
idx as f32 / (visible - 1) as f32
|
||||
slot_idx as f32 / (entries.len() - 1) as f32
|
||||
};
|
||||
let color = lerp_color(state.palette.agent_dim, state.palette.surfacing, t);
|
||||
let mut style = Style::default().fg(color).add_modifier(Modifier::ITALIC);
|
||||
|
|
@ -190,8 +211,11 @@ fn draw_subconscious_stream(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
style = style.add_modifier(Modifier::DIM);
|
||||
}
|
||||
let prefix = if is_newest { "⟡ " } else { " " };
|
||||
let body = clip_to_width(&stream[i], inner_width);
|
||||
lines.push(Line::from(vec![Span::styled(format!("{}{}", prefix, body), style)]));
|
||||
let clipped = clip_to_width(body, inner_width);
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!("{}{}", prefix, clipped),
|
||||
style,
|
||||
)]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines).alignment(Alignment::Left), area);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue