feat: queue tui input during runs
This commit is contained in:
parent
82775bf9d3
commit
c8810280af
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
|
|
@ -47,6 +48,23 @@ struct RollbackSubmitState {
|
||||||
turn_before: usize,
|
turn_before: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct QueuedInput {
|
||||||
|
segments: Vec<Segment>,
|
||||||
|
preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueuedInput {
|
||||||
|
fn new(segments: Vec<Segment>) -> Self {
|
||||||
|
let preview = Segment::flatten_to_text(&segments);
|
||||||
|
Self { segments, preview }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preview(&self) -> &str {
|
||||||
|
&self.preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub pod_name: String,
|
pub pod_name: String,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
|
|
@ -98,6 +116,9 @@ pub struct App {
|
||||||
/// Top entry index of the task pane's visible window. Clamped on
|
/// Top entry index of the task pane's visible window. Clamped on
|
||||||
/// render so it never points past the end of the list.
|
/// render so it never points past the end of the list.
|
||||||
pub task_pane_scroll: usize,
|
pub task_pane_scroll: usize,
|
||||||
|
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
|
||||||
|
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
|
||||||
|
queued_inputs: VecDeque<QueuedInput>,
|
||||||
/// Local submit state kept until the accepted run either completes
|
/// Local submit state kept until the accepted run either completes
|
||||||
/// normally or reports that the empty assistant turn was rolled back.
|
/// normally or reports that the empty assistant turn was rolled back.
|
||||||
pending_submit_rollback: Option<RollbackSubmitState>,
|
pending_submit_rollback: Option<RollbackSubmitState>,
|
||||||
|
|
@ -133,6 +154,7 @@ impl App {
|
||||||
task_store: TaskStore::new(),
|
task_store: TaskStore::new(),
|
||||||
task_pane_open: false,
|
task_pane_open: false,
|
||||||
task_pane_scroll: 0,
|
task_pane_scroll: 0,
|
||||||
|
queued_inputs: VecDeque::new(),
|
||||||
pending_submit_rollback: None,
|
pending_submit_rollback: None,
|
||||||
last_rolled_back_input: None,
|
last_rolled_back_input: None,
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +373,17 @@ impl App {
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if self.running {
|
||||||
|
self.queued_inputs.push_back(QueuedInput::new(segments));
|
||||||
|
self.input.clear();
|
||||||
|
self.completion = None;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.input.clear();
|
||||||
|
Some(self.method_for_run(segments))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
||||||
// TurnHeader / UserMessage blocks are pushed in response to
|
// TurnHeader / UserMessage blocks are pushed in response to
|
||||||
// `Event::UserMessage` (single source of truth, shared by every
|
// `Event::UserMessage` (single source of truth, shared by every
|
||||||
// client subscribed to the Pod). Locally we only clear the
|
// client subscribed to the Pod). Locally we only clear the
|
||||||
|
|
@ -363,8 +396,42 @@ impl App {
|
||||||
block_start: self.blocks.len(),
|
block_start: self.blocks.len(),
|
||||||
turn_before: self.turn_index,
|
turn_before: self.turn_index,
|
||||||
});
|
});
|
||||||
self.input.clear();
|
Method::Run { input: segments }
|
||||||
Some(Method::Run { input: segments })
|
}
|
||||||
|
|
||||||
|
pub fn queued_input_count(&self) -> usize {
|
||||||
|
self.queued_inputs.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_queued_input_preview(&self) -> Option<&str> {
|
||||||
|
self.queued_inputs.front().map(QueuedInput::preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_queued_inputs(&mut self) -> usize {
|
||||||
|
let cleared = self.queued_inputs.len();
|
||||||
|
self.queued_inputs.clear();
|
||||||
|
cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_next_queued_input_to_composer(&mut self) -> bool {
|
||||||
|
if self.queued_inputs.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !self.input.is_empty() {
|
||||||
|
self.push_error("Composer is not empty; clear it before editing queued input.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(queued) = self.queued_inputs.pop_front() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.input.replace_with_segments(&queued.segments);
|
||||||
|
self.completion = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_next_queued_run(&mut self) -> Option<Method> {
|
||||||
|
let queued = self.queued_inputs.pop_front()?;
|
||||||
|
Some(self.method_for_run(queued.segments))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_error(&mut self, message: impl Into<String>) {
|
pub fn push_error(&mut self, message: impl Into<String>) {
|
||||||
|
|
@ -502,7 +569,7 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_pod_event(&mut self, event: Event) {
|
pub fn handle_pod_event(&mut self, event: Event) -> Option<Method> {
|
||||||
match event {
|
match event {
|
||||||
Event::UserMessage { segments } => {
|
Event::UserMessage { segments } => {
|
||||||
self.turn_index += 1;
|
self.turn_index += 1;
|
||||||
|
|
@ -708,6 +775,9 @@ impl App {
|
||||||
PodStatus::Idle
|
PodStatus::Idle
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
|
||||||
|
return self.pop_next_queued_run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::CompactStart => {
|
Event::CompactStart => {
|
||||||
|
|
@ -791,6 +861,7 @@ impl App {
|
||||||
self.quit = true;
|
self.quit = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_run_state(&mut self, status: PodStatus) {
|
fn reset_run_state(&mut self, status: PodStatus) {
|
||||||
|
|
@ -1610,6 +1681,108 @@ mod completion_flow_tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_submit_is_queued_locally_and_clears_composer() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
insert_text(&mut app, "queued turn");
|
||||||
|
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
assert_eq!(app.next_queued_input_preview(), Some("queued turn"));
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finished_run_auto_sends_next_queued_input() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
insert_text(&mut app, "next turn");
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
|
let method = app.handle_pod_event(Event::RunEnd {
|
||||||
|
result: RunResult::Finished,
|
||||||
|
});
|
||||||
|
|
||||||
|
match method {
|
||||||
|
Some(Method::Run { input }) => {
|
||||||
|
assert_eq!(Segment::flatten_to_text(&input), "next turn");
|
||||||
|
}
|
||||||
|
other => panic!("expected queued Run, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_reached_run_auto_sends_next_queued_input() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
insert_text(&mut app, "next after limit");
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
|
let method = app.handle_pod_event(Event::RunEnd {
|
||||||
|
result: RunResult::LimitReached,
|
||||||
|
});
|
||||||
|
|
||||||
|
match method {
|
||||||
|
Some(Method::Run { input }) => {
|
||||||
|
assert_eq!(Segment::flatten_to_text(&input), "next after limit");
|
||||||
|
}
|
||||||
|
other => panic!("expected queued Run, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
|
||||||
|
for result in [RunResult::Paused, RunResult::RolledBack] {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
insert_text(&mut app, "held turn");
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
|
let method = app.handle_pod_event(Event::RunEnd { result });
|
||||||
|
|
||||||
|
assert!(method.is_none());
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
assert_eq!(app.next_queued_input_preview(), Some("held turn"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paused_empty_submit_still_resumes_immediately() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.set_pod_status(PodStatus::Paused);
|
||||||
|
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Resume)));
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_input_can_be_restored_to_composer_or_cleared() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
insert_text(&mut app, "edit me");
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
|
assert!(app.restore_next_queued_input_to_composer());
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
assert_eq!(input_text(&app), "edit me");
|
||||||
|
|
||||||
|
app.input.clear();
|
||||||
|
insert_text(&mut app, "clear me");
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(app.clear_queued_inputs(), 1);
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_text(app: &mut App, text: &str) {
|
||||||
|
for c in text.chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn submit_text(app: &mut App, text: &str) -> Vec<Segment> {
|
fn submit_text(app: &mut App, text: &str) -> Vec<Segment> {
|
||||||
for c in text.chars() {
|
for c in text.chars() {
|
||||||
app.insert_char(c);
|
app.insert_char(c);
|
||||||
|
|
|
||||||
|
|
@ -477,18 +477,23 @@ async fn drain_terminal_events(
|
||||||
Ok(handled)
|
Ok(handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_pod_events(app: &mut App, client: &mut PodClient) -> bool {
|
async fn drain_pod_events(
|
||||||
|
app: &mut App,
|
||||||
|
client: &mut PodClient,
|
||||||
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
||||||
match client.try_next_event() {
|
match client.try_next_event() {
|
||||||
Some(ev) => {
|
Some(ev) => {
|
||||||
handled = true;
|
handled = true;
|
||||||
app.handle_pod_event(ev);
|
if let Some(method) = app.handle_pod_event(ev) {
|
||||||
|
client.send(&method).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handled
|
Ok(handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_loop(
|
async fn run_loop(
|
||||||
|
|
@ -509,7 +514,7 @@ async fn run_loop(
|
||||||
if app.quit {
|
if app.quit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let handled_pod_event = drain_pod_events(app, &mut client);
|
let handled_pod_event = drain_pod_events(app, &mut client).await?;
|
||||||
if handled_term_event || handled_pod_event {
|
if handled_term_event || handled_pod_event {
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -520,7 +525,11 @@ async fn run_loop(
|
||||||
handle_terminal_event(app, &mut client, term_event?).await?;
|
handle_terminal_event(app, &mut client, term_event?).await?;
|
||||||
}
|
}
|
||||||
LoopInput::Pod(event) => match event {
|
LoopInput::Pod(event) => match event {
|
||||||
Some(ev) => app.handle_pod_event(ev),
|
Some(ev) => {
|
||||||
|
if let Some(method) = app.handle_pod_event(ev) {
|
||||||
|
client.send(&method).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
app.connected = false;
|
app.connected = false;
|
||||||
app.mark_orphan_compacts_incomplete();
|
app.mark_orphan_compacts_incomplete();
|
||||||
|
|
@ -635,9 +644,23 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
app.move_cursor_start();
|
app.move_cursor_start();
|
||||||
Some(app.refresh_completion())
|
Some(app.refresh_completion())
|
||||||
}
|
}
|
||||||
|
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') && alt && !ctrl => {
|
||||||
|
if app.restore_next_queued_input_to_composer() {
|
||||||
|
Some(app.refresh_completion())
|
||||||
|
} else {
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
|
||||||
|
app.clear_queued_inputs();
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
||||||
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
|
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
|
||||||
PodStatus::Running => Some(Method::Cancel),
|
PodStatus::Running => {
|
||||||
|
app.clear_queued_inputs();
|
||||||
|
Some(Method::Cancel)
|
||||||
|
}
|
||||||
PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
|
PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
|
||||||
}),
|
}),
|
||||||
KeyCode::Char('d') if ctrl => {
|
KeyCode::Char('d') if ctrl => {
|
||||||
|
|
@ -790,6 +813,7 @@ const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
|
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
|
||||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
if app.pod_status == PodStatus::Running {
|
if app.pod_status == PodStatus::Running {
|
||||||
|
app.clear_queued_inputs();
|
||||||
return Some(Method::Pause);
|
return Some(Method::Pause);
|
||||||
}
|
}
|
||||||
if let Some(t) = app.quit_confirm
|
if let Some(t) = app.quit_confirm
|
||||||
|
|
@ -919,4 +943,120 @@ mod tests {
|
||||||
"abc"
|
"abc"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_enter_queues_instead_of_sending_run() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
for c in "queued".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||||
|
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
assert_eq!(app.next_queued_input_preview(), Some("queued"));
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_input_keybindings_restore_and_clear() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
for c in "edit queued".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
assert_eq!(input_text(&app), "edit queued");
|
||||||
|
|
||||||
|
app.input.clear();
|
||||||
|
for c in "clear queued".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_and_cancel_clear_queued_input() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
for c in "queued".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
|
||||||
|
let pause = handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
assert!(matches!(pause, Some(Method::Pause)));
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
|
||||||
|
for c in "queued again".chars() {
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
|
||||||
|
let cancel = handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
assert!(matches!(cancel, Some(Method::Cancel)));
|
||||||
|
assert_eq!(app.queued_input_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_text(app: &App) -> String {
|
||||||
|
protocol::Segment::flatten_to_text(&app.input.submit_segments())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1141,6 +1141,11 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
|
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(queue) = queue_status_text(app) {
|
||||||
|
spans.push(Span::raw(" | "));
|
||||||
|
spans.push(Span::styled(queue, Style::default().fg(Color::Magenta)));
|
||||||
|
}
|
||||||
|
|
||||||
let right_text = context_usage_text(app);
|
let right_text = context_usage_text(app);
|
||||||
let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
|
let right_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
|
||||||
.alignment(ratatui::layout::Alignment::Right);
|
.alignment(ratatui::layout::Alignment::Right);
|
||||||
|
|
@ -1150,6 +1155,14 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
|
fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let mut left: Vec<Span<'static>> = Vec::new();
|
||||||
|
if app.queued_input_count() > 0 {
|
||||||
|
left.push(Span::styled(
|
||||||
|
"Alt-q edit queued Alt-c clear queued",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut right: Vec<Span<'static>> = Vec::new();
|
let mut right: Vec<Span<'static>> = Vec::new();
|
||||||
if !app.scroll.follow_tail {
|
if !app.scroll.follow_tail {
|
||||||
right.push(Span::styled(
|
right.push(Span::styled(
|
||||||
|
|
@ -1161,10 +1174,28 @@ fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
format!("[{}]", app.mode.label()),
|
format!("[{}]", app.mode.label()),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
));
|
));
|
||||||
|
let left_line = Line::from(left);
|
||||||
let right_line = Line::from(right).alignment(ratatui::layout::Alignment::Right);
|
let right_line = Line::from(right).alignment(ratatui::layout::Alignment::Right);
|
||||||
|
frame.render_widget(Paragraph::new(left_line), area);
|
||||||
frame.render_widget(Paragraph::new(right_line), area);
|
frame.render_widget(Paragraph::new(right_line), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn queue_status_text(app: &App) -> Option<String> {
|
||||||
|
let count = app.queued_input_count();
|
||||||
|
if count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut text = format!("queued: {count}");
|
||||||
|
if let Some(preview) = app.next_queued_input_preview() {
|
||||||
|
let preview = truncate_with_ellipsis(preview.trim(), 40);
|
||||||
|
if !preview.is_empty() {
|
||||||
|
text.push_str(" — ");
|
||||||
|
text.push_str(&preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) {
|
fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) {
|
||||||
// Prefix "> " on the first row, two-space gutter for continuation
|
// Prefix "> " on the first row, two-space gutter for continuation
|
||||||
// rows so multi-line input aligns visually.
|
// rows so multi-line input aligns visually.
|
||||||
|
|
@ -1324,3 +1355,32 @@ fn format_pod_event(event: &PodEvent) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::app::App;
|
||||||
|
use protocol::PodStatus;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_status_text_includes_count_and_preview() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.set_pod_status(PodStatus::Running);
|
||||||
|
for c in "queued preview".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
queue_status_text(&app),
|
||||||
|
Some("queued: 1 — queued preview".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_status_text_is_absent_without_queue() {
|
||||||
|
let app = App::new("test".into());
|
||||||
|
|
||||||
|
assert_eq!(queue_status_text(&app), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user