feat: TUI上に進行中のTaskを表示する実装

This commit is contained in:
Keisuke Hirata 2026-05-04 17:06:02 +09:00
parent 006897790c
commit cb7da11de7
7 changed files with 749 additions and 17 deletions

1
Cargo.lock generated
View File

@ -3615,6 +3615,7 @@ dependencies = [
"pod-registry",
"protocol",
"ratatui",
"serde",
"serde_json",
"session-store",
"tokio",

View File

@ -16,3 +16,4 @@ toml = { workspace = true }
manifest = { workspace = true }
session-store = { workspace = true }
pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] }

View File

@ -11,6 +11,7 @@ use crate::block::{
use crate::cache::FileCache;
use crate::input::InputBuffer;
use crate::scroll::Scroll;
use crate::task::TaskStore;
use crate::ui::Mode;
/// In-flight completion popup state. Lives on `App` while the user is
@ -76,6 +77,16 @@ pub struct App {
/// Completion popup state, when an `@` / `#` / `/` token is in
/// flight. `None` whenever the trigger conditions don't hold.
pub completion: Option<CompletionState>,
/// In-TUI mirror of the Pod's session task store, reconstructed
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
/// `[Session TaskStore snapshot]` system messages — no protocol
/// surface added on the Pod side.
pub task_store: TaskStore,
/// Whether the right-side task pane is currently open.
pub task_pane_open: bool,
/// 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,
}
impl App {
@ -100,9 +111,27 @@ impl App {
cache: FileCache::new(),
assistant_streaming: false,
completion: None,
task_store: TaskStore::new(),
task_pane_open: false,
task_pane_scroll: 0,
}
}
pub fn toggle_task_pane(&mut self) {
self.task_pane_open = !self.task_pane_open;
if !self.task_pane_open {
self.task_pane_scroll = 0;
}
}
pub fn scroll_task_pane_up(&mut self, n: usize) {
self.task_pane_scroll = self.task_pane_scroll.saturating_sub(n);
}
pub fn scroll_task_pane_down(&mut self, n: usize) {
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n);
}
pub fn set_pod_status(&mut self, status: PodStatus) {
self.pod_status = status;
self.running = status == PodStatus::Running;
@ -352,6 +381,7 @@ impl App {
self.blocks.push(Block::AssistantText { text });
}
"system" if !text.is_empty() => {
self.task_store.apply_system_message_text(&text);
self.blocks.push(Block::SystemMessage { text });
}
_ => {}
@ -365,6 +395,9 @@ impl App {
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let name = item["name"].as_str().unwrap_or("?").to_owned();
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
if let Some(args) = arguments.as_deref() {
self.task_store.apply_tool_call(&name, args);
}
self.blocks.push(Block::ToolCall(ToolCallBlock {
id,
name,
@ -535,6 +568,12 @@ impl App {
}
Event::ToolCallDone { id, arguments, .. } => {
self.current_tool = None;
let name = self
.find_tool_call_mut(&id)
.map(|b| b.name.clone());
if let Some(name) = name.as_deref() {
self.task_store.apply_tool_call(name, &arguments);
}
if let Some(b) = self.find_tool_call_mut(&id) {
b.arguments = Some(arguments);
// Only advance the state when it's still in-flight.
@ -815,6 +854,8 @@ impl App {
self.turn_index = 0;
self.blocks.clear();
self.cache = FileCache::new();
self.task_store = TaskStore::new();
self.task_pane_scroll = 0;
self.blocks.push(Block::Greeting(greeting));
self.assistant_streaming = false;
@ -1279,6 +1320,147 @@ mod completion_flow_tests {
tools: Vec::new(),
}
}
#[test]
fn live_task_create_updates_task_store() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::ToolCallStart {
id: "c1".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: "c1".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(),
});
let tasks = app.task_store.tasks();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "impl tasks");
assert_eq!(tasks[0].status, crate::task::TaskStatus::Pending);
}
#[test]
fn live_task_update_advances_status() {
let mut app = App::new("test".into());
for (id, args) in [
("c1", r#"{"subject":"a","description":"A"}"#),
("u1", r#"{"taskid":1,"status":"completed"}"#),
] {
let name = if id.starts_with('c') {
"TaskCreate"
} else {
"TaskUpdate"
};
app.handle_pod_event(Event::ToolCallStart {
id: id.into(),
name: name.into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: id.into(),
name: name.into(),
arguments: args.into(),
});
}
assert_eq!(
app.task_store.tasks()[0].status,
crate::task::TaskStatus::Completed
);
}
#[test]
fn live_system_snapshot_replaces_task_store() {
let mut app = App::new("test".into());
// Stale entry that the snapshot must wipe out.
app.handle_pod_event(Event::ToolCallStart {
id: "c1".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: "c1".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"stale","description":""}"#.into(),
});
let snapshot = "[Session TaskStore snapshot]\n\n\
TaskStore: 1 task(s)\n\n\
```json\n{\n \"tasks\": [\n {\n \"taskid\": 4,\n \
\"status\": \"inprogress\",\n \"subject\": \"from snapshot\",\n \
\"description\": \"d\"\n }\n ]\n}\n```\n";
app.handle_pod_event(Event::SystemMessage {
item: serde_json::json!({
"type": "message",
"role": "system",
"content": [{ "type": "text", "text": snapshot }],
}),
});
let tasks = app.task_store.tasks();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].taskid, 4);
assert_eq!(tasks[0].subject, "from snapshot");
}
#[test]
fn history_replay_reconstructs_task_store() {
let mut app = App::new("test".into());
// Live tool call before history lands — restore_history must
// wipe this so it doesn't double-count after replay.
app.handle_pod_event(Event::ToolCallStart {
id: "live".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
id: "live".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"live","description":""}"#.into(),
});
app.handle_pod_event(Event::History {
greeting: test_greeting(),
items: vec![
serde_json::json!({
"type": "tool_call",
"call_id": "c1",
"name": "TaskCreate",
"arguments": r#"{"subject":"a","description":"A"}"#,
}),
serde_json::json!({
"type": "tool_call",
"call_id": "c2",
"name": "TaskCreate",
"arguments": r#"{"subject":"b","description":"B"}"#,
}),
serde_json::json!({
"type": "tool_call",
"call_id": "u1",
"name": "TaskUpdate",
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
}),
],
status: PodStatus::Running,
});
let tasks = app.task_store.tasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].subject, "a");
assert_eq!(tasks[1].subject, "b");
assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress);
}
#[test]
fn task_pane_toggle_flips_state_and_resets_scroll() {
let mut app = App::new("test".into());
app.task_pane_scroll = 7;
assert!(!app.task_pane_open);
app.toggle_task_pane();
assert!(app.task_pane_open);
// Scroll position is preserved on open so the user keeps their
// place if they re-open after closing.
assert_eq!(app.task_pane_scroll, 7);
app.toggle_task_pane();
assert!(!app.task_pane_open);
assert_eq!(app.task_pane_scroll, 0);
}
}
/// Seed / mutate the file-content cache based on a completed tool call.

View File

@ -6,6 +6,7 @@ mod input;
mod picker;
mod scroll;
mod spawn;
mod task;
mod tool;
mod ui;
@ -384,6 +385,11 @@ fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
/// looking for.
const WHEEL_LINES: usize = 3;
/// Lines to advance per PageUp / PageDown when the task side pane is
/// open. Calibrated so a couple of presses moves through one entry's
/// subject + description block.
const PANE_SCROLL_LINES: usize = 5;
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES),
@ -427,6 +433,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.mode = app.mode.cycle();
Some(None)
}
KeyCode::Char('t') if ctrl => {
app.toggle_task_pane();
Some(None)
}
KeyCode::Char('a') if ctrl => {
app.move_cursor_start();
Some(app.refresh_completion())
@ -455,14 +465,24 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
return None;
}
// Scroll / navigation (history view).
// Scroll / navigation. PageUp / PageDown defaults to history; while
// the task pane is open it scrolls the pane instead so the user can
// browse past entries without first closing the pane.
match key.code {
KeyCode::PageUp => {
app.scroll.page_up();
if app.task_pane_open {
app.scroll_task_pane_up(PANE_SCROLL_LINES);
} else {
app.scroll.page_up();
}
return None;
}
KeyCode::PageDown => {
app.scroll.page_down();
if app.task_pane_open {
app.scroll_task_pane_down(PANE_SCROLL_LINES);
} else {
app.scroll.page_down();
}
return None;
}
_ => {}

317
crates/tui/src/task.rs Normal file
View File

@ -0,0 +1,317 @@
//! In-TUI mirror of the session-lifetime task store.
//!
//! This deliberately does NOT depend on `tools::TaskStore`. The TUI is a
//! presentation layer; pulling in `tools` would drag along `llm-worker`
//! and the whole tool surface. Instead we mirror the small subset we
//! need:
//!
//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with `tools`'s JSON
//! serialization (`#[serde(rename_all = "lowercase")]` on the status,
//! matching field names on the entry).
//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate`
//! tool-call arguments and the `[Session TaskStore snapshot]` system
//! message that compaction emits.
//!
//! The snapshot text format is owned by `tools::render_snapshot`. Since
//! `tools` itself parses it back on resume, the shape is a stable
//! contract.
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Pending,
Inprogress,
Completed,
Deleted,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TaskEntry {
pub taskid: u64,
pub status: TaskStatus,
pub subject: String,
pub description: String,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TaskCounts {
pub pending: usize,
pub inprogress: usize,
pub completed: usize,
pub deleted: usize,
}
impl TaskCounts {
pub fn total(&self) -> usize {
self.pending + self.inprogress + self.completed + self.deleted
}
pub fn active(&self) -> usize {
self.pending + self.inprogress
}
}
#[derive(Debug, Default, Clone)]
pub struct TaskStore {
next_taskid: u64,
tasks: Vec<TaskEntry>,
}
impl TaskStore {
pub fn new() -> Self {
Self {
next_taskid: 1,
tasks: Vec::new(),
}
}
pub fn tasks(&self) -> &[TaskEntry] {
&self.tasks
}
pub fn is_empty(&self) -> bool {
self.tasks.is_empty()
}
pub fn counts(&self) -> TaskCounts {
let mut c = TaskCounts::default();
for t in &self.tasks {
match t.status {
TaskStatus::Pending => c.pending += 1,
TaskStatus::Inprogress => c.inprogress += 1,
TaskStatus::Completed => c.completed += 1,
TaskStatus::Deleted => c.deleted += 1,
}
}
c
}
/// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other
/// tool names and unparseable JSON are silent no-ops, matching the
/// resilience of `tools::TaskStore::replay_history`.
pub fn apply_tool_call(&mut self, name: &str, arguments: &str) {
match name {
"TaskCreate" => {
if let Ok(p) = serde_json::from_str::<TaskCreateParams>(arguments) {
self.tasks.push(TaskEntry {
taskid: self.next_taskid,
status: TaskStatus::Pending,
subject: p.subject,
description: p.description,
});
self.next_taskid = self.next_taskid.saturating_add(1);
}
}
"TaskUpdate" => {
if let Ok(p) = serde_json::from_str::<TaskUpdateParams>(arguments)
&& let Some(t) = self.tasks.iter_mut().find(|t| t.taskid == p.taskid)
{
if let Some(s) = p.status {
t.status = s;
}
if let Some(s) = p.subject {
t.subject = s;
}
if let Some(d) = p.description {
t.description = d;
}
}
}
_ => {}
}
}
/// Replace all state from a `[Session TaskStore snapshot]` system
/// message. No-op if the text doesn't carry one.
pub fn apply_system_message_text(&mut self, text: &str) {
if let Some(tasks) = parse_snapshot_text(text) {
self.replace_with(tasks);
}
}
fn replace_with(&mut self, tasks: Vec<TaskEntry>) {
self.next_taskid = tasks
.iter()
.map(|t| t.taskid)
.max()
.unwrap_or(0)
.saturating_add(1)
.max(1);
self.tasks = tasks;
}
}
#[derive(Debug, Deserialize)]
struct TaskCreateParams {
subject: String,
description: String,
}
#[derive(Debug, Deserialize)]
struct TaskUpdateParams {
taskid: u64,
#[serde(default)]
status: Option<TaskStatus>,
#[serde(default)]
subject: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TaskSnapshot {
tasks: Vec<TaskEntry>,
}
fn parse_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
if !text.starts_with("[Session TaskStore snapshot]") {
return None;
}
let start_marker = "```json\n";
let end_marker = "\n```";
let start = text.find(start_marker)? + start_marker.len();
let rest = &text[start..];
let end = rest.find(end_marker)?;
let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?;
Some(snapshot.tasks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_create_assigns_sequential_ids() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":"A"}"#);
s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":"B"}"#);
let tasks = s.tasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].taskid, 1);
assert_eq!(tasks[0].subject, "a");
assert_eq!(tasks[0].status, TaskStatus::Pending);
assert_eq!(tasks[1].taskid, 2);
}
#[test]
fn task_update_changes_status_and_fields() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":"A"}"#);
s.apply_tool_call(
"TaskUpdate",
r#"{"taskid":1,"status":"inprogress","subject":"a-renamed"}"#,
);
let t = &s.tasks()[0];
assert_eq!(t.status, TaskStatus::Inprogress);
assert_eq!(t.subject, "a-renamed");
assert_eq!(t.description, "A");
}
#[test]
fn malformed_arguments_are_silently_ignored() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":1}"#);
s.apply_tool_call("TaskCreate", "not json");
s.apply_tool_call("Unknown", r#"{"subject":"x","description":"y"}"#);
s.apply_tool_call("TaskUpdate", r#"{"taskid":99,"status":"deleted"}"#);
assert!(s.tasks().is_empty());
}
#[test]
fn counts_classifies_each_status() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#);
s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":""}"#);
s.apply_tool_call("TaskCreate", r#"{"subject":"c","description":""}"#);
s.apply_tool_call("TaskUpdate", r#"{"taskid":1,"status":"inprogress"}"#);
s.apply_tool_call("TaskUpdate", r#"{"taskid":2,"status":"completed"}"#);
let c = s.counts();
assert_eq!(c.pending, 1);
assert_eq!(c.inprogress, 1);
assert_eq!(c.completed, 1);
assert_eq!(c.deleted, 0);
assert_eq!(c.total(), 3);
assert_eq!(c.active(), 2);
}
/// Snapshot text matches the wrapping `Pod::try_pre_run_compact` /
/// `tools::render_snapshot` produce: header line, blank, overview
/// line, blank, fenced JSON, trailing prose.
fn wrap_snapshot(json_body: &str, overview: &str) -> String {
format!(
"[Session TaskStore snapshot]\n\n{overview}\n\n```json\n{json_body}\n```\n\n\
This is the complete session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane."
)
}
#[test]
fn snapshot_text_replaces_state_and_advances_next_id() {
let body = r#"{
"tasks": [
{
"taskid": 5,
"status": "completed",
"subject": "first",
"description": "first desc"
},
{
"taskid": 7,
"status": "pending",
"subject": "second",
"description": "second desc"
}
]
}"#;
let text = wrap_snapshot(
body,
"TaskStore: 2 task(s) (pending: 1, inprogress: 0, completed: 1, deleted: 0)",
);
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#);
s.apply_system_message_text(&text);
let tasks = s.tasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].taskid, 5);
assert_eq!(tasks[0].status, TaskStatus::Completed);
assert_eq!(tasks[1].taskid, 7);
// Subsequent TaskCreate must continue beyond the highest taskid
// observed in the snapshot.
s.apply_tool_call("TaskCreate", r#"{"subject":"new","description":""}"#);
assert_eq!(s.tasks()[2].taskid, 8);
}
#[test]
fn unrelated_system_message_is_ignored() {
let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#);
s.apply_system_message_text("[File: src/main.rs]\nfn main() {}");
assert_eq!(s.tasks().len(), 1);
}
#[test]
fn snapshot_text_with_multiline_subject_round_trips() {
// Newlines / shape-breaking chars must survive JSON escaping.
let body = r#"{
"tasks": [
{
"taskid": 1,
"status": "inprogress",
"subject": "subject with\nembedded newline",
"description": "desc:\n status: not-actually-a-field"
}
]
}"#;
let text = wrap_snapshot(body, "TaskStore: 1 task(s)");
let mut s = TaskStore::new();
s.apply_system_message_text(&text);
let t = &s.tasks()[0];
assert_eq!(t.subject, "subject with\nembedded newline");
assert_eq!(
t.description,
"desc:\n status: not-actually-a-field"
);
}
}

View File

@ -26,6 +26,7 @@ use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment};
use crate::app::{App, CompletionState, alert_source_label, fmt_tokens};
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
/// Display density for the history view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -64,9 +65,11 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
let input_content_width = area.width.saturating_sub(2).max(1);
let input_render = app.input.render(input_content_width);
let input_height = input_area_height(&input_render, area.height);
let mini_view_h = task_mini_view_height(&app.task_store);
let chunks = Layout::vertical([
Constraint::Min(0), // history view
Constraint::Length(mini_view_h), // task mini-view (0 when empty)
Constraint::Length(1), // separator
Constraint::Length(1), // status
Constraint::Length(input_height), // input area
@ -74,11 +77,109 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
.split(area);
draw_history(frame, app, chunks[0]);
draw_separator(frame, chunks[1]);
draw_status(frame, app, chunks[2]);
draw_input(frame, &input_render, chunks[3]);
if mini_view_h > 0 {
draw_task_mini_view(frame, &app.task_store, chunks[1]);
}
draw_separator(frame, chunks[2]);
draw_status(frame, app, chunks[3]);
draw_input(frame, &input_render, chunks[4]);
if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
draw_completion_popup(frame, state, chunks[3]);
draw_completion_popup(frame, state, chunks[4]);
}
}
/// Maximum number of active (pending / inprogress) tasks the mini-view
/// shows above the summary line. Exceeding tasks are still counted in
/// the summary.
const MINI_VIEW_MAX_ACTIVE: usize = 3;
/// Height the mini-view section occupies. Returns 0 when there are no
/// tasks at all, so the section collapses cleanly into surrounding
/// layout — there's no point reserving rows for an empty store.
fn task_mini_view_height(store: &TaskStore) -> u16 {
if store.is_empty() {
return 0;
}
let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE);
// active rows + 1 summary line
(active_shown as u16).saturating_add(1)
}
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) {
if area.height == 0 || area.width == 0 {
return;
}
let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
let inner = outer_block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let mut lines: Vec<Line<'static>> = Vec::with_capacity(area.height as usize);
let mut shown = 0usize;
for entry in store.tasks() {
if shown >= MINI_VIEW_MAX_ACTIVE {
break;
}
if !matches!(entry.status, TaskStatus::Pending | TaskStatus::Inprogress) {
continue;
}
lines.push(mini_view_active_line(entry, inner.width));
shown += 1;
}
lines.push(mini_view_summary_line(store.counts(), inner.width));
Paragraph::new(lines)
.block(outer_block)
.render(area, frame.buffer_mut());
}
fn mini_view_active_line(entry: &TaskEntry, width: u16) -> Line<'static> {
let mark = task_status_mark(entry.status);
// Subject's first line only; embedded newlines would otherwise
// wreck the one-row-per-task layout.
let subject = entry.subject.lines().next().unwrap_or("");
let mark_width = UnicodeWidthStr::width(mark.0);
// Reserve mark + space.
let budget = (width as usize).saturating_sub(mark_width + 1);
let shown = truncate_with_ellipsis(subject, budget);
Line::from(vec![
Span::styled(mark.0.to_owned(), mark.1),
Span::raw(" "),
Span::raw(shown),
])
}
fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> {
let text = format!(
"{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}",
counts.total(),
counts.pending,
counts.inprogress,
counts.completed,
counts.deleted,
);
let shown = truncate_with_ellipsis(&text, width as usize);
Line::from(Span::styled(
shown,
Style::default().fg(Color::DarkGray),
))
}
/// Two-character status marker + the style to render it with. Mirrors
/// the four `TaskStatus` values; deleted ones never appear in the
/// mini-view but are listed in the side pane.
fn task_status_mark(status: TaskStatus) -> (&'static str, Style) {
match status {
TaskStatus::Pending => ("[ ]", Style::default().fg(Color::DarkGray)),
TaskStatus::Inprogress => (
"[~]",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
TaskStatus::Completed => ("[x]", Style::default().fg(Color::Green)),
TaskStatus::Deleted => ("[-]", Style::default().fg(Color::Red)),
}
}
@ -217,8 +318,23 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
app.scroll.turn_starts.clear();
return;
}
// When the task pane is open and the area is wide enough, carve a
// vertical strip on the right for it. Side pane lives inside the
// history rect only — separator / status / input stay full width to
// keep the input experience and completion popup geometry intact.
let pane_w = task_side_pane_width(area.width, app.task_pane_open);
let history_area = if pane_w > 0 {
let split =
Layout::horizontal([Constraint::Min(1), Constraint::Length(pane_w)]).split(area);
draw_task_side_pane(frame, app, split[1]);
split[0]
} else {
area
};
let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING));
let inner = outer_block.inner(area);
let inner = outer_block.inner(history_area);
if inner.width == 0 || inner.height == 0 {
return;
}
@ -248,6 +364,98 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
// uniformly for all rows.
Paragraph::new(visible)
.block(outer_block)
.render(history_area, frame.buffer_mut());
}
/// Width to reserve for the task side pane within the history rect.
/// Returns 0 when the pane is closed or the rect is too narrow to host
/// it without crushing the history view.
fn task_side_pane_width(area_width: u16, open: bool) -> u16 {
if !open {
return 0;
}
// Need a reasonable history column on the left, and enough room on
// the right for taskid + status mark + a few words of subject. Skip
// entirely on narrow terminals.
if area_width < 60 {
return 0;
}
(area_width / 3).clamp(28, 44)
}
fn draw_task_side_pane(frame: &mut Frame, app: &mut App, area: Rect) {
if area.width < 4 || area.height < 1 {
return;
}
let pane_block = UiBlock::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(Color::DarkGray))
.padding(Padding::horizontal(1));
let inner = pane_block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let store = &app.task_store;
let counts = store.counts();
let title_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let body_style = Style::default().fg(Color::DarkGray);
let muted_style = Style::default().fg(Color::DarkGray);
let mut logical: Vec<Line<'static>> = Vec::new();
logical.push(Line::from(Span::styled(
format!("Tasks ({})", counts.total()),
title_style,
)));
logical.push(Line::from(""));
if store.is_empty() {
logical.push(Line::from(Span::styled("(no tasks)", muted_style)));
} else {
for entry in store.tasks() {
let mark = task_status_mark(entry.status);
let subject_first = entry.subject.lines().next().unwrap_or("");
logical.push(Line::from(vec![
Span::styled(format!("#{} ", entry.taskid), muted_style),
Span::styled(mark.0.to_owned(), mark.1),
Span::raw(" "),
Span::raw(subject_first.to_owned()),
]));
// Subject continuations (multiline subjects).
for cont in entry.subject.lines().skip(1) {
logical.push(Line::from(vec![
Span::raw(" "),
Span::raw(cont.to_owned()),
]));
}
for raw in entry.description.lines() {
logical.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(raw.to_owned(), body_style),
]));
}
logical.push(Line::from(""));
}
}
// Pre-wrap to inner width so scroll math degenerates to row indices.
let mut wrapped: Vec<Line<'static>> = Vec::with_capacity(logical.len());
for line in logical {
wrap_line_into(line, inner.width, &mut wrapped);
}
let max_scroll = wrapped.len().saturating_sub(inner.height as usize);
if app.task_pane_scroll > max_scroll {
app.task_pane_scroll = max_scroll;
}
let start = app.task_pane_scroll;
let end = (start + inner.height as usize).min(wrapped.len());
let visible: Vec<Line<'static>> = wrapped[start..end].to_vec();
Paragraph::new(visible)
.block(pane_block)
.render(area, frame.buffer_mut());
}

View File

@ -20,31 +20,33 @@
- resume / 再接続時はサーバから来る history replay 経路に乗っかれば自動で復元される
- 単一情報源historyに揃うため、Pod と TUI で snapshot がズレない
- TUI 上の表示は 2 ヶ所
- **入力欄直上のミニビュー3〜4 行)**: 常時表示。active`pending` + `inprogress`)優先で最大 2〜3 件を `状態マーク + subject` 1 行ずつ、加えて件数サマリ 1 行
- **history 直下のミニビュー**: history と separator の間に挟む段。タスクが 1 件以上あるときだけ描画し、0 件なら領域ごと畳む。active`pending` + `inprogress`)優先で最大 2〜3 件を `状態マーク + subject` 1 行ずつ、加えて件数サマリ 1 行
- **サイドペイン(全件)**: トグルキーで右側に開閉。`pending` / `inprogress` / `completed` / `deleted` を区別して全件列挙、`description` も含める
- `tools` クレートに依存させる(または replay 部分のみを薄いヘルパとして切り出して共有する)。LLM コンテキスト加工原則との整合は自明history の純粋な再現変換であり context への割り込みではない)
- **`tools` クレートには依存させない**。TUI は表示専門レイヤなので、依存すると `llm-worker` や全ファイルツール一式まで引き込んで割に合わない。TUI 内に薄い mirror`TaskEntry` / `TaskStatus` / 小さい `TaskStore`)を持ち、`TaskCreate` / `TaskUpdate` の引数 JSON と `[Session TaskStore snapshot]` system message を直接 parse する。snapshot のフォーマットは `tools``render_snapshot` が source of truth で、resume 時に `tools` 自身が再 parse する契約になっているので、TUI 側もこの契約に乗る形になる。LLM コンテキスト加工原則との整合は自明history の純粋な再現変換であり context への割り込みではない)
## 要件
### TUI 側の TaskStore 取り回し
- `App` に最新 `TaskStore`(または `TaskSnapshot`)を保持するフィールドを追加する
- `App` に最新 `TaskStore`(または `TaskSnapshot`)を保持するフィールドを追加する。型は TUI 内 mirror`tools` 依存なし)
- 受信パスで以下を観測して TaskStore に反映する
- `ToolCall` 完了時、`name == "TaskCreate"` / `"TaskUpdate"` なら `arguments``TaskStore::from_history` と同じパースで適用
- `SystemMessage` 観測時、本文が `[Session TaskStore snapshot]` で始まるなら `parse_compact_snapshot_text` 相当で snapshot に置き換え
- `ToolCall` 完了時、`name == "TaskCreate"` / `"TaskUpdate"` なら `arguments``tools` 側と同じスキーマで parse し、create / update を適用
- `SystemMessage` 観測時、本文が `[Session TaskStore snapshot]` で始まるなら埋め込み JSON ブロックを抽出して snapshot に置き換え
- 初回 history replay の際も同じ経路を通せば、TUI 起動時点の状態が自動的に復元される
- 部分的引数streaming 中は適用しない。tool_call が完了して `arguments` が確定したタイミングだけ反映する
- 不正な JSON や未知のフィールドは黙って無視する(`tools` 側 `replay_history` と同じく落ちない)
### TUI ミニビュー
- 入力欄の直上に固定で 3〜4 行の領域を確保
- 配置は `history / mini-view / separator / status / input` の縦積みhistory 直下、separator より上)
- タスクが 1 件以上あるときだけ描画する。0 件なら領域ごと畳んで既存レイアウトと同じ高さに戻る
- active`pending` + `inprogress`)を優先して最大 2〜3 件、`状態マーク + subject` を 1 行ずつ表示。`subject` が改行を含む場合は先頭行のみ
- 件数サマリ 1 行(`pending` / `inprogress` / `completed` / `deleted` の内訳)
- active 0 件のときの見せ方は実装時に決める(サマリ行のみ残すか、領域ごと畳むか)
### TUI サイドペイン
- トグルキーで右側に開閉する横分割レイアウト(割当キーは実装時に決める)
- トグルキー: `Ctrl-T`
- 横分割レイアウトは history 領域内で行う。status / input / separator は引き続き全幅で残し、completion popup や入力体験への影響を最小化する
- 開いている間は全件を表示。各エントリは `taskid` / `status` / `subject` / `description` を含める
- スクロール対応(タスク数が画面高を超えてもよい)
@ -72,6 +74,7 @@
## 参照
- 実装: `crates/tools/src/task.rs``TaskStore::from_history` / `parse_compact_snapshot_text` / `TaskStatus` / `TaskEntry`)、`crates/tui/src/ui.rs`、`crates/tui/src/block.rs``ToolCallBlock` / `SystemMessage`
- 参考実装: `crates/tools/src/task.rs``TaskStore::from_history` / `parse_compact_snapshot_text` / `TaskStatus` / `TaskEntry` / `render_snapshot`。TUI 側はこれらを依存として取り込まず、同等のロジックを薄く再実装する
- TUI 側受信パス: `crates/tui/src/app.rs``Event::ToolCallDone` / `Event::SystemMessage` / `Event::History`)、`crates/tui/src/ui.rs`、`crates/tui/src/block.rs``ToolCallBlock` / `SystemMessage`
- 設計指針: `AGENTS.md`「LLM コンテキストの加工原則」history からの決定的再構成は許容変換)
- 関連チケット: `tickets/session-todo.md`Task ツール本体)、`tickets/session-todo-reminder.md`LLM 側ナッジ)