Compare commits
3 Commits
82775bf9d3
...
918ed3900a
| Author | SHA1 | Date | |
|---|---|---|---|
| 918ed3900a | |||
| ff747da1a0 | |||
| c8810280af |
1
TODO.md
1
TODO.md
|
|
@ -15,7 +15,6 @@
|
|||
- TUI 拡充
|
||||
- TUI から任意タイミングで Compact を発火する system command → [tickets/tui-system-command-compact.md](tickets/tui-system-command-compact.md)
|
||||
- user manifest env override 時の spawn scope overlay 前提ズレ → [tickets/tui-user-manifest-env-overlay.md](tickets/tui-user-manifest-env-overlay.md)
|
||||
- Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
|
||||
- ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
|
||||
- spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md)
|
||||
- メモリ機構
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
use protocol::{
|
||||
|
|
@ -47,6 +48,23 @@ struct RollbackSubmitState {
|
|||
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 pod_name: String,
|
||||
pub connected: bool,
|
||||
|
|
@ -98,6 +116,9 @@ pub struct App {
|
|||
/// Top entry index of the task pane's visible window. Clamped on
|
||||
/// render so it never points past the end of the list.
|
||||
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
|
||||
/// normally or reports that the empty assistant turn was rolled back.
|
||||
pending_submit_rollback: Option<RollbackSubmitState>,
|
||||
|
|
@ -133,6 +154,7 @@ impl App {
|
|||
task_store: TaskStore::new(),
|
||||
task_pane_open: false,
|
||||
task_pane_scroll: 0,
|
||||
queued_inputs: VecDeque::new(),
|
||||
pending_submit_rollback: None,
|
||||
last_rolled_back_input: None,
|
||||
}
|
||||
|
|
@ -351,6 +373,17 @@ impl App {
|
|||
}
|
||||
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
|
||||
// `Event::UserMessage` (single source of truth, shared by every
|
||||
// client subscribed to the Pod). Locally we only clear the
|
||||
|
|
@ -363,8 +396,42 @@ impl App {
|
|||
block_start: self.blocks.len(),
|
||||
turn_before: self.turn_index,
|
||||
});
|
||||
self.input.clear();
|
||||
Some(Method::Run { input: segments })
|
||||
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>) {
|
||||
|
|
@ -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 {
|
||||
Event::UserMessage { segments } => {
|
||||
self.turn_index += 1;
|
||||
|
|
@ -708,6 +775,9 @@ impl App {
|
|||
PodStatus::Idle
|
||||
}
|
||||
});
|
||||
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
|
||||
return self.pop_next_queued_run();
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::CompactStart => {
|
||||
|
|
@ -791,6 +861,7 @@ impl App {
|
|||
self.quit = true;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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> {
|
||||
for c in text.chars() {
|
||||
app.insert_char(c);
|
||||
|
|
|
|||
|
|
@ -477,18 +477,23 @@ async fn drain_terminal_events(
|
|||
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;
|
||||
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
||||
match client.try_next_event() {
|
||||
Some(ev) => {
|
||||
handled = true;
|
||||
app.handle_pod_event(ev);
|
||||
if let Some(method) = app.handle_pod_event(ev) {
|
||||
client.send(&method).await?;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
handled
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
async fn run_loop(
|
||||
|
|
@ -509,7 +514,7 @@ async fn run_loop(
|
|||
if app.quit {
|
||||
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 {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
continue;
|
||||
|
|
@ -520,7 +525,11 @@ async fn run_loop(
|
|||
handle_terminal_event(app, &mut client, term_event?).await?;
|
||||
}
|
||||
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 => {
|
||||
app.connected = false;
|
||||
app.mark_orphan_compacts_incomplete();
|
||||
|
|
@ -635,9 +644,23 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.move_cursor_start();
|
||||
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('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),
|
||||
}),
|
||||
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).
|
||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||
if app.pod_status == PodStatus::Running {
|
||||
app.clear_queued_inputs();
|
||||
return Some(Method::Pause);
|
||||
}
|
||||
if let Some(t) = app.quit_confirm
|
||||
|
|
@ -919,4 +943,120 @@ mod tests {
|
|||
"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)));
|
||||
}
|
||||
|
||||
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_line = Line::from(Span::styled(right_text, Style::default().fg(Color::Gray)))
|
||||
.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) {
|
||||
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();
|
||||
if !app.scroll.follow_tail {
|
||||
right.push(Span::styled(
|
||||
|
|
@ -1161,10 +1174,28 @@ fn draw_actionbar(frame: &mut Frame, app: &App, area: Rect) {
|
|||
format!("[{}]", app.mode.label()),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
let left_line = Line::from(left);
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
// Prefix "> " on the first row, two-space gutter for continuation
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
# TUI: Run 中の入力キューイング
|
||||
|
||||
## 背景
|
||||
|
||||
Pod は `Method::Run` を Run 中に受け取ると `AlreadyRunning` で即座に拒否する設計(1 turn = 1 message)。これはプログラマティックなクライアント(pod_cli、Pod 間通信の `sendToPod`)にとっては自然な契約だが、TUI で人間が操作している場合、「現ターンが終わるのを待って次を投げる」ためにユーザーが目視で完了を待つ必要があり、テンポが悪い。
|
||||
|
||||
「次のターンが終わり次第すぐに投げる」を可能にしたい。
|
||||
|
||||
## 方針
|
||||
|
||||
**プロトコルには載せず、TUI クライアント側でキューを持つ。**
|
||||
|
||||
理由:
|
||||
|
||||
- キューイングは人間が連続入力する TUI 固有の都合。pod_cli や Pod 間通信は 1 turn = 1 message が自然で、不要な抽象を背負わせない。
|
||||
- キュー上のメッセージは「まだ送信していないユーザー入力」であり、送信前のキャンセル・編集が自然にできるべき。これは TUI ローカル状態で扱うのが素直。
|
||||
- Pod controller / protocol への変更が不要。他クライアントへの影響ゼロ。
|
||||
|
||||
## 要件
|
||||
|
||||
- Run 中に Enter で送信した入力は TUI 内のキューに積まれる(即座に Pod に送信されない)。
|
||||
- 現在の Run が `RunEnd` で終了したら、キュー先頭を `Method::Run` として送信する。
|
||||
- キューに積まれた状態がユーザーから見える(積まれていることが分かる UI 表現)。
|
||||
- キュー内のメッセージは送信前にキャンセル・編集できる。
|
||||
- Pause 中の Resume 入力との関係を整理する(Resume はキューイングではなく即座に発火、で良いはず)。
|
||||
- キャンセル(`Method::Cancel`)が走った場合、キューをどう扱うかを決める(破棄が妥当と思われるが要検討)。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- TUI で Run 中に入力を送信すると、現ターン終了直後に自動で次が投げられる。
|
||||
- 積まれた状態と、送信前のキャンセル・編集ができる。
|
||||
- pod プロトコルや pod crate に変更が入っていない。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- 複数 Pod 間でのキュー共有・転送
|
||||
- キューの永続化(TUI 再起動をまたぐ保存)
|
||||
- キュー内メッセージの優先度・並び替え(FIFO で十分)
|
||||
Loading…
Reference in New Issue
Block a user