feat(tui): cenno / her-voice interstitial register model
Mid-turn text the model emits alongside tool calls is no longer a flat italic line. A new Register splits it in two: a *cenno* — a short ambient aside attached to tool work, kept quiet and italic — and *her-voice* — a substantive passage rendered with a left gutter bar and no italic, so it reads as her actual voice rather than a whisper. The backend classifies by word count at emit time against the new tui.cenno_word_threshold (default 30), exposed as an editable field in the Settings TUI category.
This commit is contained in:
parent
73ab402b19
commit
b98daa7f25
5 changed files with 83 additions and 16 deletions
|
|
@ -1105,7 +1105,21 @@ async fn run_turn(
|
|||
if !narration.is_empty() {
|
||||
let cfg = server.app_config.read().await;
|
||||
if cfg.tui.show_interstitial {
|
||||
let _ = tx.send(Ok(BackendEvent::Interstitial(narration.to_string()))).await;
|
||||
// Classify by length: a brief aside is a cenno, a full
|
||||
// passage is her-voice. tui.cenno_word_threshold is the line.
|
||||
let register = if narration.split_whitespace().count()
|
||||
>= cfg.tui.cenno_word_threshold
|
||||
{
|
||||
crate::backend::Register::HerVoice
|
||||
} else {
|
||||
crate::backend::Register::Cenno
|
||||
};
|
||||
let _ = tx
|
||||
.send(Ok(BackendEvent::Interstitial {
|
||||
text: narration.to_string(),
|
||||
register,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,20 @@ pub struct ConversationInfo {
|
|||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// The register of a mid-turn interstitial — how loudly it should speak.
|
||||
///
|
||||
/// When the model emits text alongside tool calls it isn't always the same
|
||||
/// kind of utterance. A few words attached to a gesture ("checking the
|
||||
/// ledger…") is ambient — a *cenno*. A full paragraph of reasoning mid-turn
|
||||
/// is her actual voice and deserves to read as such, not as a quiet aside.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Register {
|
||||
/// Short ambient aside attached to tool work. Terse, quiet.
|
||||
Cenno,
|
||||
/// A substantive mid-turn passage in her own voice.
|
||||
HerVoice,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
/// Streaming chunk of the assistant's reply.
|
||||
|
|
@ -102,8 +116,9 @@ pub enum BackendEvent {
|
|||
/// under `expressions/`). Empty string clears to default expressions.
|
||||
Outfit(String),
|
||||
/// Text the model produced alongside tool calls — her narration between
|
||||
/// gestures. Rendered in italics, quieter than a full assistant message.
|
||||
Interstitial(String),
|
||||
/// gestures. The `register` decides how it renders: a quiet cenno line
|
||||
/// or a gutter-barred her-voice passage.
|
||||
Interstitial { text: String, register: Register },
|
||||
/// The backend is alive but producing no content (waiting on provider,
|
||||
/// between tool rounds, processing). The TUI resets `last_event_at`
|
||||
/// on this the same way it does for `Token` — it's a liveness signal.
|
||||
|
|
|
|||
|
|
@ -644,9 +644,15 @@ pub struct TuiConfig {
|
|||
#[serde(default = "default_stale_timeout_secs")]
|
||||
pub stale_timeout_secs: u64,
|
||||
/// When the model produces text alongside tool calls, surface it in the
|
||||
/// chat stream as italic interstitial narration. Off = silent tool chains.
|
||||
/// chat stream as interstitial narration. Off = silent tool chains.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_interstitial: bool,
|
||||
/// Word-count boundary between the two interstitial registers. Narration
|
||||
/// shorter than this is a "cenno" — a terse ambient aside attached to
|
||||
/// tool work. At or above it, it's "her-voice": a substantive mid-turn
|
||||
/// passage, rendered with a gutter bar instead of a quiet italic line.
|
||||
#[serde(default = "default_cenno_word_threshold")]
|
||||
pub cenno_word_threshold: usize,
|
||||
}
|
||||
|
||||
impl Default for TuiConfig {
|
||||
|
|
@ -654,10 +660,13 @@ impl Default for TuiConfig {
|
|||
Self {
|
||||
stale_timeout_secs: default_stale_timeout_secs(),
|
||||
show_interstitial: true,
|
||||
cenno_word_threshold: default_cenno_word_threshold(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_cenno_word_threshold() -> usize { 30 }
|
||||
|
||||
fn default_stale_timeout_secs() -> u64 { 90 }
|
||||
|
||||
// ── Federation ──
|
||||
|
|
|
|||
|
|
@ -275,8 +275,9 @@ pub enum ChatMessage {
|
|||
/// the stream, separate from a normal /user turn.
|
||||
Interjection { text: String, ts: Instant, delivered: bool },
|
||||
/// Text the model produced alongside tool calls — her narration between
|
||||
/// gestures. Rendered in italics, quieter than a full assistant message.
|
||||
Interstitial(String),
|
||||
/// gestures. The `register` chooses the styling: a quiet cenno line, or a
|
||||
/// gutter-barred her-voice passage that reads as her actual voice.
|
||||
Interstitial { text: String, register: crate::backend::Register },
|
||||
/// Tool invocation card — name, arguments, round, plus an attached result
|
||||
/// once it streams back. `expanded` is reserved for click-to-expand (UI
|
||||
/// interactivity lands as part of message-click work).
|
||||
|
|
@ -1181,11 +1182,11 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
|||
BackendEvent::Outfit(name) => {
|
||||
self.pending_consciousness.push(BackendEvent::Outfit(name));
|
||||
}
|
||||
BackendEvent::Interstitial(text) => {
|
||||
BackendEvent::Interstitial { text, register } => {
|
||||
// Defensive: never render an empty narration slot — an
|
||||
// all-whitespace interstitial draws a bare `⟡` gap line.
|
||||
if !text.trim().is_empty() {
|
||||
self.messages.push(ChatMessage::Interstitial(text));
|
||||
self.messages.push(ChatMessage::Interstitial { text, register });
|
||||
}
|
||||
}
|
||||
BackendEvent::Keepalive => {
|
||||
|
|
@ -1767,13 +1768,34 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
ChatMessage::Interstitial(text) => {
|
||||
lines.push(Line::from(
|
||||
Span::styled(
|
||||
format!(" ⟡ {} ", text),
|
||||
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
));
|
||||
ChatMessage::Interstitial { text, register } => {
|
||||
match register {
|
||||
crate::backend::Register::Cenno => {
|
||||
// A terse ambient aside — quiet, italic, one line.
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ⟡ {} ", text),
|
||||
Style::default()
|
||||
.fg(state.palette.agent_dim)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
}
|
||||
crate::backend::Register::HerVoice => {
|
||||
// A substantive mid-turn passage — her actual voice.
|
||||
// No italic; a gutter bar in the left margin marks it
|
||||
// as a passage rather than a quiet aside.
|
||||
let bar = Style::default()
|
||||
.fg(state.palette.agent_primary)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let body = Style::default().fg(state.palette.agent_primary);
|
||||
let wrap_w = (area.width as usize).saturating_sub(6).max(20);
|
||||
for seg in wrap_words(text, wrap_w) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ▌ ", bar),
|
||||
Span::styled(seg, body),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ pub enum FieldLoc {
|
|||
FdAutoWake,
|
||||
// TUI
|
||||
TuShowInterstitial,
|
||||
TuCennoThreshold,
|
||||
}
|
||||
|
||||
impl FieldLoc {
|
||||
|
|
@ -212,7 +213,7 @@ impl FieldLoc {
|
|||
FieldLoc::FdEnabled | FieldLoc::FdRole | FieldLoc::FdInstanceLabel | FieldLoc::FdAutoWake => Category::Federation,
|
||||
FieldLoc::PrPulseEnabled | FieldLoc::PrPulseIntervalSecs | FieldLoc::PrOutfit | FieldLoc::PrAtmosphere => Category::Presence,
|
||||
FieldLoc::VcEnabled | FieldLoc::VcSttUrl | FieldLoc::VcTtsUrl | FieldLoc::VcVoiceId | FieldLoc::VcPushToTalkKey => Category::Voice,
|
||||
FieldLoc::TuShowInterstitial => Category::Tui,
|
||||
FieldLoc::TuShowInterstitial | FieldLoc::TuCennoThreshold => Category::Tui,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,6 +285,7 @@ impl FieldLoc {
|
|||
FieldLoc::FdInstanceLabel => "instance_label",
|
||||
FieldLoc::FdAutoWake => "auto_wake",
|
||||
FieldLoc::TuShowInterstitial => "show_interstitial",
|
||||
FieldLoc::TuCennoThreshold => "cenno_word_threshold",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -360,6 +362,7 @@ impl FieldLoc {
|
|||
FieldLoc::FdInstanceLabel => "instance label",
|
||||
FieldLoc::FdAutoWake => "auto-wake on summon",
|
||||
FieldLoc::TuShowInterstitial => "interstitial narration",
|
||||
FieldLoc::TuCennoThreshold => "cenno threshold (words)",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -769,6 +772,7 @@ impl SettingsView {
|
|||
}
|
||||
Category::Tui => {
|
||||
out.push((TuShowInterstitial, EditableValue::Bool(self.config.tui.show_interstitial)));
|
||||
out.push((TuCennoThreshold, EditableValue::Uint(self.config.tui.cenno_word_threshold as u64)));
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -997,6 +1001,7 @@ impl SettingsView {
|
|||
FdAutoWake => { if let EditableValue::Bool(v) = value { self.config.federation.auto_wake = v; } }
|
||||
|
||||
TuShowInterstitial => { if let EditableValue::Bool(v) = value { self.config.tui.show_interstitial = v; } }
|
||||
TuCennoThreshold => { if let EditableValue::Uint(v) = value { self.config.tui.cenno_word_threshold = v as usize; } }
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
|
@ -1295,6 +1300,7 @@ impl SettingsView {
|
|||
FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs |
|
||||
FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs |
|
||||
FieldLoc::ScN1Every | FieldLoc::ScN1Secs |
|
||||
FieldLoc::TuCennoThreshold |
|
||||
FieldLoc::EvRetainDays | FieldLoc::PrPulseIntervalSecs
|
||||
);
|
||||
let is_float = matches!(loc,
|
||||
|
|
@ -1334,6 +1340,7 @@ impl SettingsView {
|
|||
FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs |
|
||||
FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs |
|
||||
FieldLoc::ScN1Every | FieldLoc::ScN1Secs |
|
||||
FieldLoc::TuCennoThreshold |
|
||||
FieldLoc::PrPulseIntervalSecs => {
|
||||
if let Ok(v) = buffer.parse::<u64>() {
|
||||
self.apply_field(loc, EditableValue::Uint(v));
|
||||
|
|
|
|||
Loading…
Reference in a new issue