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() {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
||||||
|
|
|
||||||
|
|
@ -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(""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue