Skip to content

Commit f6a37fd

Browse files
committed
feat: render todowrite tool items with plan panel syncfeat: render todowrite tool items with plan panel sync
1 parent ad49f08 commit f6a37fd

File tree

8 files changed

+382
-0
lines changed

8 files changed

+382
-0
lines changed

src-tauri/src/backend/event_translator.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ pub(crate) fn translate_sse_event(
350350
"question.asked" => translate_question_asked(properties, state),
351351
"question.replied" => translate_question_completed(properties),
352352
"question.rejected" => translate_question_completed(properties),
353+
"todo.updated" => translate_todo_updated(properties, state),
353354
"server.heartbeat"
354355
| "file.watcher.updated"
355356
| "session.deleted"
@@ -852,6 +853,42 @@ fn translate_tool_part(
852853
return events;
853854
}
854855

856+
// Handle todowrite tool specially — emit a todo item
857+
if tool_name == "todowrite" {
858+
let todo_status = match status {
859+
"pending" | "running" => "pending",
860+
_ => "completed",
861+
};
862+
let todos = build_todo_list(raw_input.as_ref(), tool_state);
863+
let item = json!({
864+
"id": item_id,
865+
"type": "todowrite",
866+
"status": todo_status,
867+
"todos": todos
868+
});
869+
let method = match status {
870+
"pending" | "running" => "item/started",
871+
_ => "item/completed",
872+
};
873+
events.push(json!({
874+
"method": method,
875+
"params": {
876+
"threadId": thread_id,
877+
"item": item
878+
}
879+
}));
880+
// Also emit a plan update so the PlanPanel sidebar shows the todo list
881+
let turn_id = state
882+
.get_turn_state(thread_id)
883+
.map(|ts| ts.turn_id.clone())
884+
.unwrap_or_default();
885+
events.push(build_plan_from_todos(thread_id, &turn_id, &todos));
886+
if status == "completed" || status == "error" {
887+
state.reset_agent_message_item(thread_id);
888+
}
889+
return events;
890+
}
891+
855892
// Handle explore-type tools (read, grep, glob, list) specially
856893
if item_type == "explore" {
857894
let explore_status = match status {
@@ -1494,6 +1531,34 @@ fn translate_question_completed(properties: &Value) -> Vec<Value> {
14941531
})]
14951532
}
14961533

1534+
/// Translate an OpenCode `todo.updated` SSE event into a `turn/plan/updated`
1535+
/// event so the PlanPanel sidebar reflects the current session todo list.
1536+
fn translate_todo_updated(
1537+
properties: &Value,
1538+
state: &mut SessionTranslationState,
1539+
) -> Vec<Value> {
1540+
let session_id = properties
1541+
.get("sessionID")
1542+
.or_else(|| properties.get("sessionId"))
1543+
.and_then(|v| v.as_str())
1544+
.unwrap_or(&state.session_id)
1545+
.to_string();
1546+
let thread_id = if session_id.is_empty() {
1547+
state.session_id.clone()
1548+
} else {
1549+
session_id
1550+
};
1551+
let turn_id = state
1552+
.get_turn_state(&thread_id)
1553+
.map(|ts| ts.turn_id.clone())
1554+
.unwrap_or_default();
1555+
let todos = properties
1556+
.get("todos")
1557+
.cloned()
1558+
.unwrap_or_else(|| json!([]));
1559+
vec![build_plan_from_todos(&thread_id, &turn_id, &todos)]
1560+
}
1561+
14971562
// ---------------------------------------------------------------------------
14981563
// Synthetic turn events
14991564
// ---------------------------------------------------------------------------
@@ -1552,10 +1617,102 @@ fn tool_kind_to_item_type(kind: &str) -> &str {
15521617
"edit" | "write" | "create" => "fileChange",
15531618
"bash" | "command" | "terminal" => "commandExecution",
15541619
"read" | "grep" | "glob" | "list" | "ls" => "explore",
1620+
"todowrite" => "todowrite",
15551621
_ => "commandExecution",
15561622
}
15571623
}
15581624

1625+
/// Build a JSON array of todo items from the todowrite tool's input or metadata.
1626+
/// Falls back to extracting todos from the input field if metadata is not present.
1627+
fn build_todo_list(raw_input: Option<&Value>, tool_state: &Value) -> Value {
1628+
// Prefer metadata.todos (populated on completion) over input.todos (the request payload)
1629+
if let Some(todos) = tool_state
1630+
.get("metadata")
1631+
.and_then(|m| m.get("todos"))
1632+
.and_then(|t| t.as_array())
1633+
{
1634+
let items: Vec<Value> = todos
1635+
.iter()
1636+
.filter_map(|todo| {
1637+
let content = todo.get("content").and_then(|v| v.as_str())?;
1638+
let status = todo
1639+
.get("status")
1640+
.and_then(|v| v.as_str())
1641+
.unwrap_or("pending");
1642+
let priority = todo
1643+
.get("priority")
1644+
.and_then(|v| v.as_str())
1645+
.unwrap_or("medium");
1646+
Some(json!({ "content": content, "status": status, "priority": priority }))
1647+
})
1648+
.collect();
1649+
return json!(items);
1650+
}
1651+
// Fall back to input.todos
1652+
if let Some(input) = raw_input {
1653+
if let Some(todos) = input.get("todos").and_then(|t| t.as_array()) {
1654+
let items: Vec<Value> = todos
1655+
.iter()
1656+
.filter_map(|todo| {
1657+
let content = todo.get("content").and_then(|v| v.as_str())?;
1658+
let status = todo
1659+
.get("status")
1660+
.and_then(|v| v.as_str())
1661+
.unwrap_or("pending");
1662+
let priority = todo
1663+
.get("priority")
1664+
.and_then(|v| v.as_str())
1665+
.unwrap_or("medium");
1666+
Some(json!({ "content": content, "status": status, "priority": priority }))
1667+
})
1668+
.collect();
1669+
return json!(items);
1670+
}
1671+
}
1672+
json!([])
1673+
}
1674+
1675+
/// Map an OpenCode todo status string to a CodexMonitor plan step status.
1676+
fn todo_status_to_plan_status(status: &str) -> &'static str {
1677+
match status {
1678+
"completed" => "completed",
1679+
"in_progress" => "inProgress",
1680+
"cancelled" => "completed",
1681+
_ => "pending",
1682+
}
1683+
}
1684+
1685+
/// Build a `turn/plan/updated` event from a list of todo JSON values.
1686+
/// Returns `None` when the todo list is empty (the caller should decide
1687+
/// whether to emit a clear event in that case).
1688+
fn build_plan_from_todos(thread_id: &str, turn_id: &str, todos: &Value) -> Value {
1689+
let steps: Vec<Value> = todos
1690+
.as_array()
1691+
.unwrap_or(&vec![])
1692+
.iter()
1693+
.filter_map(|todo| {
1694+
let content = todo.get("content").and_then(|v| v.as_str())?;
1695+
let status = todo
1696+
.get("status")
1697+
.and_then(|v| v.as_str())
1698+
.unwrap_or("pending");
1699+
Some(json!({
1700+
"step": content,
1701+
"status": todo_status_to_plan_status(status)
1702+
}))
1703+
})
1704+
.collect();
1705+
json!({
1706+
"method": "turn/plan/updated",
1707+
"params": {
1708+
"threadId": thread_id,
1709+
"turnId": turn_id,
1710+
"explanation": null,
1711+
"plan": steps
1712+
}
1713+
})
1714+
}
1715+
15591716
/// Map OpenCode tool names to explore entry kinds.
15601717
fn tool_to_explore_kind(tool_name: &str) -> &str {
15611718
match tool_name {

src-tauri/src/shared/codex_core.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ fn replay_tool_kind_to_item_type(tool_name: &str) -> &str {
577577
"edit" | "write" | "create" => "fileChange",
578578
"bash" | "command" | "terminal" => "commandExecution",
579579
"task" => "collabToolCall",
580+
"todowrite" => "todowrite",
580581
_ => "commandExecution",
581582
}
582583
}
@@ -655,6 +656,27 @@ fn replay_build_tool_item(
655656
if !output.trim().is_empty() {
656657
item["output"] = json!(output);
657658
}
659+
} else if item_type == "todowrite" {
660+
if let Some(inp) = raw_input {
661+
if let Some(todos) = inp.get("todos").and_then(|t| t.as_array()) {
662+
let todo_items: Vec<Value> = todos
663+
.iter()
664+
.filter_map(|todo| {
665+
let content = todo.get("content").and_then(|v| v.as_str())?;
666+
let todo_status = todo
667+
.get("status")
668+
.and_then(|v| v.as_str())
669+
.unwrap_or("pending");
670+
let priority = todo
671+
.get("priority")
672+
.and_then(|v| v.as_str())
673+
.unwrap_or("medium");
674+
Some(json!({ "content": content, "status": todo_status, "priority": priority }))
675+
})
676+
.collect();
677+
item["todos"] = json!(todo_items);
678+
}
679+
}
658680
}
659681

660682
item

src/features/messages/components/MessageRows.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Terminal from "lucide-react/dist/esm/icons/terminal";
1313
import Users from "lucide-react/dist/esm/icons/users";
1414
import Wrench from "lucide-react/dist/esm/icons/wrench";
1515
import X from "lucide-react/dist/esm/icons/x";
16+
import ListChecks from "lucide-react/dist/esm/icons/list-checks";
1617
import type { ConversationItem } from "../../../types";
1718
import { languageFromPath } from "../../../utils/syntax";
1819
import { DiffBlock } from "../../git/components/DiffBlock";
@@ -703,3 +704,62 @@ export const ExploreRow = memo(function ExploreRow({ item }: ExploreRowProps) {
703704
</div>
704705
);
705706
});
707+
708+
type TodoRowProps = {
709+
item: Extract<ConversationItem, { kind: "todo" }>;
710+
};
711+
712+
function todoStatusClass(status: string) {
713+
if (status === "completed") return "completed";
714+
if (status === "in_progress") return "processing";
715+
if (status === "cancelled") return "failed";
716+
return "";
717+
}
718+
719+
function todoPriorityLabel(priority: string) {
720+
if (priority === "high") return "high";
721+
if (priority === "low") return "low";
722+
return null;
723+
}
724+
725+
export const TodoRow = memo(function TodoRow({ item }: TodoRowProps) {
726+
const tone = item.status === "completed" ? "completed" : "processing";
727+
return (
728+
<div className="tool-inline todo-inline">
729+
<div className="tool-inline-bar-toggle" aria-hidden />
730+
<div className="tool-inline-content">
731+
<div className="todo-inline-header">
732+
<ListChecks
733+
className={`tool-inline-icon ${tone}`}
734+
size={14}
735+
aria-hidden
736+
/>
737+
<span className="todo-inline-title">Tasks</span>
738+
</div>
739+
<div className="todo-inline-list">
740+
{item.todos.map((todo, index) => {
741+
const statusClass = todoStatusClass(todo.status);
742+
const priorityLabel = todoPriorityLabel(todo.priority);
743+
return (
744+
<div
745+
key={`${todo.content}-${index}`}
746+
className={`todo-inline-item ${statusClass ? `todo-inline-item--${statusClass}` : ""}`}
747+
>
748+
<span
749+
className={`todo-inline-dot ${statusClass}`}
750+
aria-hidden
751+
/>
752+
<span className="todo-inline-content">{todo.content}</span>
753+
{priorityLabel && (
754+
<span className={`todo-inline-priority todo-inline-priority--${todo.priority}`}>
755+
{priorityLabel}
756+
</span>
757+
)}
758+
</div>
759+
);
760+
})}
761+
</div>
762+
</div>
763+
</div>
764+
);
765+
});

src/features/messages/components/Messages.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
MessageRow,
3434
ReasoningRow,
3535
ReviewRow,
36+
TodoRow,
3637
ToolRow,
3738
WorkingIndicator,
3839
} from "./MessageRows";
@@ -477,6 +478,9 @@ export const Messages = memo(function Messages({
477478
if (item.kind === "explore") {
478479
return <ExploreRow key={item.id} item={item} />;
479480
}
481+
if (item.kind === "todo") {
482+
return <TodoRow key={item.id} item={item} />;
483+
}
480484
return null;
481485
};
482486

src/features/messages/utils/messageRenderUtils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ export function scrollKeyForItems(items: ConversationItem[]) {
394394
return `${last.id}-${last.status ?? ""}-${last.diff.length}`;
395395
case "review":
396396
return `${last.id}-${last.state}-${last.text.length}`;
397+
case "todo":
398+
return `${last.id}-${last.status}-${last.todos.length}`;
397399
default: {
398400
const _exhaustive: never = last;
399401
return _exhaustive;

0 commit comments

Comments
 (0)