@@ -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.
15601717fn tool_to_explore_kind ( tool_name : & str ) -> & str {
15611718 match tool_name {
0 commit comments