feat: Task表示のレビュー・修正

This commit is contained in:
2026-05-04 17:28:39 +09:00
parent cb7da11de7
commit f33eba3fe6
8 changed files with 278 additions and 10 deletions
+3
View File
@@ -17,3 +17,6 @@ manifest = { workspace = true }
session-store = { workspace = true }
pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
[dev-dependencies]
tools = { workspace = true }
+113
View File
@@ -315,3 +315,116 @@ mod tests {
);
}
}
/// Cross-crate contract tests. The TUI deliberately re-implements a
/// stripped-down mirror of `tools::TaskStore` instead of depending on
/// the real one (see `tickets/tui-task-display.md`). That decoupling
/// means a format change on the tools side — a renamed field on
/// `TaskEntry`, a different fence syntax in `render_snapshot`, a new
/// JSON wrapper — would silently leave the TUI parsing nothing instead
/// of failing loudly.
///
/// These tests pull `tools` in as a dev-dependency so the contract is
/// exercised at CI time. If they fail, either the format genuinely
/// changed (update both sides) or the TUI mirror has drifted (re-sync
/// it).
#[cfg(test)]
mod cross_format_contract {
use super::*;
use tools::task::{TaskStatus as ToolsTaskStatus, TaskStore as ToolsTaskStore};
/// Mirrors the envelope `Pod::try_pre_run_compact` wraps the raw
/// snapshot text in. Hand-rolled here so the test fails loudly if
/// the prose around the JSON fence ever shifts.
fn wrap_pod_style(snapshot_text: &str) -> String {
format!(
"[Session TaskStore snapshot]\n\n{snapshot_text}\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."
)
}
fn tools_status_label(s: ToolsTaskStatus) -> &'static str {
match s {
ToolsTaskStatus::Pending => "pending",
ToolsTaskStatus::Inprogress => "inprogress",
ToolsTaskStatus::Completed => "completed",
ToolsTaskStatus::Deleted => "deleted",
}
}
fn tui_status_label(s: TaskStatus) -> &'static str {
match s {
TaskStatus::Pending => "pending",
TaskStatus::Inprogress => "inprogress",
TaskStatus::Completed => "completed",
TaskStatus::Deleted => "deleted",
}
}
#[test]
fn tools_snapshot_text_round_trips_into_tui_store() {
let upstream = ToolsTaskStore::new();
upstream.create("first".into(), "first desc".into());
upstream.create("second".into(), "second desc with\nnewline".into());
upstream
.update(1, Some(ToolsTaskStatus::Inprogress), None, None)
.expect("update 1");
upstream
.update(2, Some(ToolsTaskStatus::Completed), None, None)
.expect("update 2");
let envelope = wrap_pod_style(&upstream.snapshot_text());
let mut downstream = TaskStore::new();
downstream.apply_system_message_text(&envelope);
let upstream_tasks = upstream.list();
let downstream_tasks = downstream.tasks();
assert_eq!(
downstream_tasks.len(),
upstream_tasks.len(),
"TUI parsed wrong number of tasks — `tools::render_snapshot` shape may have shifted"
);
for (u, d) in upstream_tasks.iter().zip(downstream_tasks.iter()) {
assert_eq!(d.taskid, u.taskid);
assert_eq!(d.subject, u.subject);
assert_eq!(d.description, u.description);
assert_eq!(tui_status_label(d.status), tools_status_label(u.status));
}
}
#[test]
fn tools_taskentry_field_shape_deserializes_into_tui_taskentry() {
// A single `tools::TaskEntry` round-tripped through JSON. Field
// renames like `taskid` → `task_id` or status case changes on
// the tools side would surface here as a serde failure or a
// wrong-status assertion.
let upstream = ToolsTaskStore::new();
let created = upstream.create("subj".into(), "desc".into());
let json = serde_json::to_string(&created).expect("serialize tools::TaskEntry");
let parsed: TaskEntry =
serde_json::from_str(&json).expect("deserialize into tui::task::TaskEntry");
assert_eq!(parsed.taskid, created.taskid);
assert_eq!(parsed.subject, created.subject);
assert_eq!(parsed.description, created.description);
assert_eq!(tui_status_label(parsed.status), "pending");
}
#[test]
fn empty_tools_store_snapshot_is_recognised_by_tui() {
// Edge case: a freshly initialised TaskStore still produces a
// valid snapshot envelope. The TUI must parse it as "zero
// tasks", not silently fall through to no-op.
let upstream = ToolsTaskStore::new();
let envelope = wrap_pod_style(&upstream.snapshot_text());
// Seed the TUI store with stale state to confirm replacement.
let mut downstream = TaskStore::new();
downstream.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#);
assert_eq!(downstream.tasks().len(), 1);
downstream.apply_system_message_text(&envelope);
assert!(downstream.is_empty());
}
}
+15 -10
View File
@@ -66,25 +66,30 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
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);
// One blank row separates the history tail from the mini-view so
// the latest message doesn't visually crash into the task summary.
// Folds away with the mini-view when there are no tasks.
let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 };
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
Constraint::Min(0), // history view
Constraint::Length(mini_view_gap), // gap above mini-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
])
.split(area);
draw_history(frame, app, chunks[0]);
if mini_view_h > 0 {
draw_task_mini_view(frame, &app.task_store, chunks[1]);
draw_task_mini_view(frame, &app.task_store, chunks[2]);
}
draw_separator(frame, chunks[2]);
draw_status(frame, app, chunks[3]);
draw_input(frame, &input_render, chunks[4]);
draw_separator(frame, chunks[3]);
draw_status(frame, app, chunks[4]);
draw_input(frame, &input_render, chunks[5]);
if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
draw_completion_popup(frame, state, chunks[4]);
draw_completion_popup(frame, state, chunks[5]);
}
}