ui: split out the app struct into a new ui mod
This commit is contained in:
+7
-460
@@ -1,22 +1,15 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_openai::types::chat::ChatCompletionRequestMessage;
|
use async_openai::types::chat::ChatCompletionRequestMessage;
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
use futures_timer::Delay;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use ratatui::{Frame, layout::{Constraint, Direction, Layout}, widgets::{Block, BorderType, Clear, Gauge, List, ListDirection, ListState, Paragraph, Wrap}};
|
|
||||||
use static_cell::StaticCell;
|
use static_cell::StaticCell;
|
||||||
use throbber_widgets_tui::{Throbber, ThrobberState};
|
|
||||||
use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}};
|
use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}};
|
||||||
use tokio::time::Instant;
|
use futures::StreamExt;
|
||||||
use tui_input::{Input, backend::crossterm::EventHandler};
|
|
||||||
use futures::{StreamExt, future::FutureExt};
|
|
||||||
|
|
||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use tui_skeleton::{AnimationMode, SkeletonText};
|
|
||||||
|
|
||||||
use crate::{audio::{AudioInputControl, start_audio_input}, prediction::{PredictionAction, SessionControl, SessionUpdate}, scene::{Scene, Scenery, StageDirection, conversation::ConversationEntry}, transcription::TranscriptionControl, tts::{TtsControl, start_tts}};
|
use crate::{audio::start_audio_input, scene::{Scenery, StageDirection}, tts::start_tts, ui::Ui};
|
||||||
|
|
||||||
mod scene;
|
mod scene;
|
||||||
mod events;
|
mod events;
|
||||||
@@ -26,6 +19,7 @@ mod prediction;
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod artifacts;
|
mod artifacts;
|
||||||
mod archive;
|
mod archive;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
// TODO: We should be able to delete entries from the conversation, or at least go back and edit something I said.
|
// TODO: We should be able to delete entries from the conversation, or at least go back and edit something I said.
|
||||||
// TODO: I want a "mark" command or keyboard shortcut, that inserts a marker into the log, so I know where to come back for the next speaking segment.
|
// TODO: I want a "mark" command or keyboard shortcut, that inserts a marker into the log, so I know where to come back for the next speaking segment.
|
||||||
@@ -56,430 +50,6 @@ mod archive;
|
|||||||
- Right panel: Shortcuts for triggering scenarios, soundboard events, etc
|
- Right panel: Shortcuts for triggering scenarios, soundboard events, etc
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct App {
|
|
||||||
scene: Scene,
|
|
||||||
|
|
||||||
reply_state: ListState,
|
|
||||||
conversation_state: ListState,
|
|
||||||
user_input: Input,
|
|
||||||
throbber_state: ThrobberState,
|
|
||||||
is_requesting: bool,
|
|
||||||
audio_level: f64,
|
|
||||||
recording_audio: bool,
|
|
||||||
focus_state: FocusState,
|
|
||||||
|
|
||||||
transcription: TranscriptionControl,
|
|
||||||
audio: AudioInputControl,
|
|
||||||
tts: TtsControl,
|
|
||||||
predictions: SessionControl
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum FocusState {
|
|
||||||
Conversation,
|
|
||||||
UserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new(predictions: SessionControl, audio: AudioInputControl, transcription: TranscriptionControl, tts: TtsControl) -> Self {
|
|
||||||
Self {
|
|
||||||
scene: Default::default(),
|
|
||||||
reply_state: Default::default(),
|
|
||||||
conversation_state: Default::default(),
|
|
||||||
user_input: Default::default(),
|
|
||||||
throbber_state: Default::default(),
|
|
||||||
is_requesting: false,
|
|
||||||
audio_level: -60.,
|
|
||||||
audio,
|
|
||||||
recording_audio: false,
|
|
||||||
transcription,
|
|
||||||
focus_state: FocusState::UserInput,
|
|
||||||
tts,
|
|
||||||
predictions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_line<'a>(entry: &ConversationEntry, max_width: usize) -> Text<'a> {
|
|
||||||
let prefix = match entry {
|
|
||||||
ConversationEntry::Eva(_) => "Eva: ",
|
|
||||||
ConversationEntry::User(_) => "Argee: ",
|
|
||||||
ConversationEntry::ShipComputer(_) => "Ship Computer: ",
|
|
||||||
_ => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
let style = match entry {
|
|
||||||
ConversationEntry::Eva(_) => Style::new().fg(style::Color::Cyan),
|
|
||||||
ConversationEntry::User(_) => Style::new().fg(style::Color::Magenta),
|
|
||||||
ConversationEntry::ShipComputer(_) => Style::new().fg(style::Color::Green),
|
|
||||||
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
|
|
||||||
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
|
|
||||||
};
|
|
||||||
|
|
||||||
let text_style = match entry {
|
|
||||||
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
|
|
||||||
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
|
|
||||||
_ => Style::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let text = match entry {
|
|
||||||
ConversationEntry::Eva(text) => text,
|
|
||||||
ConversationEntry::ShipComputer(text) => text,
|
|
||||||
ConversationEntry::StageDirection(text) => text,
|
|
||||||
ConversationEntry::SystemMessage(text) => text,
|
|
||||||
ConversationEntry::User(text) => text
|
|
||||||
};
|
|
||||||
|
|
||||||
let avail_width = max_width - prefix.len();
|
|
||||||
let indent = " ".repeat(prefix.len());
|
|
||||||
let wrap_options = textwrap::Options::new(avail_width).initial_indent(prefix).subsequent_indent(&indent);
|
|
||||||
let wrapped: Vec<Line> = textwrap::wrap(text, wrap_options)
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, s)| {
|
|
||||||
if idx == 0 {
|
|
||||||
Line::from_iter([Span::from(prefix).style(style), Span::from(s[prefix.len()..].to_string()).style(text_style)])
|
|
||||||
} else {
|
|
||||||
Line::from(s.to_string()).style(text_style)
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
Text::from_iter(wrapped)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_conversation(&mut self, frame: &mut Frame, area: Rect) {
|
|
||||||
let width = area.width.into();
|
|
||||||
let items: Vec<Text> = self.scene.conversation().iter().rev().map(|entry| {
|
|
||||||
Self::format_line(entry, width)
|
|
||||||
}).collect();
|
|
||||||
// TODO: Would be nice to be able to scroll a longer conversation with the scroll wheel, or with page up/down
|
|
||||||
frame.render_stateful_widget(
|
|
||||||
List::new(items)
|
|
||||||
.block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)"))
|
|
||||||
.direction(ListDirection::BottomToTop)
|
|
||||||
.highlight_symbol("> ")
|
|
||||||
.highlight_style(style::Style::new().bold().fg(style::Color::Cyan)),
|
|
||||||
area,
|
|
||||||
&mut self.conversation_state
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_options(&mut self, frame: &mut Frame, area: Rect) {
|
|
||||||
let borders = Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)");
|
|
||||||
if self.is_requesting {
|
|
||||||
let list = SkeletonText::new(std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64)
|
|
||||||
.braille(true)
|
|
||||||
.line_widths(&[0.25, 0.5, 0.4, 0.6])
|
|
||||||
.mode(AnimationMode::Noise)
|
|
||||||
.block(borders);
|
|
||||||
frame.render_widget(list, area);
|
|
||||||
} else {
|
|
||||||
let wrap_options = textwrap::Options::new(area.width as usize).subsequent_indent("...");
|
|
||||||
let options: Vec<Text> = self.scene.reply_options().iter().map(|option| {
|
|
||||||
let mut contents: Vec<Line> = vec![];
|
|
||||||
|
|
||||||
if let Some(direction) = &option.stage_direction {
|
|
||||||
let padded = format!("({})", direction);
|
|
||||||
let mut wrapped_direction: Vec<Line> = textwrap::wrap(&padded, wrap_options.clone())
|
|
||||||
.iter()
|
|
||||||
.map(|x| { Line::from(x.to_string()).fg(style::Color::Yellow)}).collect();
|
|
||||||
contents.append(&mut wrapped_direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut text: Vec<Line> = textwrap::wrap(&option.text, wrap_options.clone())
|
|
||||||
.iter()
|
|
||||||
.map(|x| { Line::from(x.to_string())}).collect();
|
|
||||||
contents.append(&mut text);
|
|
||||||
Text::from_iter(contents)
|
|
||||||
}).collect();
|
|
||||||
frame.render_stateful_widget(
|
|
||||||
List::new(options)
|
|
||||||
.block(borders)
|
|
||||||
.style(ratatui::style::Color::White)
|
|
||||||
.highlight_symbol("> ".fg(Color::Cyan))
|
|
||||||
.highlight_style(style::Style::new().bold().bg(style::Color::DarkGray))
|
|
||||||
.repeat_highlight_symbol(true),
|
|
||||||
area,
|
|
||||||
&mut self.reply_state
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_user_input(&mut self, frame: &mut Frame, area: Rect) {
|
|
||||||
let width = area.width.max(3) - 3;
|
|
||||||
let scroll = self.user_input.visual_scroll(width as usize);
|
|
||||||
let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input (Press 'Ctrl-X' to start/stop audio transcription)")).scroll((0, scroll as u16));
|
|
||||||
frame.render_widget(input, area);
|
|
||||||
let x = self.user_input.visual_cursor().max(scroll);
|
|
||||||
frame.set_cursor_position((area.x + x as u16 + 1, area.y + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
|
|
||||||
} else {
|
|
||||||
frame.render_widget(Clear::default(), throb_area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_status(&self, frame: &mut Frame, area: Rect) {
|
|
||||||
let time_remaining: Duration = self.scene.direction.time_remaining();
|
|
||||||
let minutes_remaining = time_remaining.num_seconds() / 60;
|
|
||||||
|
|
||||||
let negative = time_remaining.abs() != time_remaining;
|
|
||||||
|
|
||||||
let time_style = if minutes_remaining <= 0 || negative {
|
|
||||||
Style::new().fg(ratatui::style::Color::LightRed).bold()
|
|
||||||
} else if minutes_remaining < 5 {
|
|
||||||
Style::new().fg(ratatui::style::Color::LightRed).bold()
|
|
||||||
} else if minutes_remaining < 10 {
|
|
||||||
ratatui::style::Color::Red.into()
|
|
||||||
} else if minutes_remaining < 25 {
|
|
||||||
ratatui::style::Color::Yellow.into()
|
|
||||||
} else if minutes_remaining < 60 {
|
|
||||||
ratatui::style::Color::Green.into()
|
|
||||||
} else {
|
|
||||||
ratatui::style::Color::Blue.into()
|
|
||||||
};
|
|
||||||
|
|
||||||
let formatted_time = if negative {
|
|
||||||
format!("-{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours().abs(), time_remaining.num_minutes().abs()% 60, time_remaining.num_seconds().abs() % 60)
|
|
||||||
} else {
|
|
||||||
format!("{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours(), time_remaining.num_minutes() % 60, time_remaining.num_seconds() % 60)
|
|
||||||
};
|
|
||||||
|
|
||||||
let status_line = Line::from_iter([
|
|
||||||
Span::from(format!("Episode {}", self.scene.direction.episode_number)).style(ratatui::style::Color::LightBlue),
|
|
||||||
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
|
||||||
Span::from(format!("{} tracks", self.scene.scenery().current_playlist.len())).style(ratatui::style::Color::LightBlue),
|
|
||||||
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
|
||||||
Span::from(format!("Time Remaining: {}", formatted_time)).style(time_style),
|
|
||||||
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
|
||||||
Span::from(format!("{} artifacts recorded", self.scene.scenery().artifacts.len())).style(ratatui::style::Color::LightBlue),
|
|
||||||
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
|
||||||
Span::from(format!("{} tokens sacrificed", self.scene.tokens_consumed)).style(ratatui::style::Color::LightCyan),
|
|
||||||
]);
|
|
||||||
frame.render_widget(status_line, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_narration(&self, frame: &mut Frame, area: Rect) {
|
|
||||||
let narrative_desc = if self.scene.direction.narrative.is_empty() {
|
|
||||||
Span::from("No narrative available.").style(ratatui::style::Color::DarkGray)
|
|
||||||
} else {
|
|
||||||
Span::from(self.scene.direction.narrative.clone())
|
|
||||||
};
|
|
||||||
let setting = Paragraph::new(narrative_desc).block(Block::bordered().border_style(style::Color::LightMagenta).title("Stage Direction")).wrap(ratatui::widgets::Wrap { trim: false });
|
|
||||||
frame.render_widget(setting, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_event_log(&self, frame: &mut Frame, area: Rect) {
|
|
||||||
let items: Vec<Line> = self.scene.conversation().iter().filter(|entry| { if let ConversationEntry::StageDirection(_) = entry { true } else { false }}).rev().map(|entry| {
|
|
||||||
match entry {
|
|
||||||
ConversationEntry::StageDirection(text) => Line::from_iter([text]).style(ratatui::style::Color::Yellow),
|
|
||||||
_ => unreachable!()
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
frame.render_widget(Paragraph::new(items).block(Block::bordered().border_style(style::Color::Red).title("Event Log")).wrap(Wrap { trim: false }), area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(&mut self, frame: &mut Frame) {
|
|
||||||
let layout = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Fill(3),
|
|
||||||
Constraint::Min(9),
|
|
||||||
Constraint::Max(3),
|
|
||||||
Constraint::Max(1)
|
|
||||||
])
|
|
||||||
.split(frame.area());
|
|
||||||
|
|
||||||
let scene_layout = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Fill(4), Constraint::Fill(1)])
|
|
||||||
.split(layout[0]);
|
|
||||||
|
|
||||||
let context_layout = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Fill(1), Constraint::Fill(1)])
|
|
||||||
.split(scene_layout[1]);
|
|
||||||
|
|
||||||
self.draw_conversation(frame, scene_layout[0]);
|
|
||||||
self.draw_narration(frame, context_layout[0]);
|
|
||||||
self.draw_event_log(frame, context_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)])
|
|
||||||
.split(layout[3]);
|
|
||||||
|
|
||||||
self.draw_user_input(frame, layout[2]);
|
|
||||||
self.draw_io_throbber(frame, status_layout[0]);
|
|
||||||
self.draw_status(frame, status_layout[1]);
|
|
||||||
self.draw_recording_status(frame, status_layout[2]);
|
|
||||||
self.draw_volume(frame, status_layout[3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_recording_status(&self, frame: &mut Frame, area: Rect) {
|
|
||||||
frame.render_widget(Line::from_iter(
|
|
||||||
if self.recording_audio {
|
|
||||||
[
|
|
||||||
Span::from(" "),
|
|
||||||
Span::from(" Recording ").bg(style::Color::LightRed).fg(style::Color::White),
|
|
||||||
Span::from(" ")
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
[
|
|
||||||
Span::from(" "),
|
|
||||||
Span::from(" Stopped ").bg(style::Color::DarkGray).fg(style::Color::White),
|
|
||||||
Span::from(" ")
|
|
||||||
]
|
|
||||||
}
|
|
||||||
), area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_volume(&self, frame: &mut Frame, area: Rect) {
|
|
||||||
const NOISE_FLOOR: f64 = 50.;
|
|
||||||
let vu_pct = 1.0 - (self.audio_level.abs().min(NOISE_FLOOR) / NOISE_FLOOR);
|
|
||||||
|
|
||||||
let volume_color = if self.recording_audio {
|
|
||||||
if vu_pct >= 0.85 {
|
|
||||||
style::Color::Red
|
|
||||||
} else if vu_pct >= 0.60 {
|
|
||||||
style::Color::Yellow
|
|
||||||
} else {
|
|
||||||
style::Color::LightGreen
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
style::Color::Gray
|
|
||||||
};
|
|
||||||
|
|
||||||
let gauge = Gauge::default()
|
|
||||||
.ratio(vu_pct)
|
|
||||||
.use_unicode(true)
|
|
||||||
.gauge_style(volume_color)
|
|
||||||
.label(format!("{:.01}dB", self.audio_level));
|
|
||||||
frame.render_widget(gauge, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_selected_prompt(&mut self) {
|
|
||||||
let selected = self.scene.reply_options()[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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_command(&mut self, command: &str) {
|
|
||||||
let mut parts = command.splitn(2, " ");
|
|
||||||
let command = parts.next().unwrap();
|
|
||||||
let arg = parts.next().unwrap_or("");
|
|
||||||
match command {
|
|
||||||
// FIXME: Need some new kind of /bandcamp command to force loading of specific urls
|
|
||||||
"/episode" => {
|
|
||||||
if let Ok(episode_number) = arg.trim().parse() {
|
|
||||||
self.predictions.insert(PredictionAction::SetEpisodeNumber(episode_number)).await;
|
|
||||||
} else {
|
|
||||||
self.predictions.log("Invalid episode number format. Use /episode [number]".into()).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/timer" => {
|
|
||||||
if let Ok(minutes) = arg.trim().parse::<i64>() {
|
|
||||||
let end_time = Utc::now() + Duration::minutes(minutes);
|
|
||||||
self.predictions.insert(PredictionAction::SetShowEndTime(end_time)).await;
|
|
||||||
self.predictions.log(format!("Set timer for {} minutes.", minutes)).await;
|
|
||||||
} else {
|
|
||||||
self.predictions.log("Invalid timer format. Use /timer [minutes]".into()).await;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/narrative" => {
|
|
||||||
self.predictions.insert(PredictionAction::SetNarrative(arg.to_string())).await;
|
|
||||||
},
|
|
||||||
"/event" => {
|
|
||||||
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.log("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]".into()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_event(&mut self, evt: event::Event) {
|
|
||||||
if let Some(key) = evt.as_key_press_event() {
|
|
||||||
match self.focus_state {
|
|
||||||
FocusState::Conversation => {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Tab => {
|
|
||||||
self.focus_state = FocusState::UserInput;
|
|
||||||
self.conversation_state.select(None);
|
|
||||||
self.reply_state.select_first();
|
|
||||||
},
|
|
||||||
KeyCode::PageUp => self.conversation_state.scroll_down_by(5),
|
|
||||||
KeyCode::PageDown => self.conversation_state.scroll_up_by(5),
|
|
||||||
KeyCode::Home => self.conversation_state.select_last(),
|
|
||||||
KeyCode::End => self.conversation_state.select_first(),
|
|
||||||
KeyCode::Down => self.conversation_state.select_previous(),
|
|
||||||
KeyCode::Up => self.conversation_state.select_next(),
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let row_num = self.conversation_state.selected().unwrap();
|
|
||||||
if let ConversationEntry::Eva(text) = &self.scene.conversation()[self.scene.conversation().len() - 1 - row_num] {
|
|
||||||
self.tts.speak(text.clone()).await;
|
|
||||||
self.focus_state = FocusState::UserInput;
|
|
||||||
self.conversation_state.select(None);
|
|
||||||
self.reply_state.select_first();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => ()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
FocusState::UserInput => {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Tab => {
|
|
||||||
self.focus_state = FocusState::Conversation;
|
|
||||||
self.conversation_state.select_first();
|
|
||||||
self.reply_state.select(None);
|
|
||||||
},
|
|
||||||
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.predictions.regenerate_options().await,
|
|
||||||
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
if self.recording_audio {
|
|
||||||
self.recording_audio = false;
|
|
||||||
self.transcription.stop();
|
|
||||||
} else {
|
|
||||||
self.recording_audio = true;
|
|
||||||
self.transcription.start();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
KeyCode::Down => self.reply_state.select_next(),
|
|
||||||
KeyCode::Up => self.reply_state.select_previous(),
|
|
||||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
self.insert_selected_prompt().await;
|
|
||||||
},
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let next_msg = self.user_input.value_and_reset();
|
|
||||||
if next_msg.trim().is_empty() {
|
|
||||||
self.insert_selected_prompt().await;
|
|
||||||
} else {
|
|
||||||
if next_msg.starts_with("/") {
|
|
||||||
self.on_command(&next_msg).await;
|
|
||||||
} else {
|
|
||||||
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(next_msg))).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {self.user_input.handle_event(&evt);},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||||
pub struct SaveData {
|
pub struct SaveData {
|
||||||
@@ -559,39 +129,16 @@ async fn main() {
|
|||||||
let tts_ctrl = start_tts(tts_output).await;
|
let tts_ctrl = start_tts(tts_output).await;
|
||||||
let transcription_ctrl = transcription::start_transcription(mic_stream).await;
|
let transcription_ctrl = transcription::start_transcription(mic_stream).await;
|
||||||
|
|
||||||
let mut app = App::new(prediction_ctrl, audio_ctrl, transcription_ctrl, tts_ctrl);
|
let mut app = Ui::new(prediction_ctrl, audio_ctrl, transcription_ctrl, tts_ctrl);
|
||||||
|
|
||||||
let mut events = EventStream::new();
|
let mut events = EventStream::new();
|
||||||
let mut last_tick = Instant::now();
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if last_tick.elapsed() >= std::time::Duration::from_millis(100) {
|
|
||||||
last_tick = Instant::now();
|
|
||||||
app.throbber_state.calc_next();
|
|
||||||
}
|
|
||||||
terminal.draw(|frame| { app.draw(frame)}).unwrap();
|
terminal.draw(|frame| { app.draw(frame)}).unwrap();
|
||||||
|
|
||||||
let delay = Delay::new(std::time::Duration::from_millis(60)).fuse();
|
|
||||||
let event = events.next().fuse();
|
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = delay => (),
|
_ = app.update() => {},
|
||||||
next_update = app.predictions.changed() => {
|
maybe_event = events.next() => {
|
||||||
match next_update {
|
|
||||||
SessionUpdate::Thinking(is_thinking) => app.is_requesting = is_thinking,
|
|
||||||
SessionUpdate::Scene(scene) => {
|
|
||||||
app.scene = scene;
|
|
||||||
app.reply_state.select_first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
next_volume = app.audio.next() => {
|
|
||||||
app.audio_level = next_volume
|
|
||||||
},
|
|
||||||
transcription_result = app.transcription.next() => {
|
|
||||||
app.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(transcription_result))).await;
|
|
||||||
},
|
|
||||||
maybe_event = event => {
|
|
||||||
match maybe_event {
|
match maybe_event {
|
||||||
Some(Ok(event)) => {
|
Some(Ok(event)) => {
|
||||||
|
|
||||||
@@ -603,7 +150,7 @@ async fn main() {
|
|||||||
|
|
||||||
app.on_event(event).await;
|
app.on_event(event).await;
|
||||||
},
|
},
|
||||||
_ => ()
|
_ => break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use async_openai::types::chat::{ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent, ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent};
|
use async_openai::types::chat::{ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent, ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent};
|
||||||
|
use ratatui::style::{self, Style};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -11,6 +12,47 @@ pub enum ConversationEntry {
|
|||||||
SystemMessage(String)
|
SystemMessage(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ConversationEntry {
|
||||||
|
pub fn prefix(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
ConversationEntry::Eva(_) => Some("Eva: "),
|
||||||
|
ConversationEntry::User(_) => Some("Argee: "),
|
||||||
|
ConversationEntry::ShipComputer(_) => Some("Ship Computer: "),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prefix_style(&self) -> Style {
|
||||||
|
match self {
|
||||||
|
ConversationEntry::Eva(_) => Style::new().fg(style::Color::Cyan),
|
||||||
|
ConversationEntry::User(_) => Style::new().fg(style::Color::Magenta),
|
||||||
|
ConversationEntry::ShipComputer(_) => Style::new().fg(style::Color::Green),
|
||||||
|
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
|
||||||
|
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text_style(&self) -> Style {
|
||||||
|
match self {
|
||||||
|
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
|
||||||
|
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
|
||||||
|
_ => Style::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToString for ConversationEntry {
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
match self {
|
||||||
|
ConversationEntry::Eva(text) => text,
|
||||||
|
ConversationEntry::ShipComputer(text) => text,
|
||||||
|
ConversationEntry::StageDirection(text) => text,
|
||||||
|
ConversationEntry::SystemMessage(text) => text,
|
||||||
|
ConversationEntry::User(text) => text
|
||||||
|
}.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TryInto<ChatCompletionRequestMessage> for ConversationEntry {
|
impl TryInto<ChatCompletionRequestMessage> for ConversationEntry {
|
||||||
fn try_into(self) -> Result<ChatCompletionRequestMessage, Self::Error> {
|
fn try_into(self) -> Result<ChatCompletionRequestMessage, Self::Error> {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
+5
-1
@@ -39,7 +39,7 @@ pub struct Scenery {
|
|||||||
pub struct Scene {
|
pub struct Scene {
|
||||||
reply_options: GeneratedResponses,
|
reply_options: GeneratedResponses,
|
||||||
conversation: Vec<ConversationEntry>,
|
conversation: Vec<ConversationEntry>,
|
||||||
pub direction: StageDirection,
|
direction: StageDirection,
|
||||||
pub tokens_consumed: usize,
|
pub tokens_consumed: usize,
|
||||||
scenery: Scenery
|
scenery: Scenery
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,10 @@ impl Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn direction(&self) -> &StageDirection {
|
||||||
|
&self.direction
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scenery(&self) -> &Scenery {
|
pub fn scenery(&self) -> &Scenery {
|
||||||
&self.scenery
|
&self.scenery
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use crossterm::event::{self, KeyCode, KeyModifiers};
|
||||||
|
use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, Style, Stylize}, text::{Line, Span, Text}, widgets::{BorderType, Clear, Gauge, List, ListDirection, ListState, Paragraph, Wrap}};
|
||||||
|
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};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Ui {
|
||||||
|
scene: Scene,
|
||||||
|
|
||||||
|
reply_state: ListState,
|
||||||
|
conversation_state: ListState,
|
||||||
|
user_input: Input,
|
||||||
|
throbber_state: ThrobberState,
|
||||||
|
is_requesting: bool,
|
||||||
|
audio_level: f64,
|
||||||
|
recording_audio: bool,
|
||||||
|
focus_state: FocusState,
|
||||||
|
last_tick: Instant,
|
||||||
|
|
||||||
|
transcription: TranscriptionControl,
|
||||||
|
audio: AudioInputControl,
|
||||||
|
tts: TtsControl,
|
||||||
|
predictions: SessionControl
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum FocusState {
|
||||||
|
Conversation,
|
||||||
|
UserInput
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ui {
|
||||||
|
pub fn new(predictions: SessionControl, audio: AudioInputControl, transcription: TranscriptionControl, tts: TtsControl) -> Self {
|
||||||
|
Self {
|
||||||
|
scene: Default::default(),
|
||||||
|
reply_state: Default::default(),
|
||||||
|
conversation_state: Default::default(),
|
||||||
|
user_input: Default::default(),
|
||||||
|
throbber_state: Default::default(),
|
||||||
|
is_requesting: false,
|
||||||
|
audio_level: -60.,
|
||||||
|
audio,
|
||||||
|
recording_audio: false,
|
||||||
|
transcription,
|
||||||
|
focus_state: FocusState::UserInput,
|
||||||
|
tts,
|
||||||
|
predictions,
|
||||||
|
last_tick: Instant::now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_line<'a>(entry: &'a ConversationEntry, max_width: usize) -> Text<'a> {
|
||||||
|
let prefix = entry.prefix().unwrap_or_default();
|
||||||
|
|
||||||
|
let style = entry.prefix_style();
|
||||||
|
|
||||||
|
let text_style = entry.text_style();
|
||||||
|
|
||||||
|
let avail_width = max_width - prefix.len();
|
||||||
|
let indent = " ".repeat(prefix.len());
|
||||||
|
let wrap_options = textwrap::Options::new(avail_width).initial_indent(prefix).subsequent_indent(&indent);
|
||||||
|
let wrapped: Vec<Line> = textwrap::wrap(&entry.to_string(), wrap_options)
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, s)| {
|
||||||
|
if idx == 0 {
|
||||||
|
Line::from_iter([Span::from(prefix).style(style), Span::from(s[prefix.len()..].to_string()).style(text_style)])
|
||||||
|
} else {
|
||||||
|
Line::from(s.to_string()).style(text_style)
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Text::from_iter(wrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_conversation(&mut self, frame: &mut Frame, area: Rect) {
|
||||||
|
let width = area.width.into();
|
||||||
|
let items: Vec<Text> = self.scene.conversation().iter().rev().map(|entry| {
|
||||||
|
Self::format_line(entry, width)
|
||||||
|
}).collect();
|
||||||
|
// TODO: Would be nice to be able to scroll a longer conversation with the scroll wheel, or with page up/down
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
List::new(items)
|
||||||
|
.block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)"))
|
||||||
|
.direction(ListDirection::BottomToTop)
|
||||||
|
.highlight_symbol("> ")
|
||||||
|
.highlight_style(style::Style::new().bold().fg(style::Color::Cyan)),
|
||||||
|
area,
|
||||||
|
&mut self.conversation_state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_options(&mut self, frame: &mut Frame, area: Rect) {
|
||||||
|
let borders = Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)");
|
||||||
|
if self.is_requesting {
|
||||||
|
let list = SkeletonText::new(std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64)
|
||||||
|
.braille(true)
|
||||||
|
.line_widths(&[0.25, 0.5, 0.4, 0.6])
|
||||||
|
.mode(AnimationMode::Noise)
|
||||||
|
.block(borders);
|
||||||
|
frame.render_widget(list, area);
|
||||||
|
} else {
|
||||||
|
let wrap_options = textwrap::Options::new(area.width as usize).subsequent_indent("...");
|
||||||
|
let options: Vec<Text> = self.scene.reply_options().iter().map(|option| {
|
||||||
|
let mut contents: Vec<Line> = vec![];
|
||||||
|
|
||||||
|
if let Some(direction) = &option.stage_direction {
|
||||||
|
let padded = format!("({})", direction);
|
||||||
|
let mut wrapped_direction: Vec<Line> = textwrap::wrap(&padded, wrap_options.clone())
|
||||||
|
.iter()
|
||||||
|
.map(|x| { Line::from(x.to_string()).fg(style::Color::Yellow)}).collect();
|
||||||
|
contents.append(&mut wrapped_direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut text: Vec<Line> = textwrap::wrap(&option.text, wrap_options.clone())
|
||||||
|
.iter()
|
||||||
|
.map(|x| { Line::from(x.to_string())}).collect();
|
||||||
|
contents.append(&mut text);
|
||||||
|
Text::from_iter(contents)
|
||||||
|
}).collect();
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
List::new(options)
|
||||||
|
.block(borders)
|
||||||
|
.style(ratatui::style::Color::White)
|
||||||
|
.highlight_symbol("> ".fg(ratatui::style::Color::Cyan))
|
||||||
|
.highlight_style(style::Style::new().bold().bg(style::Color::DarkGray))
|
||||||
|
.repeat_highlight_symbol(true),
|
||||||
|
area,
|
||||||
|
&mut self.reply_state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_user_input(&mut self, frame: &mut Frame, area: Rect) {
|
||||||
|
let width = area.width.max(3) - 3;
|
||||||
|
let scroll = self.user_input.visual_scroll(width as usize);
|
||||||
|
let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input (Press 'Ctrl-X' to start/stop audio transcription)")).scroll((0, scroll as u16));
|
||||||
|
frame.render_widget(input, area);
|
||||||
|
let x = self.user_input.visual_cursor().max(scroll);
|
||||||
|
frame.set_cursor_position((area.x + x as u16 + 1, area.y + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
|
||||||
|
} else {
|
||||||
|
frame.render_widget(Clear::default(), throb_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
let time_remaining: Duration = self.scene.direction().time_remaining();
|
||||||
|
let minutes_remaining = time_remaining.num_seconds() / 60;
|
||||||
|
|
||||||
|
let negative = time_remaining.abs() != time_remaining;
|
||||||
|
|
||||||
|
let time_style = if minutes_remaining <= 0 || negative {
|
||||||
|
Style::new().fg(ratatui::style::Color::LightRed).bold()
|
||||||
|
} else if minutes_remaining < 5 {
|
||||||
|
Style::new().fg(ratatui::style::Color::LightRed).bold()
|
||||||
|
} else if minutes_remaining < 10 {
|
||||||
|
ratatui::style::Color::Red.into()
|
||||||
|
} else if minutes_remaining < 25 {
|
||||||
|
ratatui::style::Color::Yellow.into()
|
||||||
|
} else if minutes_remaining < 60 {
|
||||||
|
ratatui::style::Color::Green.into()
|
||||||
|
} else {
|
||||||
|
ratatui::style::Color::Blue.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let formatted_time = if negative {
|
||||||
|
format!("-{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours().abs(), time_remaining.num_minutes().abs()% 60, time_remaining.num_seconds().abs() % 60)
|
||||||
|
} else {
|
||||||
|
format!("{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours(), time_remaining.num_minutes() % 60, time_remaining.num_seconds() % 60)
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_line = Line::from_iter([
|
||||||
|
Span::from(format!("Episode {}", self.scene.direction().episode_number)).style(ratatui::style::Color::LightBlue),
|
||||||
|
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
||||||
|
Span::from(format!("{} tracks", self.scene.scenery().current_playlist.len())).style(ratatui::style::Color::LightBlue),
|
||||||
|
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
||||||
|
Span::from(format!("Time Remaining: {}", formatted_time)).style(time_style),
|
||||||
|
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
||||||
|
Span::from(format!("{} artifacts recorded", self.scene.scenery().artifacts.len())).style(ratatui::style::Color::LightBlue),
|
||||||
|
Span::from(" | ").style(ratatui::style::Color::DarkGray),
|
||||||
|
Span::from(format!("{} tokens sacrificed", self.scene.tokens_consumed)).style(ratatui::style::Color::LightCyan),
|
||||||
|
]);
|
||||||
|
frame.render_widget(status_line, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_narration(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
let narrative_desc = if self.scene.direction().narrative.is_empty() {
|
||||||
|
Span::from("No narrative available.").style(ratatui::style::Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Span::from(self.scene.direction().narrative.clone())
|
||||||
|
};
|
||||||
|
let setting = Paragraph::new(narrative_desc).block(Block::bordered().border_style(style::Color::LightMagenta).title("Stage Direction")).wrap(ratatui::widgets::Wrap { trim: false });
|
||||||
|
frame.render_widget(setting, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_event_log(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
let items: Vec<Line> = self.scene.conversation().iter().filter(|entry| { if let ConversationEntry::StageDirection(_) = entry { true } else { false }}).rev().map(|entry| {
|
||||||
|
match entry {
|
||||||
|
ConversationEntry::StageDirection(text) => Line::from_iter([text]).style(ratatui::style::Color::Yellow),
|
||||||
|
_ => unreachable!()
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
frame.render_widget(Paragraph::new(items).block(Block::bordered().border_style(style::Color::Red).title("Event Log")).wrap(Wrap { trim: false }), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(&mut self, frame: &mut Frame) {
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Fill(3),
|
||||||
|
Constraint::Min(9),
|
||||||
|
Constraint::Max(3),
|
||||||
|
Constraint::Max(1)
|
||||||
|
])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
let scene_layout = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Fill(4), Constraint::Fill(1)])
|
||||||
|
.split(layout[0]);
|
||||||
|
|
||||||
|
let context_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Fill(1), Constraint::Fill(1)])
|
||||||
|
.split(scene_layout[1]);
|
||||||
|
|
||||||
|
self.draw_conversation(frame, scene_layout[0]);
|
||||||
|
self.draw_narration(frame, context_layout[0]);
|
||||||
|
self.draw_event_log(frame, context_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)])
|
||||||
|
.split(layout[3]);
|
||||||
|
|
||||||
|
self.draw_user_input(frame, layout[2]);
|
||||||
|
self.draw_io_throbber(frame, status_layout[0]);
|
||||||
|
self.draw_status(frame, status_layout[1]);
|
||||||
|
self.draw_recording_status(frame, status_layout[2]);
|
||||||
|
self.draw_volume(frame, status_layout[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_recording_status(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
frame.render_widget(Line::from_iter(
|
||||||
|
if self.recording_audio {
|
||||||
|
[
|
||||||
|
Span::from(" "),
|
||||||
|
Span::from(" Recording ").bg(style::Color::LightRed).fg(style::Color::White),
|
||||||
|
Span::from(" ")
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
[
|
||||||
|
Span::from(" "),
|
||||||
|
Span::from(" Stopped ").bg(style::Color::DarkGray).fg(style::Color::White),
|
||||||
|
Span::from(" ")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_volume(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
const NOISE_FLOOR: f64 = 50.;
|
||||||
|
let vu_pct = 1.0 - (self.audio_level.abs().min(NOISE_FLOOR) / NOISE_FLOOR);
|
||||||
|
|
||||||
|
let volume_color = if self.recording_audio {
|
||||||
|
if vu_pct >= 0.85 {
|
||||||
|
style::Color::Red
|
||||||
|
} else if vu_pct >= 0.60 {
|
||||||
|
style::Color::Yellow
|
||||||
|
} else {
|
||||||
|
style::Color::LightGreen
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
style::Color::Gray
|
||||||
|
};
|
||||||
|
|
||||||
|
let gauge = Gauge::default()
|
||||||
|
.ratio(vu_pct)
|
||||||
|
.use_unicode(true)
|
||||||
|
.gauge_style(volume_color)
|
||||||
|
.label(format!("{:.01}dB", self.audio_level));
|
||||||
|
frame.render_widget(gauge, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_selected_prompt(&mut self) {
|
||||||
|
let selected = self.scene.reply_options()[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_command(&mut self, command: &str) {
|
||||||
|
let mut parts = command.splitn(2, " ");
|
||||||
|
let command = parts.next().unwrap();
|
||||||
|
let arg = parts.next().unwrap_or("");
|
||||||
|
match command {
|
||||||
|
// FIXME: Need some new kind of /bandcamp command to force loading of specific urls
|
||||||
|
"/episode" => {
|
||||||
|
if let Ok(episode_number) = arg.trim().parse() {
|
||||||
|
self.predictions.insert(PredictionAction::SetEpisodeNumber(episode_number)).await;
|
||||||
|
} else {
|
||||||
|
self.predictions.log("Invalid episode number format. Use /episode [number]".into()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/timer" => {
|
||||||
|
if let Ok(minutes) = arg.trim().parse::<i64>() {
|
||||||
|
let end_time = Utc::now() + Duration::minutes(minutes);
|
||||||
|
self.predictions.insert(PredictionAction::SetShowEndTime(end_time)).await;
|
||||||
|
self.predictions.log(format!("Set timer for {} minutes.", minutes)).await;
|
||||||
|
} else {
|
||||||
|
self.predictions.log("Invalid timer format. Use /timer [minutes]".into()).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/narrative" => {
|
||||||
|
self.predictions.insert(PredictionAction::SetNarrative(arg.to_string())).await;
|
||||||
|
},
|
||||||
|
"/event" => {
|
||||||
|
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.log("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]".into()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn on_event(&mut self, evt: event::Event) {
|
||||||
|
if let Some(key) = evt.as_key_press_event() {
|
||||||
|
match self.focus_state {
|
||||||
|
FocusState::Conversation => {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
self.focus_state = FocusState::UserInput;
|
||||||
|
self.conversation_state.select(None);
|
||||||
|
self.reply_state.select_first();
|
||||||
|
},
|
||||||
|
KeyCode::PageUp => self.conversation_state.scroll_down_by(5),
|
||||||
|
KeyCode::PageDown => self.conversation_state.scroll_up_by(5),
|
||||||
|
KeyCode::Home => self.conversation_state.select_last(),
|
||||||
|
KeyCode::End => self.conversation_state.select_first(),
|
||||||
|
KeyCode::Down => self.conversation_state.select_previous(),
|
||||||
|
KeyCode::Up => self.conversation_state.select_next(),
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let row_num = self.conversation_state.selected().unwrap();
|
||||||
|
if let ConversationEntry::Eva(text) = &self.scene.conversation()[self.scene.conversation().len() - 1 - row_num] {
|
||||||
|
self.tts.speak(text.clone()).await;
|
||||||
|
self.focus_state = FocusState::UserInput;
|
||||||
|
self.conversation_state.select(None);
|
||||||
|
self.reply_state.select_first();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => ()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FocusState::UserInput => {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
self.focus_state = FocusState::Conversation;
|
||||||
|
self.conversation_state.select_first();
|
||||||
|
self.reply_state.select(None);
|
||||||
|
},
|
||||||
|
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.predictions.regenerate_options().await,
|
||||||
|
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
if self.recording_audio {
|
||||||
|
self.recording_audio = false;
|
||||||
|
self.transcription.stop();
|
||||||
|
} else {
|
||||||
|
self.recording_audio = true;
|
||||||
|
self.transcription.start();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Down => self.reply_state.select_next(),
|
||||||
|
KeyCode::Up => self.reply_state.select_previous(),
|
||||||
|
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.insert_selected_prompt().await;
|
||||||
|
},
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let next_msg = self.user_input.value_and_reset();
|
||||||
|
if next_msg.trim().is_empty() {
|
||||||
|
self.insert_selected_prompt().await;
|
||||||
|
} else {
|
||||||
|
if next_msg.starts_with("/") {
|
||||||
|
self.on_command(&next_msg).await;
|
||||||
|
} else {
|
||||||
|
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(next_msg))).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {self.user_input.handle_event(&evt);},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(&mut self) {
|
||||||
|
|
||||||
|
if self.last_tick.elapsed() >= std::time::Duration::from_millis(100) {
|
||||||
|
self.last_tick = Instant::now();
|
||||||
|
self.throbber_state.calc_next();
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select!{
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_millis(60)), if self.is_requesting => (),
|
||||||
|
next_update = self.predictions.changed() => {
|
||||||
|
match next_update {
|
||||||
|
SessionUpdate::Thinking(is_thinking) => self.is_requesting = is_thinking,
|
||||||
|
SessionUpdate::Scene(scene) => {
|
||||||
|
self.scene = scene;
|
||||||
|
self.reply_state.select_first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next_volume = self.audio.next() => {
|
||||||
|
self.audio_level = next_volume
|
||||||
|
},
|
||||||
|
transcription_result = self.transcription.next() => {
|
||||||
|
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(transcription_result))).await;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user