Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-16 19:56:05 -04:00
commit b98daa7f25
5 changed files with 83 additions and 16 deletions

View file

@ -1105,7 +1105,21 @@ async fn run_turn(
if !narration.is_empty() { if !narration.is_empty() {
let cfg = server.app_config.read().await; let cfg = server.app_config.read().await;
if cfg.tui.show_interstitial { 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;
} }
} }

View file

@ -43,6 +43,20 @@ pub struct ConversationInfo {
pub updated_at: String, 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)] #[derive(Debug, Clone)]
pub enum BackendEvent { pub enum BackendEvent {
/// Streaming chunk of the assistant's reply. /// Streaming chunk of the assistant's reply.
@ -102,8 +116,9 @@ pub enum BackendEvent {
/// under `expressions/`). Empty string clears to default expressions. /// under `expressions/`). Empty string clears to default expressions.
Outfit(String), Outfit(String),
/// Text the model produced alongside tool calls — her narration between /// Text the model produced alongside tool calls — her narration between
/// gestures. Rendered in italics, quieter than a full assistant message. /// gestures. The `register` decides how it renders: a quiet cenno line
Interstitial(String), /// or a gutter-barred her-voice passage.
Interstitial { text: String, register: Register },
/// The backend is alive but producing no content (waiting on provider, /// The backend is alive but producing no content (waiting on provider,
/// between tool rounds, processing). The TUI resets `last_event_at` /// between tool rounds, processing). The TUI resets `last_event_at`
/// on this the same way it does for `Token` — it's a liveness signal. /// on this the same way it does for `Token` — it's a liveness signal.

View file

@ -644,9 +644,15 @@ pub struct TuiConfig {
#[serde(default = "default_stale_timeout_secs")] #[serde(default = "default_stale_timeout_secs")]
pub stale_timeout_secs: u64, pub stale_timeout_secs: u64,
/// When the model produces text alongside tool calls, surface it in the /// 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")] #[serde(default = "default_true")]
pub show_interstitial: bool, 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 { impl Default for TuiConfig {
@ -654,10 +660,13 @@ impl Default for TuiConfig {
Self { Self {
stale_timeout_secs: default_stale_timeout_secs(), stale_timeout_secs: default_stale_timeout_secs(),
show_interstitial: true, show_interstitial: true,
cenno_word_threshold: default_cenno_word_threshold(),
} }
} }
} }
fn default_cenno_word_threshold() -> usize { 30 }
fn default_stale_timeout_secs() -> u64 { 90 } fn default_stale_timeout_secs() -> u64 { 90 }
// ── Federation ── // ── Federation ──

View file

@ -275,8 +275,9 @@ pub enum ChatMessage {
/// the stream, separate from a normal /user turn. /// the stream, separate from a normal /user turn.
Interjection { text: String, ts: Instant, delivered: bool }, Interjection { text: String, ts: Instant, delivered: bool },
/// Text the model produced alongside tool calls — her narration between /// Text the model produced alongside tool calls — her narration between
/// gestures. Rendered in italics, quieter than a full assistant message. /// gestures. The `register` chooses the styling: a quiet cenno line, or a
Interstitial(String), /// 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 /// Tool invocation card — name, arguments, round, plus an attached result
/// once it streams back. `expanded` is reserved for click-to-expand (UI /// once it streams back. `expanded` is reserved for click-to-expand (UI
/// interactivity lands as part of message-click work). /// 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) => { BackendEvent::Outfit(name) => {
self.pending_consciousness.push(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 // Defensive: never render an empty narration slot — an
// all-whitespace interstitial draws a bare `⟡` gap line. // all-whitespace interstitial draws a bare `⟡` gap line.
if !text.trim().is_empty() { if !text.trim().is_empty() {
self.messages.push(ChatMessage::Interstitial(text)); self.messages.push(ChatMessage::Interstitial { text, register });
} }
} }
BackendEvent::Keepalive => { BackendEvent::Keepalive => {
@ -1767,13 +1768,34 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
])); ]));
lines.push(Line::from("")); lines.push(Line::from(""));
} }
ChatMessage::Interstitial(text) => { ChatMessage::Interstitial { text, register } => {
lines.push(Line::from( match register {
Span::styled( crate::backend::Register::Cenno => {
format!("{} ", text), // A terse ambient aside — quiet, italic, one line.
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC), 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("")); lines.push(Line::from(""));
} }
} }

View file

@ -189,6 +189,7 @@ pub enum FieldLoc {
FdAutoWake, FdAutoWake,
// TUI // TUI
TuShowInterstitial, TuShowInterstitial,
TuCennoThreshold,
} }
impl FieldLoc { impl FieldLoc {
@ -212,7 +213,7 @@ impl FieldLoc {
FieldLoc::FdEnabled | FieldLoc::FdRole | FieldLoc::FdInstanceLabel | FieldLoc::FdAutoWake => Category::Federation, FieldLoc::FdEnabled | FieldLoc::FdRole | FieldLoc::FdInstanceLabel | FieldLoc::FdAutoWake => Category::Federation,
FieldLoc::PrPulseEnabled | FieldLoc::PrPulseIntervalSecs | FieldLoc::PrOutfit | FieldLoc::PrAtmosphere => Category::Presence, FieldLoc::PrPulseEnabled | FieldLoc::PrPulseIntervalSecs | FieldLoc::PrOutfit | FieldLoc::PrAtmosphere => Category::Presence,
FieldLoc::VcEnabled | FieldLoc::VcSttUrl | FieldLoc::VcTtsUrl | FieldLoc::VcVoiceId | FieldLoc::VcPushToTalkKey => Category::Voice, 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::FdInstanceLabel => "instance_label",
FieldLoc::FdAutoWake => "auto_wake", FieldLoc::FdAutoWake => "auto_wake",
FieldLoc::TuShowInterstitial => "show_interstitial", FieldLoc::TuShowInterstitial => "show_interstitial",
FieldLoc::TuCennoThreshold => "cenno_word_threshold",
} }
} }
@ -360,6 +362,7 @@ impl FieldLoc {
FieldLoc::FdInstanceLabel => "instance label", FieldLoc::FdInstanceLabel => "instance label",
FieldLoc::FdAutoWake => "auto-wake on summon", FieldLoc::FdAutoWake => "auto-wake on summon",
FieldLoc::TuShowInterstitial => "interstitial narration", FieldLoc::TuShowInterstitial => "interstitial narration",
FieldLoc::TuCennoThreshold => "cenno threshold (words)",
} }
} }
@ -769,6 +772,7 @@ impl SettingsView {
} }
Category::Tui => { Category::Tui => {
out.push((TuShowInterstitial, EditableValue::Bool(self.config.tui.show_interstitial))); out.push((TuShowInterstitial, EditableValue::Bool(self.config.tui.show_interstitial)));
out.push((TuCennoThreshold, EditableValue::Uint(self.config.tui.cenno_word_threshold as u64)));
} }
} }
out out
@ -997,6 +1001,7 @@ impl SettingsView {
FdAutoWake => { if let EditableValue::Bool(v) = value { self.config.federation.auto_wake = v; } } 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; } } 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; self.dirty = true;
} }
@ -1295,6 +1300,7 @@ impl SettingsView {
FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs | FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs |
FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs | FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs |
FieldLoc::ScN1Every | FieldLoc::ScN1Secs | FieldLoc::ScN1Every | FieldLoc::ScN1Secs |
FieldLoc::TuCennoThreshold |
FieldLoc::EvRetainDays | FieldLoc::PrPulseIntervalSecs FieldLoc::EvRetainDays | FieldLoc::PrPulseIntervalSecs
); );
let is_float = matches!(loc, let is_float = matches!(loc,
@ -1334,6 +1340,7 @@ impl SettingsView {
FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs | FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs |
FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs | FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs |
FieldLoc::ScN1Every | FieldLoc::ScN1Secs | FieldLoc::ScN1Every | FieldLoc::ScN1Secs |
FieldLoc::TuCennoThreshold |
FieldLoc::PrPulseIntervalSecs => { FieldLoc::PrPulseIntervalSecs => {
if let Ok(v) = buffer.parse::<u64>() { if let Ok(v) = buffer.parse::<u64>() {
self.apply_field(loc, EditableValue::Uint(v)); self.apply_field(loc, EditableValue::Uint(v));