loop: add todo checklist
This should improve Sketch's executive function and user communication.
diff --git a/claudetool/shared.go b/claudetool/shared.go
index d95a5e4..310044c 100644
--- a/claudetool/shared.go
+++ b/claudetool/shared.go
@@ -23,3 +23,16 @@
wd, _ := ctx.Value(workingDirCtxKey).(string)
return wd
}
+
+type sessionIDCtxKeyType string
+
+const sessionIDCtxKey sessionIDCtxKeyType = "sessionID"
+
+func WithSessionID(ctx context.Context, sessionID string) context.Context {
+ return context.WithValue(ctx, sessionIDCtxKey, sessionID)
+}
+
+func SessionID(ctx context.Context) string {
+ sessionID, _ := ctx.Value(sessionIDCtxKey).(string)
+ return sessionID
+}
diff --git a/claudetool/todo.go b/claudetool/todo.go
new file mode 100644
index 0000000..64c7550
--- /dev/null
+++ b/claudetool/todo.go
@@ -0,0 +1,176 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "sketch.dev/llm"
+)
+
+var TodoRead = &llm.Tool{
+ Name: "todo_read",
+ Description: `Reads the current todo list. Use frequently to track progress and understand what's pending.`,
+ InputSchema: llm.EmptySchema(),
+ Run: todoReadRun,
+}
+
+var TodoWrite = &llm.Tool{
+ Name: "todo_write",
+ Description: todoWriteDescription,
+ InputSchema: llm.MustSchema(todoWriteInputSchema),
+ Run: todoWriteRun,
+}
+
+const (
+ todoWriteDescription = `todo_write: Creates and manages a structured task list for tracking work and communicating progress to users. Use early and often.
+
+Use for:
+- multi-step tasks
+- complex work
+- when users provide multiple requests
+- conversations that start trivial but grow in scope
+- when users request additional work (directly or via feedback)
+
+Skip for:
+- trivial single-step tasks
+- purely conversational exchanges
+
+Update dynamically as work evolves - conversations can spawn tasks, simple tasks can become complex, and new discoveries may require additional work.
+
+Rules:
+- Update immediately when task states or task list changes
+- Only one task "in-progress" at any time
+- Each update completely replaces the task list - include all tasks (past and present)
+- Never modify or delete completed tasks
+- Queued and in-progress tasks may be restructured as understanding evolves
+- Tasks should be atomic, clear, precise, and actionable
+- If the user adds new tasks: append, don't replace
+`
+
+ todoWriteInputSchema = `
+{
+ "type": "object",
+ "required": ["tasks"],
+ "properties": {
+ "tasks": {
+ "type": "array",
+ "description": "Array of tasks to write",
+ "items": {
+ "type": "object",
+ "required": ["id", "task", "status"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "stable, unique hyphenated slug"
+ },
+ "task": {
+ "type": "string",
+ "description": "actionable step in active tense, sentence case, plain text only, displayed to user"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["queued", "in-progress", "completed"],
+ "description": "current task status"
+ }
+ }
+ }
+ }
+ }
+}
+`
+)
+
+type TodoItem struct {
+ ID string `json:"id"`
+ Task string `json:"task"`
+ Status string `json:"status"`
+}
+
+type TodoList struct {
+ Items []TodoItem `json:"items"`
+}
+
+type TodoWriteInput struct {
+ Tasks []TodoItem `json:"tasks"`
+}
+
+// TodoFilePath returns the path to the todo file for the given session ID.
+func TodoFilePath(sessionID string) string {
+ if sessionID == "" {
+ return "/tmp/sketch_todos.json"
+ }
+ return filepath.Join("/tmp", sessionID, "todos.json")
+}
+
+func todoFilePathForContext(ctx context.Context) string {
+ return TodoFilePath(SessionID(ctx))
+}
+
+func todoReadRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+ todoPath := todoFilePathForContext(ctx)
+ content, err := os.ReadFile(todoPath)
+ if os.IsNotExist(err) {
+ return llm.TextContent("No todo list found. Use todo_write to create one."), nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to read todo file: %w", err)
+ }
+
+ var todoList TodoList
+ if err := json.Unmarshal(content, &todoList); err != nil {
+ return nil, fmt.Errorf("failed to parse todo file: %w", err)
+ }
+
+ result := fmt.Sprintf(`<todo_list count="%d">%s`, len(todoList.Items), "\n")
+ for _, item := range todoList.Items {
+ result += fmt.Sprintf(` <task id="%s" status="%s">%s</task>%s`, item.ID, item.Status, item.Task, "\n")
+ }
+ result += "</todo_list>"
+
+ return llm.TextContent(result), nil
+}
+
+func todoWriteRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+ var input TodoWriteInput
+ if err := json.Unmarshal(m, &input); err != nil {
+ return nil, fmt.Errorf("invalid input: %w", err)
+ }
+
+ // Validate that only one task is in-progress
+ inProgressCount := 0
+ for _, task := range input.Tasks {
+ if task.Status == "in-progress" {
+ inProgressCount++
+ }
+ }
+ switch {
+ case inProgressCount > 1:
+ return nil, fmt.Errorf("only one task can be 'in-progress' at a time, found %d", inProgressCount)
+ }
+
+ todoList := TodoList{
+ Items: input.Tasks,
+ }
+
+ todoPath := todoFilePathForContext(ctx)
+ // Ensure directory exists
+ if err := os.MkdirAll(filepath.Dir(todoPath), 0o700); err != nil {
+ return nil, fmt.Errorf("failed to create todo directory: %w", err)
+ }
+
+ content, err := json.Marshal(todoList)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal todo list: %w", err)
+ }
+
+ if err := os.WriteFile(todoPath, content, 0o600); err != nil {
+ return nil, fmt.Errorf("failed to write todo file: %w", err)
+ }
+
+ result := fmt.Sprintf("Updated todo list with %d items.", len(input.Tasks))
+
+ return llm.TextContent(result), nil
+}
diff --git a/claudetool/todo_test.go b/claudetool/todo_test.go
new file mode 100644
index 0000000..ac36cc2
--- /dev/null
+++ b/claudetool/todo_test.go
@@ -0,0 +1,155 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestTodoReadEmpty(t *testing.T) {
+ ctx := WithSessionID(context.Background(), "test-session-1")
+
+ // Ensure todo file doesn't exist
+ todoPath := todoFilePathForContext(ctx)
+ os.Remove(todoPath)
+
+ result, err := todoReadRun(ctx, []byte("{}"))
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("expected 1 content item, got %d", len(result))
+ }
+
+ expected := "No todo list found. Use todo_write to create one."
+ if result[0].Text != expected {
+ t.Errorf("expected %q, got %q", expected, result[0].Text)
+ }
+}
+
+func TestTodoWriteAndRead(t *testing.T) {
+ ctx := WithSessionID(context.Background(), "test-session-2")
+
+ // Clean up
+ todoPath := todoFilePathForContext(ctx)
+ defer os.Remove(todoPath)
+ os.Remove(todoPath)
+
+ // Write some todos
+ todos := []TodoItem{
+ {ID: "1", Task: "Implement todo tools", Status: "completed"},
+ {ID: "2", Task: "Update system prompt", Status: "in-progress"},
+ {ID: "3", Task: "Write tests", Status: "queued"},
+ }
+
+ writeInput := TodoWriteInput{Tasks: todos}
+ writeInputJSON, _ := json.Marshal(writeInput)
+
+ result, err := todoWriteRun(ctx, writeInputJSON)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("expected 1 content item, got %d", len(result))
+ }
+
+ expected := "Updated todo list with 3 items."
+ if result[0].Text != expected {
+ t.Errorf("expected %q, got %q", expected, result[0].Text)
+ }
+
+ // Read the todos back
+ result, err = todoReadRun(ctx, []byte("{}"))
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("expected 1 content item, got %d", len(result))
+ }
+
+ resultText := result[0].Text
+ if !strings.Contains(resultText, "<todo_list count=\"3\">") {
+ t.Errorf("expected result to contain XML todo list header, got %q", resultText)
+ }
+
+ // Check that all todos are present with proper XML structure
+ if !strings.Contains(resultText, `<task id="1" status="completed">Implement todo tools</task>`) {
+ t.Errorf("expected result to contain first todo in XML format, got %q", resultText)
+ }
+ if !strings.Contains(resultText, `<task id="2" status="in-progress">Update system prompt</task>`) {
+ t.Errorf("expected result to contain second todo in XML format, got %q", resultText)
+ }
+ if !strings.Contains(resultText, `<task id="3" status="queued">Write tests</task>`) {
+ t.Errorf("expected result to contain third todo in XML format, got %q", resultText)
+ }
+
+ // Check XML structure
+ if !strings.Contains(resultText, "</todo_list>") {
+ t.Errorf("expected result to contain closing XML tag, got %q", resultText)
+ }
+}
+
+func TestTodoWriteMultipleInProgress(t *testing.T) {
+ ctx := WithSessionID(context.Background(), "test-session-3")
+
+ // Try to write todos with multiple in-progress items
+ todos := []TodoItem{
+ {ID: "1", Task: "Task 1", Status: "in-progress"},
+ {ID: "2", Task: "Task 2", Status: "in-progress"},
+ }
+
+ writeInput := TodoWriteInput{Tasks: todos}
+ writeInputJSON, _ := json.Marshal(writeInput)
+
+ _, err := todoWriteRun(ctx, writeInputJSON)
+ if err == nil {
+ t.Fatal("expected error for multiple in_progress tasks, got none")
+ }
+
+ expected := "only one task can be 'in-progress' at a time, found 2"
+ if err.Error() != expected {
+ t.Errorf("expected error %q, got %q", expected, err.Error())
+ }
+}
+
+func TestTodoSessionIsolation(t *testing.T) {
+ // Test that different sessions have different todo files
+ ctx1 := WithSessionID(context.Background(), "session-1")
+ ctx2 := WithSessionID(context.Background(), "session-2")
+
+ path1 := todoFilePathForContext(ctx1)
+ path2 := todoFilePathForContext(ctx2)
+
+ if path1 == path2 {
+ t.Errorf("expected different paths for different sessions, both got %q", path1)
+ }
+
+ expected1 := filepath.Join("/tmp", "session-1", "todos.json")
+ expected2 := filepath.Join("/tmp", "session-2", "todos.json")
+
+ if path1 != expected1 {
+ t.Errorf("expected path1 %q, got %q", expected1, path1)
+ }
+
+ if path2 != expected2 {
+ t.Errorf("expected path2 %q, got %q", expected2, path2)
+ }
+}
+
+func TestTodoFallbackPath(t *testing.T) {
+ // Test fallback when no session ID in context
+ ctx := context.Background() // No session ID
+
+ path := todoFilePathForContext(ctx)
+ expected := "/tmp/sketch_todos.json"
+
+ if path != expected {
+ t.Errorf("expected fallback path %q, got %q", expected, path)
+ }
+}
diff --git a/cmd/go2ts/go2ts.go b/cmd/go2ts/go2ts.go
index cc97c6b..a888be1 100644
--- a/cmd/go2ts/go2ts.go
+++ b/cmd/go2ts/go2ts.go
@@ -57,6 +57,8 @@
loop.ToolCall{},
llm.Usage{},
server.State{},
+ server.TodoItem{},
+ server.TodoList{},
loop.MultipleChoiceOption{},
loop.MultipleChoiceParams{},
git_tools.DiffFile{},
diff --git a/loop/agent.go b/loop/agent.go
index f9450ab..e77f588 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -126,6 +126,8 @@
FirstMessageIndex() int
CurrentStateName() string
+ // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
+ CurrentTodoContent() string
}
type CodingAgentMessageType string
@@ -180,6 +182,9 @@
// This is useful for subconversations that generate output that shouldn't be shown to the user.
HideOutput bool `json:"hide_output,omitempty"`
+ // TodoContent contains the agent's todo file content when it has changed
+ TodoContent *string `json:"todo_content,omitempty"`
+
Idx int `json:"idx"`
}
@@ -457,6 +462,17 @@
return a.stateMachine.CurrentState().String()
}
+// CurrentTodoContent returns the current todo list data as JSON.
+// It returns an empty string if no todos exist.
+func (a *Agent) CurrentTodoContent() string {
+ todoPath := claudetool.TodoFilePath(a.config.SessionID)
+ content, err := os.ReadFile(todoPath)
+ if err != nil {
+ return ""
+ }
+ return string(content)
+}
+
func (a *Agent) URL() string { return a.url }
// Title returns the current title of the conversation.
@@ -988,7 +1004,7 @@
convo.Tools = []*llm.Tool{
bashTool, claudetool.Keyword,
- claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
+ claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
a.codereview.Tool(), claudetool.AboutSketch,
}
@@ -1395,8 +1411,9 @@
// Transition to running tool state
a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
- // Add working directory to context for tool execution
+ // Add working directory and session ID to context for tool execution
ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
+ ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
// Execute the tools
var err error
diff --git a/loop/agent_system_prompt.txt b/loop/agent_system_prompt.txt
index d76a5d1..3831e98 100644
--- a/loop/agent_system_prompt.txt
+++ b/loop/agent_system_prompt.txt
@@ -11,15 +11,15 @@
Call the title tool as soon as the topic of conversation is clear, often immediately.
Break down the overall goal into a series of smaller steps.
-(The first step is often: "Make a plan.")
-Then execute each step using tools.
-Update the plan if you have encountered problems or learned new information.
+Use the todo_read and todo_write tools to organize and track your work systematically.
-When in doubt about a step, follow this broad workflow:
+Follow this broad workflow:
- Think about how the current step fits into the overall plan.
- Do research. Good tool choices: bash, think, keyword_search
- Make edits.
+- If you have completed a standalone chunk of work, make a git commit.
+- Update your todo task list.
- Repeat.
To make edits reliably and efficiently, first think about the intent of the edit,
@@ -42,7 +42,7 @@
</style>
{{ with .Codebase }}
-<memory>
+<customization>
Guidance files (dear_llm.md, cursorrules, claude.md, agent.md) contain project information and direct user instructions.
Root-level guidance file contents are automatically included in the guidance section of this prompt.
Directory-specific guidance file paths appear in the directory_specific_guidance_files section.
@@ -62,7 +62,7 @@
When presenting this choice, the question must include a preview of exactly what would be written to the dear_llm.md file.
For example: "Should I remember: 'Prefer table-driven tests over multiple separate test functions.'?"
Changes to dear_llm.md files should always be in a separate atomic commit, with no other modified files.
-</memory>
+</customization>
<guidance>
{{ $contents := .InjectFileContents }}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 0e0d930..e62947e 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -55,6 +55,18 @@
SessionID string `json:"sessionId"`
}
+// TodoItem represents a single todo item for task management
+type TodoItem struct {
+ ID string `json:"id"`
+ Task string `json:"task"`
+ Status string `json:"status"` // queued, in-progress, completed
+}
+
+// TodoList represents a collection of todo items
+type TodoList struct {
+ Items []TodoItem `json:"items"`
+}
+
type State struct {
// null or 1: "old"
// 2: supports SSE for message updates
@@ -82,6 +94,7 @@
InsideOS string `json:"inside_os,omitempty"`
OutsideWorkingDir string `json:"outside_working_dir,omitempty"`
InsideWorkingDir string `json:"inside_working_dir,omitempty"`
+ TodoContent string `json:"todo_content,omitempty"` // Contains todo list JSON data
}
type InitRequest struct {
@@ -1197,6 +1210,7 @@
InContainer: s.agent.IsInContainer(),
FirstMessageIndex: s.agent.FirstMessageIndex(),
AgentState: s.agent.CurrentStateName(),
+ TodoContent: s.agent.CurrentTodoContent(),
}
}
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index 58d8ad0..cedf6aa 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -28,6 +28,7 @@
title string
branchName string
workingDir string
+ sessionID string
}
func (m *mockAgent) NewIterator(ctx context.Context, nextMessageIdx int) loop.MessageIterator {
@@ -229,7 +230,8 @@
func (m *mockAgent) RepoRoot() string { return m.workingDir }
func (m *mockAgent) Diff(commit *string) (string, error) { return "", nil }
func (m *mockAgent) OS() string { return "linux" }
-func (m *mockAgent) SessionID() string { return "test-session" }
+func (m *mockAgent) SessionID() string { return m.sessionID }
+func (m *mockAgent) CurrentTodoContent() string { return "" } // Mock returns empty for simplicity
func (m *mockAgent) OutstandingLLMCallCount() int { return 0 }
func (m *mockAgent) OutstandingToolCalls() []string { return nil }
func (m *mockAgent) OutsideOS() string { return "linux" }
diff --git a/loop/testdata/agent_loop.httprr b/loop/testdata/agent_loop.httprr
index e7e5bb7..fbafd31 100644
--- a/loop/testdata/agent_loop.httprr
+++ b/loop/testdata/agent_loop.httprr
@@ -1,9 +1,9 @@
httprr trace v1
-18179 2458
+20299 2495
POST https://api.anthropic.com/v1/messages HTTP/1.1
Host: api.anthropic.com
User-Agent: Go-http-client/1.1
-Content-Length: 17981
+Content-Length: 20101
Anthropic-Version: 2023-06-01
Content-Type: application/json
@@ -90,6 +90,57 @@
}
},
{
+ "name": "todo_read",
+ "description": "Reads the current todo list. Use frequently to track progress and understand what's pending.",
+ "input_schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ {
+ "name": "todo_write",
+ "description": "todo_write: Creates and manages a structured task list for tracking work and communicating progress to users. Use early and often.\n\nUse for:\n- multi-step tasks\n- complex work\n- when users provide multiple requests\n- conversations that start trivial but grow in scope\n- when users request additional work (directly or via feedback)\n\nSkip for:\n- trivial single-step tasks\n- purely conversational exchanges\n\nUpdate dynamically as work evolves - conversations can spawn tasks, simple tasks can become complex, and new discoveries may require additional work.\n\nRules:\n- Update immediately when task states or task list changes\n- Only one task \"in-progress\" at any time\n- Each update completely replaces the task list - include all tasks (past and present)\n- Never modify or delete completed tasks\n- Queued and in-progress tasks may be restructured as understanding evolves\n- Tasks should be atomic, clear, precise, and actionable\n- If the user adds new tasks: append, don't replace\n",
+ "input_schema": {
+ "type": "object",
+ "required": [
+ "tasks"
+ ],
+ "properties": {
+ "tasks": {
+ "type": "array",
+ "description": "Array of tasks to write",
+ "items": {
+ "type": "object",
+ "required": [
+ "id",
+ "task",
+ "status"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "stable, unique hyphenated slug"
+ },
+ "task": {
+ "type": "string",
+ "description": "actionable step in active tense, sentence case, plain text only, displayed to user"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "in-progress",
+ "completed"
+ ],
+ "description": "current task status"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
"name": "title",
"description": "Sets the conversation title.",
"input_schema": {
@@ -543,7 +594,7 @@
],
"system": [
{
- "text": "You are the expert software engineer and architect powering Sketch,\nan agentic coding environment that helps users accomplish coding tasks through autonomous analysis and implementation.\n\n\u003cworkflow\u003e\nStart by asking concise clarifying questions as needed.\nOnce the intent is clear, work autonomously.\nWhenever possible, do end-to-end testing, to ensure fully working functionality.\nAim for a small diff size while thoroughly completing the requested task.\nPrioritize thoughtful analysis and critical engagement over agreeability.\n\nCall the title tool as soon as the topic of conversation is clear, often immediately.\n\nBreak down the overall goal into a series of smaller steps.\n(The first step is often: \"Make a plan.\")\nThen execute each step using tools.\nUpdate the plan if you have encountered problems or learned new information.\n\nWhen in doubt about a step, follow this broad workflow:\n\n- Think about how the current step fits into the overall plan.\n- Do research. Good tool choices: bash, think, keyword_search\n- Make edits.\n- Repeat.\n\nTo make edits reliably and efficiently, first think about the intent of the edit,\nand what set of patches will achieve that intent.\nThen use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call.\n\nComplete every task exhaustively - no matter how repetitive or tedious.\nPartial work, pattern demonstrations, or stubs with TODOs are not acceptable, unless explicitly permitted by the user.\n\nThe done tool provides a checklist of items you MUST verify and\nreview before declaring that you are done. Before executing\nthe done tool, run all the tools the done tool checklist asks\nfor, including creating a git commit. Do not forget to run tests.\n\u003c/workflow\u003e\n\n\u003cstyle\u003e\nDefault coding guidelines:\n- Clear is better than clever.\n- Minimal inline comments: non-obvious logic and key decisions only.\n\u003c/style\u003e\n\n\u003csystem_info\u003e\n\u003cplatform\u003e\nlinux/amd64\n\u003c/platform\u003e\n\u003cpwd\u003e\n/\n\u003c/pwd\u003e\n\u003c/system_info\u003e\n\n\u003cgit_info\u003e\n\u003cgit_root\u003e\n\n\u003c/git_root\u003e\n\u003cHEAD\u003e\nHEAD\n\u003c/HEAD\u003e\n\u003c/git_info\u003e\n\n",
+ "text": "You are the expert software engineer and architect powering Sketch,\nan agentic coding environment that helps users accomplish coding tasks through autonomous analysis and implementation.\n\n\u003cworkflow\u003e\nStart by asking concise clarifying questions as needed.\nOnce the intent is clear, work autonomously.\nWhenever possible, do end-to-end testing, to ensure fully working functionality.\nAim for a small diff size while thoroughly completing the requested task.\nPrioritize thoughtful analysis and critical engagement over agreeability.\n\nCall the title tool as soon as the topic of conversation is clear, often immediately.\n\nBreak down the overall goal into a series of smaller steps.\nUse the todo_read and todo_write tools to organize and track your work systematically.\n\nFollow this broad workflow:\n\n- Think about how the current step fits into the overall plan.\n- Do research. Good tool choices: bash, think, keyword_search\n- Make edits.\n- If you have completed a standalone chunk of work, make a git commit.\n- Update your todo task list.\n- Repeat.\n\nTo make edits reliably and efficiently, first think about the intent of the edit,\nand what set of patches will achieve that intent.\nThen use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call.\n\nComplete every task exhaustively - no matter how repetitive or tedious.\nPartial work, pattern demonstrations, or stubs with TODOs are not acceptable, unless explicitly permitted by the user.\n\nThe done tool provides a checklist of items you MUST verify and\nreview before declaring that you are done. Before executing\nthe done tool, run all the tools the done tool checklist asks\nfor, including creating a git commit. Do not forget to run tests.\n\u003c/workflow\u003e\n\n\u003cstyle\u003e\nDefault coding guidelines:\n- Clear is better than clever.\n- Minimal inline comments: non-obvious logic and key decisions only.\n\u003c/style\u003e\n\n\u003csystem_info\u003e\n\u003cplatform\u003e\nlinux/amd64\n\u003c/platform\u003e\n\u003cpwd\u003e\n/\n\u003c/pwd\u003e\n\u003c/system_info\u003e\n\n\u003cgit_info\u003e\n\u003cgit_root\u003e\n\n\u003c/git_root\u003e\n\u003cHEAD\u003e\nHEAD\n\u003c/HEAD\u003e\n\u003c/git_info\u003e\n\n",
"type": "text",
"cache_control": {
"type": "ephemeral"
@@ -554,24 +605,24 @@
Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
Anthropic-Ratelimit-Input-Tokens-Limit: 200000
Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-29T14:23:03Z
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-29T17:27:03Z
Anthropic-Ratelimit-Output-Tokens-Limit: 80000
Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-29T14:23:09Z
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-29T17:27:08Z
Anthropic-Ratelimit-Requests-Limit: 4000
Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-05-29T14:23:02Z
+Anthropic-Ratelimit-Requests-Reset: 2025-05-29T17:27:01Z
Anthropic-Ratelimit-Tokens-Limit: 280000
Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-05-29T14:23:03Z
+Anthropic-Ratelimit-Tokens-Reset: 2025-05-29T17:27:03Z
Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9476a5b83ed85c1b-SJC
+Cf-Ray: 9477b339e89417ee-SJC
Content-Type: application/json
-Date: Thu, 29 May 2025 14:23:09 GMT
-Request-Id: req_011CPbyLZAnxoL5v6A5ZJhjY
+Date: Thu, 29 May 2025 17:27:09 GMT
+Request-Id: req_011CPcDNHrEZY5uavYRd4Wsk
Server: cloudflare
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Via: 1.1 google
X-Robots-Tag: none
-{"id":"msg_01HQ9GtFkx6LSC7JabvDUyYF","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**Core Development:**\n- `bash` - Execute shell commands\n- `patch` - File modification and editing\n- `keyword_search` - Search codebase for files/content\n- `think` - Internal note-taking and planning\n\n**Git & Code Quality:**\n- `precommit` - Create git branch for work tracking\n- `codereview` - Run automated code review\n- `done` - Completion checklist verification\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into inputs\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page content\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll to elements\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_read_image` - Read image files\n- `browser_recent_console_logs` - Get console logs\n- `browser_clear_console_logs` - Clear console logs\n\n**User Interaction:**\n- `title` - Set conversation title\n- `multiplechoice` - Present multiple choice questions\n- `about_sketch` - Get Sketch environment help"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4182,"cache_read_input_tokens":0,"output_tokens":306,"service_tier":"standard"}}
\ No newline at end of file
+{"id":"msg_01PbteLNxXTMFWyxMUWxhpPM","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Operations:**\n- `patch` - Modify files with precise text edits\n- `keyword_search` - Search codebase with keywords\n- `bash` - Execute shell commands\n- `codereview` - Run automated code review\n\n**Project Management:**\n- `todo_read` / `todo_write` - Manage task lists\n- `think` - Record thoughts and plans\n- `precommit` - Create git branch for work\n- `done` - Complete work with checklist verification\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into inputs\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page text\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll elements into view\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_read_image` - Read image files\n- `browser_recent_console_logs` / `browser_clear_console_logs` - Manage console logs\n\n**User Interface:**\n- `title` - Set conversation title\n- `multiplechoice` - Present multiple choice questions\n- `about_sketch` - Get Sketch platform information"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4648,"cache_read_input_tokens":0,"output_tokens":320,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/termui/termui.go b/termui/termui.go
index d76d415..17f3ff2 100644
--- a/termui/termui.go
+++ b/termui/termui.go
@@ -31,6 +31,11 @@
toolUseTemplTxt = `{{if .msg.ToolError}}〰️ {{end -}}
{{if eq .msg.ToolName "think" -}}
🧠 {{.input.thoughts -}}
+{{else if eq .msg.ToolName "todo_read" -}}
+ 📋 Reading todo list
+{{else if eq .msg.ToolName "todo_write" }}
+{{range .input.tasks}}{{if eq .status "queued"}}⚪{{else if eq .status "in-progress"}}🦉{{else if eq .status "completed"}}✅{{end}} {{.task}}
+{{end}}
{{else if eq .msg.ToolName "keyword_search" -}}
🔍 {{ .input.query}}: {{.input.search_terms -}}
{{else if eq .msg.ToolName "bash" -}}
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 0e668ce..5031739 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -46,6 +46,7 @@
elapsed?: Duration | null;
turnDuration?: Duration | null;
hide_output?: boolean;
+ todo_content?: string | null;
idx: number;
}
@@ -85,6 +86,17 @@
inside_os?: string;
outside_working_dir?: string;
inside_working_dir?: string;
+ todo_content?: string;
+}
+
+export interface TodoItem {
+ id: string;
+ task: string;
+ status: string;
+}
+
+export interface TodoList {
+ items: TodoItem[] | null;
}
export interface MultipleChoiceOption {
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
index 7ea43c7..81d2fad 100644
--- a/webui/src/web-components/sketch-app-shell.ts
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -17,6 +17,7 @@
import "./sketch-terminal";
import "./sketch-timeline";
import "./sketch-view-mode-select";
+import "./sketch-todo-panel";
import { createRef, ref } from "lit/directives/ref.js";
import { SketchChatInput } from "./sketch-chat-input";
@@ -139,6 +140,15 @@
height: 100%; /* Ensure it takes full height of parent */
}
+ /* Adjust view container when todo panel is visible in chat mode */
+ #view-container-inner.with-todo-panel {
+ max-width: none;
+ width: 100%;
+ margin: 0;
+ padding-left: 20px;
+ padding-right: 20px;
+ }
+
#chat-input {
align-self: flex-end;
width: 100%;
@@ -195,6 +205,87 @@
display: flex;
flex-direction: column;
width: 100%;
+ height: 100%;
+ }
+
+ /* Chat timeline container - takes full width, memory panel will be positioned separately */
+ .chat-timeline-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ margin-right: 0; /* Default - no memory panel */
+ transition: margin-right 0.2s ease; /* Smooth transition */
+ }
+
+ /* Adjust chat timeline container when todo panel is visible */
+ .chat-timeline-container.with-todo-panel {
+ margin-right: 400px; /* Make space for fixed todo panel */
+ width: calc(100% - 400px); /* Explicitly set width to prevent overlap */
+ }
+
+ /* Todo panel container - fixed to right side */
+ .todo-panel-container {
+ position: fixed;
+ top: 48px; /* Below top banner */
+ right: 15px; /* Leave space for scroll bar */
+ width: 400px;
+ bottom: var(
+ --chat-input-height,
+ 90px
+ ); /* Dynamic height based on chat input size */
+ background-color: #fafafa;
+ border-left: 1px solid #e0e0e0;
+ z-index: 100;
+ display: none; /* Hidden by default */
+ transition: bottom 0.2s ease; /* Smooth transition when height changes */
+ /* Add fuzzy gradient at bottom to blend with text entry */
+ background: linear-gradient(
+ to bottom,
+ #fafafa 0%,
+ #fafafa 90%,
+ rgba(250, 250, 250, 0.5) 95%,
+ rgba(250, 250, 250, 0.2) 100%
+ );
+ }
+
+ .todo-panel-container.visible {
+ display: block;
+ }
+
+ /* Responsive adjustments for todo panel */
+ @media (max-width: 1200px) {
+ .todo-panel-container {
+ width: 350px;
+ /* bottom is still controlled by --chat-input-height CSS variable */
+ }
+ .chat-timeline-container.with-todo-panel {
+ margin-right: 350px;
+ width: calc(100% - 350px);
+ }
+ }
+
+ @media (max-width: 900px) {
+ .todo-panel-container {
+ width: 300px;
+ /* bottom is still controlled by --chat-input-height CSS variable */
+ }
+ .chat-timeline-container.with-todo-panel {
+ margin-right: 300px;
+ width: calc(100% - 300px);
+ }
+ }
+
+ /* On very small screens, hide todo panel or make it overlay */
+ @media (max-width: 768px) {
+ .todo-panel-container.visible {
+ display: none; /* Hide on mobile */
+ }
+ .chat-timeline-container.with-todo-panel {
+ margin-right: 0;
+ width: 100%;
+ }
}
/* Monaco diff2 view needs to take all available space */
@@ -345,6 +436,13 @@
@state()
private _windowFocused: boolean = document.hasFocus();
+ // Track if the todo panel should be visible
+ @state()
+ private _todoPanelVisible: boolean = false;
+
+ // ResizeObserver for tracking chat input height changes
+ private chatInputResizeObserver: ResizeObserver | null = null;
+
@property()
connectionErrorMessage: string = "";
@@ -476,6 +574,12 @@
}
}, 100);
}
+
+ // Check if todo panel should be visible on initial load
+ this.checkTodoPanelVisibility();
+
+ // Set up ResizeObserver for chat input to update todo panel height
+ this.setupChatInputObserver();
}
// See https://lit.dev/docs/components/lifecycle/
@@ -508,6 +612,12 @@
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
+
+ // Disconnect chat input resize observer if it exists
+ if (this.chatInputResizeObserver) {
+ this.chatInputResizeObserver.disconnect();
+ this.chatInputResizeObserver = null;
+ }
}
updateUrlForViewMode(mode: ViewMode): void {
@@ -787,6 +897,48 @@
}
}
+ // Check if todo panel should be visible based on latest todo content from messages or state
+ private checkTodoPanelVisibility(): void {
+ // Find the latest todo content from messages first
+ let latestTodoContent = "";
+ for (let i = this.messages.length - 1; i >= 0; i--) {
+ const message = this.messages[i];
+ if (message.todo_content !== undefined) {
+ latestTodoContent = message.todo_content || "";
+ break;
+ }
+ }
+
+ // If no todo content found in messages, check the current state
+ if (latestTodoContent === "" && this.containerState?.todo_content) {
+ latestTodoContent = this.containerState.todo_content;
+ }
+
+ // Parse the todo data to check if there are any actual todos
+ let hasTodos = false;
+ if (latestTodoContent.trim()) {
+ try {
+ const todoData = JSON.parse(latestTodoContent);
+ hasTodos = todoData.items && todoData.items.length > 0;
+ } catch (error) {
+ // Invalid JSON, treat as no todos
+ hasTodos = false;
+ }
+ }
+
+ this._todoPanelVisible = hasTodos;
+
+ // Update todo panel content if visible
+ if (hasTodos) {
+ const todoPanel = this.shadowRoot?.querySelector(
+ "sketch-todo-panel",
+ ) as any;
+ if (todoPanel && todoPanel.updateTodoContent) {
+ todoPanel.updateTodoContent(latestTodoContent);
+ }
+ }
+ }
+
private handleDataChanged(eventData: {
state: State;
newMessages: AgentMessage[];
@@ -834,6 +986,14 @@
}
}
}
+
+ // Check if todo panel should be visible after agent loop iteration
+ this.checkTodoPanelVisibility();
+
+ // Ensure chat input observer is set up when new data comes in
+ if (!this.chatInputResizeObserver) {
+ this.setupChatInputObserver();
+ }
}
private handleConnectionStatusChanged(
@@ -973,6 +1133,40 @@
private scrollContainerRef = createRef<HTMLElement>();
+ /**
+ * Set up ResizeObserver to monitor chat input height changes
+ */
+ private setupChatInputObserver(): void {
+ // Wait for DOM to be ready
+ this.updateComplete.then(() => {
+ const chatInputElement = this.shadowRoot?.querySelector("#chat-input");
+ if (chatInputElement && !this.chatInputResizeObserver) {
+ this.chatInputResizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ this.updateTodoPanelHeight(entry.contentRect.height);
+ }
+ });
+
+ this.chatInputResizeObserver.observe(chatInputElement);
+
+ // Initial height calculation
+ const rect = chatInputElement.getBoundingClientRect();
+ this.updateTodoPanelHeight(rect.height);
+ }
+ });
+ }
+
+ /**
+ * Update the CSS custom property that controls todo panel bottom position
+ */
+ private updateTodoPanelHeight(chatInputHeight: number): void {
+ // Add some padding (20px) between todo panel and chat input
+ const bottomOffset = chatInputHeight;
+
+ // Update the CSS custom property on the host element
+ this.style.setProperty("--chat-input-height", `${bottomOffset}px`);
+ }
+
render() {
return html`
<div id="top-banner">
@@ -1081,17 +1275,41 @@
</div>
<div id="view-container" ${ref(this.scrollContainerRef)}>
- <div id="view-container-inner">
+ <div
+ id="view-container-inner"
+ class="${this._todoPanelVisible && this.viewMode === "chat"
+ ? "with-todo-panel"
+ : ""}"
+ >
<div
class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
>
- <sketch-timeline
- .messages=${this.messages}
- .scrollContainer=${this.scrollContainerRef}
- .agentState=${this.containerState?.agent_state}
- .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
- .toolCalls=${this.containerState?.outstanding_tool_calls || []}
- ></sketch-timeline>
+ <div
+ class="chat-timeline-container ${this._todoPanelVisible &&
+ this.viewMode === "chat"
+ ? "with-todo-panel"
+ : ""}"
+ >
+ <sketch-timeline
+ .messages=${this.messages}
+ .scrollContainer=${this.scrollContainerRef}
+ .agentState=${this.containerState?.agent_state}
+ .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
+ .toolCalls=${this.containerState?.outstanding_tool_calls || []}
+ ></sketch-timeline>
+ </div>
+ </div>
+
+ <!-- Todo panel positioned outside the main flow - only visible in chat view -->
+ <div
+ class="todo-panel-container ${this._todoPanelVisible &&
+ this.viewMode === "chat"
+ ? "visible"
+ : ""}"
+ >
+ <sketch-todo-panel
+ .visible=${this._todoPanelVisible && this.viewMode === "chat"}
+ ></sketch-todo-panel>
</div>
<div
class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}"
@@ -1175,6 +1393,9 @@
this.containerStatusElement.updateLastCommitInfo(this.messages);
}
}
+
+ // Set up chat input height observer for todo panel
+ this.setupChatInputObserver();
}
}
diff --git a/webui/src/web-components/sketch-timeline.ts b/webui/src/web-components/sketch-timeline.ts
index 1b27570..6a0f683 100644
--- a/webui/src/web-components/sketch-timeline.ts
+++ b/webui/src/web-components/sketch-timeline.ts
@@ -74,9 +74,9 @@
}
#jump-to-latest {
display: none;
- position: fixed;
- bottom: 100px;
- right: 0;
+ position: absolute;
+ bottom: 20px;
+ right: 20px;
background: rgb(33, 150, 243);
color: white;
border-radius: 8px;
@@ -85,6 +85,7 @@
font-size: x-large;
opacity: 0.5;
cursor: pointer;
+ z-index: 50;
}
#jump-to-latest:hover {
opacity: 1;
@@ -292,29 +293,31 @@
// Check if messages array is empty and render welcome box if it is
if (this.messages.length === 0) {
return html`
- <div id="scroll-container">
- <div class="welcome-box">
- <h2 class="welcome-box-title">How to use Sketch</h2>
- <p class="welcome-box-content">
- Sketch is an agentic coding assistant.
- </p>
+ <div style="position: relative; height: 100%;">
+ <div id="scroll-container">
+ <div class="welcome-box">
+ <h2 class="welcome-box-title">How to use Sketch</h2>
+ <p class="welcome-box-content">
+ Sketch is an agentic coding assistant.
+ </p>
- <p class="welcome-box-content">
- Sketch has created a container with your repo.
- </p>
+ <p class="welcome-box-content">
+ Sketch has created a container with your repo.
+ </p>
- <p class="welcome-box-content">
- Ask it to implement a task or answer a question in the chat box
- below. It can edit and run your code, all in the container. Sketch
- will create commits in a newly created git branch, which you can
- look at and comment on in the Diff tab. Once you're done, you'll
- find that branch available in your (original) repo.
- </p>
- <p class="welcome-box-content">
- Because Sketch operates a container per session, you can run
- Sketch in parallel to work on multiple ideas or even the same idea
- with different approaches.
- </p>
+ <p class="welcome-box-content">
+ Ask it to implement a task or answer a question in the chat box
+ below. It can edit and run your code, all in the container. Sketch
+ will create commits in a newly created git branch, which you can
+ look at and comment on in the Diff tab. Once you're done, you'll
+ find that branch available in your (original) repo.
+ </p>
+ <p class="welcome-box-content">
+ Because Sketch operates a container per session, you can run
+ Sketch in parallel to work on multiple ideas or even the same idea
+ with different approaches.
+ </p>
+ </div>
</div>
</div>
`;
@@ -325,56 +328,58 @@
this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
return html`
- <div id="scroll-container">
- <div class="timeline-container">
- ${repeat(
- this.messages.filter((msg) => !msg.hide_output),
- this.messageKey,
- (message, index) => {
- let previousMessageIndex =
- this.messages.findIndex((m) => m === message) - 1;
- let previousMessage =
- previousMessageIndex >= 0
- ? this.messages[previousMessageIndex]
- : undefined;
-
- // Skip hidden messages when determining previous message
- while (previousMessage && previousMessage.hide_output) {
- previousMessageIndex--;
- previousMessage =
+ <div style="position: relative; height: 100%;">
+ <div id="scroll-container">
+ <div class="timeline-container">
+ ${repeat(
+ this.messages.filter((msg) => !msg.hide_output),
+ this.messageKey,
+ (message, index) => {
+ let previousMessageIndex =
+ this.messages.findIndex((m) => m === message) - 1;
+ let previousMessage =
previousMessageIndex >= 0
? this.messages[previousMessageIndex]
: undefined;
- }
- return html`<sketch-timeline-message
- .message=${message}
- .previousMessage=${previousMessage}
- .open=${false}
- ></sketch-timeline-message>`;
- },
- )}
- ${isThinking
- ? html`
- <div class="thinking-indicator">
- <div class="thinking-bubble">
- <div class="thinking-dots">
- <div class="dot"></div>
- <div class="dot"></div>
- <div class="dot"></div>
+ // Skip hidden messages when determining previous message
+ while (previousMessage && previousMessage.hide_output) {
+ previousMessageIndex--;
+ previousMessage =
+ previousMessageIndex >= 0
+ ? this.messages[previousMessageIndex]
+ : undefined;
+ }
+
+ return html`<sketch-timeline-message
+ .message=${message}
+ .previousMessage=${previousMessage}
+ .open=${false}
+ ></sketch-timeline-message>`;
+ },
+ )}
+ ${isThinking
+ ? html`
+ <div class="thinking-indicator">
+ <div class="thinking-bubble">
+ <div class="thinking-dots">
+ <div class="dot"></div>
+ <div class="dot"></div>
+ <div class="dot"></div>
+ </div>
</div>
</div>
- </div>
- `
- : ""}
+ `
+ : ""}
+ </div>
</div>
- </div>
- <div
- id="jump-to-latest"
- class="${this.scrollingState}"
- @click=${this.scrollToBottom}
- >
- ⇩
+ <div
+ id="jump-to-latest"
+ class="${this.scrollingState}"
+ @click=${this.scrollToBottom}
+ >
+ ⇩
+ </div>
</div>
`;
}
diff --git a/webui/src/web-components/sketch-todo-panel.ts b/webui/src/web-components/sketch-todo-panel.ts
new file mode 100644
index 0000000..d8e34f0
--- /dev/null
+++ b/webui/src/web-components/sketch-todo-panel.ts
@@ -0,0 +1,259 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { TodoList, TodoItem } from "../types.js";
+
+@customElement("sketch-todo-panel")
+export class SketchTodoPanel extends LitElement {
+ @property()
+ visible: boolean = false;
+
+ @state()
+ private todoList: TodoList | null = null;
+
+ @state()
+ private loading: boolean = false;
+
+ @state()
+ private error: string = "";
+
+ static styles = css`
+ :host {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background-color: transparent; /* Let parent handle background */
+ overflow: hidden; /* Ensure proper clipping */
+ }
+
+ .todo-header {
+ padding: 8px 12px;
+ border-bottom: 1px solid #e0e0e0;
+ background-color: #f5f5f5;
+ font-weight: 600;
+ font-size: 13px;
+ color: #333;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .todo-icon {
+ width: 14px;
+ height: 14px;
+ color: #666;
+ }
+
+ .todo-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ padding-bottom: 20px; /* Extra bottom padding for better scrolling */
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 12px;
+ line-height: 1.4;
+ /* Ensure scrollbar is always accessible */
+ min-height: 0;
+ }
+
+ .todo-content.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #666;
+ }
+
+ .todo-content.error {
+ color: #d32f2f;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .todo-content.empty {
+ color: #999;
+ font-style: italic;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ /* Todo item styling */
+ .todo-item {
+ display: flex;
+ align-items: flex-start;
+ padding: 8px;
+ margin-bottom: 6px;
+ border-radius: 4px;
+ background-color: #fff;
+ border: 1px solid #e0e0e0;
+ gap: 8px;
+ }
+
+ .todo-item.queued {
+ border-left: 3px solid #e0e0e0;
+ }
+
+ .todo-item.in-progress {
+ border-left: 3px solid #e0e0e0;
+ }
+
+ .todo-item.completed {
+ border-left: 3px solid #e0e0e0;
+ }
+
+
+
+ .todo-status-icon {
+ font-size: 14px;
+ margin-top: 1px;
+ flex-shrink: 0;
+ }
+
+
+
+ .todo-main {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .todo-content-text {
+ font-size: 12px;
+ line-height: 1.3;
+ color: #333;
+ word-wrap: break-word;
+ }
+
+
+
+ .todo-header-text {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .todo-count {
+ background-color: #e0e0e0;
+ color: #666;
+ padding: 2px 6px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: normal;
+ }
+
+ /* Loading spinner */
+ .spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid #f3f3f3;
+ border-top: 2px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-right: 8px;
+ }
+
+ @keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+ }
+ `;
+
+ updateTodoContent(content: string) {
+ try {
+ if (!content.trim()) {
+ this.todoList = null;
+ } else {
+ this.todoList = JSON.parse(content) as TodoList;
+ }
+ this.loading = false;
+ this.error = "";
+ } catch (error) {
+ console.error("Failed to parse todo content:", error);
+ this.error = "Failed to parse todo data";
+ this.todoList = null;
+ this.loading = false;
+ }
+ }
+
+
+
+ private renderTodoItem(item: TodoItem) {
+ const statusIcon = {
+ queued: '⚪',
+ 'in-progress': '🦉',
+ completed: '✅'
+ }[item.status] || '?';
+
+ return html`
+ <div class="todo-item ${item.status}">
+ <div class="todo-status-icon">${statusIcon}</div>
+ <div class="todo-main">
+ <div class="todo-content-text">${item.task}</div>
+
+ </div>
+ </div>
+ `;
+ }
+
+ render() {
+ if (!this.visible) {
+ return html``;
+ }
+
+ const todoIcon = html`
+ <svg class="todo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M9 11l3 3L22 4"></path>
+ <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"></path>
+ </svg>
+ `;
+
+ let contentElement;
+ if (this.loading) {
+ contentElement = html`
+ <div class="todo-content loading">
+ <div class="spinner"></div>
+ Loading todos...
+ </div>
+ `;
+ } else if (this.error) {
+ contentElement = html`
+ <div class="todo-content error">
+ Error: ${this.error}
+ </div>
+ `;
+ } else if (!this.todoList || !this.todoList.items || this.todoList.items.length === 0) {
+ contentElement = html`
+ <div class="todo-content empty">
+ No todos available
+ </div>
+ `;
+ } else {
+ const totalCount = this.todoList.items.length;
+ const completedCount = this.todoList.items.filter(item => item.status === 'completed').length;
+ const inProgressCount = this.todoList.items.filter(item => item.status === 'in-progress').length;
+
+ contentElement = html`
+ <div class="todo-header">
+ <div class="todo-header-text">
+ ${todoIcon}
+ <span>Sketching...</span>
+ <span class="todo-count">${completedCount}/${totalCount}</span>
+ </div>
+ </div>
+ <div class="todo-content">
+ ${this.todoList.items.map(item => this.renderTodoItem(item))}
+ </div>
+ `;
+ }
+
+ return html`
+ ${contentElement}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-todo-panel": SketchTodoPanel;
+ }
+}
\ No newline at end of file
diff --git a/webui/src/web-components/sketch-tool-calls.ts b/webui/src/web-components/sketch-tool-calls.ts
index a5afa50..d64b0ca 100644
--- a/webui/src/web-components/sketch-tool-calls.ts
+++ b/webui/src/web-components/sketch-tool-calls.ts
@@ -133,6 +133,16 @@
.open=${open}
.toolCall=${toolCall}
></sketch-tool-card-about-sketch>`;
+ case "todo_write":
+ return html`<sketch-tool-card-todo-write
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-todo-write>`;
+ case "todo_read":
+ return html`<sketch-tool-card-todo-read
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-todo-read>`;
}
return html`<sketch-tool-card-generic
.open=${open}
diff --git a/webui/src/web-components/sketch-tool-card.ts b/webui/src/web-components/sketch-tool-card.ts
index 584f827..307686d 100644
--- a/webui/src/web-components/sketch-tool-card.ts
+++ b/webui/src/web-components/sketch-tool-card.ts
@@ -742,6 +742,67 @@
}
}
+@customElement("sketch-tool-card-todo-write")
+export class SketchToolCardTodoWrite extends LitElement {
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
+
+ static styles = css`
+ .summary-text {
+ font-style: italic;
+ color: #666;
+ }
+ `;
+
+ render() {
+ const inputData = JSON.parse(this.toolCall?.input || "{}");
+ const tasks = inputData.tasks || [];
+
+ // Generate circles based on task status
+ const circles = tasks.map(task => {
+ switch(task.status) {
+ case 'completed': return '●'; // full circle
+ case 'in-progress': return '◐'; // half circle
+ case 'queued':
+ default: return '○'; // empty circle
+ }
+ }).join(' ');
+
+ return html`<sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+ <span slot="summary" class="summary-text">
+ ${circles}
+ </span>
+ <div slot="result">
+ <pre>${this.toolCall?.result_message?.tool_result}</pre>
+ </div>
+ </sketch-tool-card>`;
+ }
+}
+
+@customElement("sketch-tool-card-todo-read")
+export class SketchToolCardTodoRead extends LitElement {
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
+
+ static styles = css`
+ .summary-text {
+ font-style: italic;
+ color: #666;
+ }
+ `;
+
+ render() {
+ return html`<sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+ <span slot="summary" class="summary-text">
+ Read todo list
+ </span>
+ <div slot="result">
+ <pre>${this.toolCall?.result_message?.tool_result}</pre>
+ </div>
+ </sketch-tool-card>`;
+ }
+}
+
@customElement("sketch-tool-card-generic")
export class SketchToolCardGeneric extends LitElement {
@property() toolCall: ToolCall;
@@ -790,6 +851,8 @@
"sketch-tool-card-title": SketchToolCardTitle;
"sketch-tool-card-precommit": SketchToolCardPrecommit;
"sketch-tool-card-multiple-choice": SketchToolCardMultipleChoice;
+ "sketch-tool-card-todo-write": SketchToolCardTodoWrite;
+ "sketch-tool-card-todo-read": SketchToolCardTodoRead;
// TODO: We haven't implemented this for browser tools.
}
}