prediction: rewrite the prompting stack to create the ship computer as a second character, and let characters/agents operate autonomously with their own tasks and event queues

This commit is contained in:
2026-06-22 08:57:49 +02:00
parent 2d7153eaf7
commit 70ec40a880
21 changed files with 1091 additions and 563 deletions
+45 -19
View File
@@ -1,23 +1,26 @@
use chrono::{Duration, Utc};
use crossterm::event::{self, KeyCode, KeyModifiers};
use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, }, text::Span, widgets::{BorderType, Clear, ListState, Paragraph}};
use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, Style, Stylize, }, text::Span, widgets::{BorderType, Clear, List, ListState, Paragraph}};
use throbber_widgets_tui::{Throbber, ThrobberState};
use tokio::time::Instant;
use tui_input::{Input, backend::crossterm::EventHandler};
use tui_skeleton::{AnimationMode, Block, Constraint, SkeletonText};
use crate::{audio::AudioInputControl, prediction::{PredictionAction, SessionControl, SessionUpdate}, scene::{Scene, conversation::ConversationEntry}, transcription::TranscriptionControl, tts::TtsControl};
use crate::{audio::AudioInputControl, prediction::{GeneratedResponses, PredictionAction, SessionControl, SessionUpdate}, scene::{Scene, conversation::{ConversationEntry, Speaker}}, transcription::TranscriptionControl, tts::TtsControl};
use crate::widgets::*;
#[derive(Debug)]
pub struct Ui {
scene: Scene,
reply_options: GeneratedResponses,
reply_state: ListState,
conversation_state: ListState,
user_input: Input,
throbber_state: ThrobberState,
is_requesting: bool,
computer_is_thinking: bool,
audio_level: f64,
recording_audio: bool,
focus_state: FocusState,
@@ -45,6 +48,7 @@ impl Ui {
user_input: Default::default(),
throbber_state: Default::default(),
is_requesting: false,
computer_is_thinking: false,
audio_level: -60.,
audio,
recording_audio: false,
@@ -53,7 +57,8 @@ impl Ui {
tts,
predictions,
last_tick: Instant::now(),
conversation: vec![]
conversation: vec![],
reply_options: Default::default()
}
}
@@ -67,7 +72,7 @@ impl Ui {
.block(borders);
frame.render_widget(list, area);
} else {
frame.render_stateful_widget(Options(self.scene.reply_options()), area, &mut self.reply_state);
frame.render_stateful_widget(Options(&self.reply_options.responses), area, &mut self.reply_state);
}
}
@@ -83,7 +88,17 @@ impl Ui {
fn draw_io_throbber(&mut self, frame: &mut Frame, area: Rect) {
let throb_area = area.centered(Constraint::Max(1), Constraint::Max(1));
if self.is_requesting {
let throbber = Throbber::default();
let throbber = Throbber::default().throbber_style(Style::new().cyan());
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
} else {
frame.render_widget(Clear::default(), throb_area);
}
}
fn draw_computer_throbber(&mut self, frame: &mut Frame, area: Rect) {
let throb_area = area.centered(Constraint::Min(1), Constraint::Min(1));
if self.computer_is_thinking {
let throbber = Throbber::default().throbber_style(Style::new().red()).throbber_set(throbber_widgets_tui::WHITE_SQUARE);
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
} else {
frame.render_widget(Clear::default(), throb_area);
@@ -113,31 +128,34 @@ impl Ui {
let scene_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(4), Constraint::Fill(1)])
.constraints([Constraint::Fill(5), Constraint::Fill(2)])
.split(layout[0]);
frame.render_stateful_widget(Conversation(&self.conversation), scene_layout[0], &mut self.conversation_state);
self.draw_narration(frame, scene_layout[1]);
//self.draw_narration(frame, scene_layout[1]);
//self.draw_tasklist(frame, scene_layout[1]);
frame.render_widget(TaskList(&self.scene.computer_task_list), scene_layout[1]);
self.draw_options(frame, layout[1]);
let status_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Max(3), Constraint::Fill(2), Constraint::Max(13), Constraint::Min(50)])
.constraints([Constraint::Max(3), Constraint::Max(3), Constraint::Fill(2), Constraint::Max(13), Constraint::Min(50)])
.split(layout[3]);
self.draw_user_input(frame, layout[2]);
self.draw_io_throbber(frame, status_layout[0]);
frame.render_widget(StatusBar(&self.scene), status_layout[1]);
frame.render_widget(RecordingStatus(self.recording_audio), status_layout[2]);
frame.render_widget(Volume(self.audio_level, self.recording_audio), status_layout[3]);
self.draw_computer_throbber(frame, status_layout[1]);
frame.render_widget(StatusBar(&self.scene), status_layout[2]);
frame.render_widget(RecordingStatus(self.recording_audio), status_layout[3]);
frame.render_widget(Volume(self.audio_level, self.recording_audio), status_layout[4]);
}
async fn insert_selected_prompt(&mut self) {
let selected = self.scene.reply_options()[self.reply_state.selected().unwrap()].clone();
let selected = self.reply_options.responses[self.reply_state.selected().unwrap()].clone();
if let Some(direction) = &selected.stage_direction {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::StageDirection(direction.clone()))).await;
}
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Eva(selected.text.clone()))).await;
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Spoken(Speaker::Eva, selected.text.clone()))).await;
}
async fn on_command(&mut self, command: &str) {
@@ -174,7 +192,8 @@ impl Ui {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::StageDirection(arg.to_string()))).await;
},
"/computer" => {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::ShipComputer(arg.to_string()))).await;
self.predictions.insert(PredictionAction::ComputerCommand(arg.to_string())).await;
return;
},
_ => {
log::error!("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]");
@@ -183,6 +202,7 @@ impl Ui {
}
pub async fn on_event(&mut self, evt: event::Event) {
// TODO: ctrl+l should drop all the SystemMessage entries from the log to clean up the UI
if let Some(key) = evt.as_key_press_event() {
match self.focus_state {
FocusState::Conversation => {
@@ -200,7 +220,7 @@ impl Ui {
KeyCode::Up => self.conversation_state.select_next(),
KeyCode::Enter => {
let row_num = self.conversation_state.selected().unwrap();
if let ConversationEntry::Eva(text) = &self.conversation[self.conversation.len() - 1 - row_num] {
if let ConversationEntry::Spoken(Speaker::Eva, text) = &self.conversation[self.conversation.len() - 1 - row_num] {
self.tts.speak(text.clone()).await;
self.focus_state = FocusState::UserInput;
self.conversation_state.select(None);
@@ -240,7 +260,7 @@ impl Ui {
if next_msg.starts_with("/") {
self.on_command(&next_msg).await;
} else {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(next_msg))).await;
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Spoken(Speaker::User, next_msg))).await;
}
}
},
@@ -259,14 +279,20 @@ impl Ui {
}
tokio::select!{
_ = tokio::time::sleep(std::time::Duration::from_millis(60)), if self.is_requesting => (),
_ = tokio::time::sleep(std::time::Duration::from_millis(60)), if self.is_requesting || self.computer_is_thinking => (),
next_update = self.predictions.changed() => {
match next_update {
SessionUpdate::Thinking(is_thinking) => self.is_requesting = is_thinking,
SessionUpdate::Thinking(Speaker::Eva, is_thinking) => self.is_requesting = is_thinking,
SessionUpdate::Thinking(Speaker::ShipComputer, is_thinking) => self.computer_is_thinking = is_thinking,
SessionUpdate::Thinking(_, _) => unreachable!(),
SessionUpdate::Scene(scene) => {
self.scene = scene;
self.reply_state.select_first();
},
SessionUpdate::Responses(responses) => {
self.reply_options = responses;
self.reply_state.select_first();
}
SessionUpdate::Conversation(conversation) => {
self.conversation = conversation;
},
@@ -276,7 +302,7 @@ impl Ui {
self.audio_level = next_volume
},
transcription_result = self.transcription.next() => {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(transcription_result))).await;
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Spoken(Speaker::User, transcription_result))).await;
},
}
}