feat: TUI上に進行中のTaskを表示する実装
This commit is contained in:
parent
6178812979
commit
9072ac4e03
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3615,6 +3615,7 @@ dependencies = [
|
|||
"pod-registry",
|
||||
"protocol",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ toml = { workspace = true }
|
|||
manifest = { workspace = true }
|
||||
session-store = { workspace = true }
|
||||
pod-registry = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
317
crates/tui/src/task.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 側ナッジ)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user