refactor: remove todo tool (#35989)

This commit is contained in:
Aiden Cline 2026-07-09 00:13:48 -05:00 committed by GitHub
parent d4155f2906
commit 7feefb697f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
250 changed files with 237 additions and 4755 deletions

View file

@ -495,7 +495,6 @@ async function subscribeSessionEvents() {
console.log("Subscribing to session events...")
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
bash: ["Bash", "\x1b[31m\x1b[1m"],
edit: ["Edit", "\x1b[32m\x1b[1m"],
glob: ["Glob", "\x1b[34m\x1b[1m"],

View file

@ -33,11 +33,9 @@ test.describe("timeline tool state stability", () => {
}
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
const questionID = "prt_state_question"
const todoID = "prt_state_todo"
const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"),
]
const childID = "ses_timeline_child"
@ -49,7 +47,6 @@ test.describe("timeline tool state stability", () => {
await timeline.send(status("busy"), 120)
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
const regionIDs = [
"prt_state_webfetch",
@ -105,7 +102,6 @@ test.describe("timeline tool state stability", () => {
]),
)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
await expect(
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible()

View file

@ -269,7 +269,6 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"

View file

@ -90,7 +90,7 @@ async function mockServers(page: Page, requests: string[]) {
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))

View file

@ -65,7 +65,7 @@ async function mockServers(page: Page) {
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})

View file

@ -35,7 +35,6 @@ test.describe("session timeline projection", () => {
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
"prt_question",
"question",
@ -65,7 +64,6 @@ test.describe("session timeline projection", () => {
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {

View file

@ -17,13 +17,11 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
error: "The user dismissed this question",
}),
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
for (let index = 0; index < ordinary.length; index++) {
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
}

View file

@ -1,186 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TodoDockNavigation"
const projectID = "proj_todo_dock_navigation"
const sourceID = "ses_todo_dock_source"
const otherID = "ses_todo_dock_other"
const sourceTitle = "Todo dock animation"
const otherTitle = "Separate session"
const activeTodos = [
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
]
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "todo-dock-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
await page.goto(sessionHref(sourceID))
await expectSessionTitle(page, sourceTitle)
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await page.waitForTimeout(700)
const opening = sampleDock(page, 1_000)
todos[sourceID] = activeTodos
events.push(todoEvent(sourceID, activeTodos))
await expect(dock).toBeVisible()
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
const returningOpen = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
const openSamples = (await returningOpen).filter((sample) => sample.present)
expect(openSamples.length).toBeGreaterThan(0)
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
expect(openSamples[0]!.height).toBeGreaterThan(70)
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
const closing = sampleDock(page, 1_000)
todos[sourceID] = completedTodos
events.push(todoEvent(sourceID, completedTodos))
await expect(dock).toHaveCount(0)
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
todos[sourceID] = []
events.push(todoEvent(sourceID, []))
await switchSession(page, otherID, otherTitle)
const returningEmpty = sampleDock(page, 700)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
})
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
return {
directory,
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
}
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, dirBase64, server, sessionIDs }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = sessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
}

View file

@ -63,7 +63,7 @@ async function mockServer(page: Page) {
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))

View file

@ -229,7 +229,6 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"

View file

@ -17,7 +17,6 @@ export interface MockServerConfig {
onMessage?: (input: { sessionID: string; messageID: string }) => void
events?: () => unknown[]
eventRetry?: number
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
@ -96,8 +95,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, message)
}
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)

View file

@ -1,8 +1,6 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() {
@ -104,94 +102,6 @@ function PromptInputExample() {
)
}
const todos: Todo[] = [
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
]
function PromptInputWithOpenDock() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
activeTab: undefined as string | undefined,
todoCollapsed: false,
})
const inputControls = {
agents: {
available: [],
options: ["build"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => [],
open: () => {},
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const state = {
blocked: () => false,
questionRequest: () => undefined,
permissionRequest: () => undefined,
permissionResponding: () => false,
decide: () => {},
todos: () => todos,
dock: () => true,
closing: () => false,
opening: () => false,
}
return (
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt: input.state,
ready: () => true,
centered: () => false,
todo: {
collapsed: () => controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
},
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
@ -206,12 +116,3 @@ export const Basic = {
</div>
),
}
export const DockAlreadyOpen = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
<PromptInputWithOpenDock />
</div>
),
}

View file

@ -221,8 +221,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync().session.set("todo", sessionID, [])
input.onAbort?.()
const key = pendingKey(sessionID)

View file

@ -11,7 +11,6 @@ const sessionFields = new Set([
"session_status",
"session_working",
"session_diff",
"todo",
"permission",
"question",
"message",
@ -115,7 +114,6 @@ export const createDirSyncContext = (
index(sessionID)
},
diff: serverSync.session.diff,
todo: serverSync.session.todo,
history: serverSync.session.history,
evict(sessionID: string) {
serverSync.session.evict(sessionID)

View file

@ -30,7 +30,6 @@ function directoryState() {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,

View file

@ -223,7 +223,6 @@ export function createChildStoreManager(input: {
return (type ?? "idle") !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
get mcp_ready() {

View file

@ -72,7 +72,6 @@ const baseState = (input: Partial<State> = {}) =>
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
@ -216,7 +215,6 @@ describe("applyDirectoryEvent", () => {
message: { ses_1: [message] },
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
session_diff: { ses_1: [] },
todo: { ses_1: [] },
permission: { ses_1: [] },
question: { ses_1: [] },
session_status: { ses_1: { type: "busy" } },
@ -237,7 +235,6 @@ describe("applyDirectoryEvent", () => {
expect(store.message.ses_1).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
@ -262,7 +259,6 @@ describe("applyDirectoryEvent", () => {
message: { [item.info.id]: [message] },
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
session_diff: { [item.info.id]: [] },
todo: { [item.info.id]: [] },
permission: { [item.info.id]: [] },
question: { [item.info.id]: [] },
session_status: { [item.info.id]: { type: "busy" } },
@ -283,7 +279,6 @@ describe("applyDirectoryEvent", () => {
expect(store.message[item.info.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[item.info.id]).toBeUndefined()
expect(store.todo[item.info.id]).toBeUndefined()
expect(store.permission[item.info.id]).toBeUndefined()
expect(store.question[item.info.id]).toBeUndefined()
expect(store.session_status[item.info.id]).toBeUndefined()
@ -294,7 +289,6 @@ describe("applyDirectoryEvent", () => {
const dropped = rootSession({ id: "ses_b" })
const kept = rootSession({ id: "ses_a" })
const message = userMessage("msg_1", dropped.id)
const todos: string[] = []
const [store, setStore] = createStore(
baseState({
limit: 1,
@ -302,7 +296,6 @@ describe("applyDirectoryEvent", () => {
message: { [dropped.id]: [message] },
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
@ -316,21 +309,15 @@ describe("applyDirectoryEvent", () => {
push() {},
directory: "/tmp",
loadLsp() {},
setSessionTodo(sessionID, value) {
if (value !== undefined) return
todos.push(sessionID)
},
})
expect(store.session.map((x) => x.id)).toEqual([kept.id])
expect(store.message[dropped.id]).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {

View file

@ -9,7 +9,6 @@ import type {
Session,
SessionStatus,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
@ -19,7 +18,6 @@ import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
"session.status",
"message.updated",
"message.removed",
@ -62,13 +60,8 @@ export function applyGlobalEvent(input: {
)
}
function cleanupSessionCaches(
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
if (!sessionID) return
setSessionTodo?.(sessionID, undefined)
setStore(
produce((draft) => {
dropSessionCaches(draft, [sessionID])
@ -76,17 +69,11 @@ function cleanupSessionCaches(
)
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: Session[],
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status),
@ -95,9 +82,6 @@ export function cleanupDroppedSessionCaches(
.filter((sessionID): sessionID is string => !!sessionID),
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
if (stale.length === 0) return
for (const sessionID of stale) {
setSessionTodo?.(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
@ -114,7 +98,6 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
sessionContent?: boolean
permission?: State["permission"]
@ -138,7 +121,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
@ -155,7 +138,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@ -168,7 +151,7 @@ export function applyDirectoryEvent(input: {
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed)
break
}
case "session.deleted": {
@ -182,7 +165,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@ -192,12 +175,6 @@ export function applyDirectoryEvent(input: {
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
input.setStore("session_status", props.sessionID, reconcile(props.status))

View file

@ -6,7 +6,6 @@ import type {
QuestionRequest,
SessionStatus,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@ -34,7 +33,6 @@ describe("app session cache", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@ -43,7 +41,6 @@ describe("app session cache", () => {
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: { ses_1: [] as Todo[] },
message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
@ -56,7 +53,6 @@ describe("app session cache", () => {
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
@ -68,7 +64,6 @@ describe("app session cache", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@ -77,7 +72,6 @@ describe("app session cache", () => {
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},

View file

@ -5,7 +5,6 @@ import type {
QuestionRequest,
SessionStatus,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40
@ -13,7 +12,6 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@ -36,7 +34,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]

View file

@ -14,7 +14,6 @@ import type {
Session,
SessionStatus,
FileDiffInfo,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
@ -53,9 +52,6 @@ export type State = {
session_diff: {
[sessionID: string]: FileDiffInfo[]
}
todo: {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
}

View file

@ -65,7 +65,6 @@ type SessionView = {
reviewOpen?: string[]
pendingMessage?: string
pendingMessageAt?: number
todoCollapsed?: boolean
}
type TabHandoff = {
@ -836,18 +835,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(key(), tab, pos)
},
todoCollapsed: {
get: () => s().todoCollapsed ?? false,
set(collapsed: boolean) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
} else {
setStore("sessionView", session, "todoCollapsed", collapsed)
}
},
},
terminal: {
opened: terminalOpened,
open() {

View file

@ -151,7 +151,6 @@ function setup(sessions: Record<string, Session>) {
return response()
},
diff: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
} as unknown as OpencodeClient
return { get, messages, store: createServerSession(client) }

View file

@ -9,7 +9,6 @@ import type {
Session,
SessionStatus,
FileDiffInfo,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
@ -140,7 +139,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
@ -153,7 +151,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const messageLoads = new Map<string, MessageLoadState>()
const pendingParts = new Map<string, Map<string, Set<string>>>()
@ -199,7 +196,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
@ -254,8 +250,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
!requests.has(sessionID) &&
!messageLoads.has(sessionID) &&
!inflight.has(sessionID) &&
!inflightDiff.has(sessionID) &&
!inflightTodo.has(sessionID)
!inflightDiff.has(sessionID)
)
generations.delete(sessionID)
}
@ -418,7 +413,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
requests.delete(sessionID)
inflight.delete(sessionID)
inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID)
messageLoads.delete(sessionID)
pendingParts.delete(sessionID)
orphanParts.delete(sessionID)
@ -448,7 +442,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
...requests.keys(),
...inflight.keys(),
...inflightDiff.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
@ -773,11 +766,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
return
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
setData("session_status", props.sessionID, reconcile(props.status))
@ -1140,17 +1128,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
})
})
},
todo(sessionID: string, options?: { force?: boolean }) {
touch(sessionID)
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
return retry(() => client.session.todo({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
})
})
},
history: {
more: (sessionID: string) =>
data.message[sessionID] !== undefined &&

View file

@ -577,9 +577,6 @@ export const dict = {
"session.messages.loading": "جارٍ تحميل الرسائل...",
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.question.minimize": "تصغير السؤال",
"session.question.restore": "استعادة السؤال",
"session.question.pending.one": "{{count}} سؤال معلق",
@ -867,8 +864,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
"settings.permissions.tool.todowrite.title": "كتابة المهام",
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
"settings.permissions.tool.webfetch.title": "جلب الويب",
"settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL",
"settings.permissions.tool.websearch.title": "بحث الويب",
@ -931,7 +926,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
"session.question.progress": "{{current}} من {{total}} أسئلة",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "مستكشف الملفات",

View file

@ -582,9 +582,6 @@ export const dict = {
"session.messages.loading": "Carregando mensagens...",
"session.messages.jumpToLatest": "Ir para a mais recente",
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.question.minimize": "Minimizar pergunta",
"session.question.restore": "Restaurar pergunta",
"session.question.pending.one": "{{count}} pergunta pendente",
@ -880,8 +877,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
"settings.permissions.tool.webfetch.title": "Buscar Web",
"settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL",
"settings.permissions.tool.websearch.title": "Pesquisa Web",
@ -944,7 +939,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
"session.review.noVcs.createGit.action": "Criar repositório Git",
"session.todo.progress": "{{done}} de {{total}} tarefas concluídas",
"session.question.progress": "{{current}} de {{total}} perguntas",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de Arquivos",

View file

@ -637,9 +637,6 @@ export const dict = {
"session.messages.jumpToLatest": "Idi na najnovije",
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
"session.todo.title": "Zadaci",
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.question.minimize": "Minimiziraj pitanje",
"session.question.restore": "Vrati pitanje",
"session.question.pending.one": "{{count}} pitanje na čekanju",
@ -954,8 +951,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
"settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a",
"settings.permissions.tool.websearch.title": "Web pretraga",
@ -1020,7 +1015,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
"session.question.progress": "{{current}} od {{total}} pitanja",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",

View file

@ -632,9 +632,6 @@ export const dict = {
"session.messages.jumpToLatest": "Gå til seneste",
"session.context.addToContext": "Tilføj {{selection}} til kontekst",
"session.todo.title": "Opgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.question.minimize": "Minimer spørgsmål",
"session.question.restore": "Gendan spørgsmål",
"session.question.pending.one": "{{count}} afventende spørgsmål",
@ -946,8 +943,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
"settings.permissions.tool.todowrite.title": "Skriv To-do",
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
"settings.permissions.tool.webfetch.title": "Webhentning",
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøgning",
@ -1012,7 +1007,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
"session.review.noVcs.createGit.action": "Opret Git-repository",
"session.todo.progress": "{{done}} af {{total}} opgaver fuldført",
"session.question.progress": "{{current}} af {{total}} spørgsmål",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Stifinder",

View file

@ -591,9 +591,6 @@ export const dict = {
"session.messages.loading": "Lade Nachrichten...",
"session.messages.jumpToLatest": "Zum neuesten springen",
"session.context.addToContext": "{{selection}} zum Kontext hinzufügen",
"session.todo.title": "Aufgaben",
"session.todo.collapse": "Einklappen",
"session.todo.expand": "Ausklappen",
"session.question.minimize": "Frage minimieren",
"session.question.restore": "Frage wiederherstellen",
"session.question.pending.one": "{{count}} ausstehende Frage",
@ -892,8 +889,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
"settings.permissions.tool.todowrite.title": "Todo schreiben",
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
"settings.permissions.tool.webfetch.title": "Web-Abruf",
"settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen",
"settings.permissions.tool.websearch.title": "Web-Suche",
@ -957,7 +952,6 @@ export const dict = {
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
"session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...",
"session.review.noVcs.createGit.action": "Git-Repository erstellen",
"session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt",
"session.question.progress": "{{current}} von {{total}} Fragen",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Datei-Explorer",

View file

@ -659,10 +659,6 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest",
"session.context.addToContext": "Add {{selection}} to context",
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
@ -1040,8 +1036,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Load a skill by name",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Run language server queries",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Update the todo list",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
"settings.permissions.tool.websearch.title": "Web Search",

View file

@ -638,9 +638,6 @@ export const dict = {
"session.messages.jumpToLatest": "Ir al último",
"session.context.addToContext": "Añadir {{selection}} al contexto",
"session.todo.title": "Tareas",
"session.todo.collapse": "Contraer",
"session.todo.expand": "Expandir",
"session.question.minimize": "Minimizar pregunta",
"session.question.restore": "Restaurar pregunta",
"session.question.pending.one": "{{count}} pregunta pendiente",
@ -962,8 +959,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
"settings.permissions.tool.todowrite.title": "Escribir Todo",
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Obtener contenido de una URL",
"settings.permissions.tool.websearch.title": "Búsqueda Web",
@ -1028,7 +1023,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
"session.review.noVcs.createGit.action": "Crear repositorio Git",
"session.todo.progress": "{{done}} de {{total}} tareas completadas",
"session.question.progress": "{{current}} de {{total}} preguntas",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de archivos",

View file

@ -587,9 +587,6 @@ export const dict = {
"session.messages.loading": "Chargement des messages...",
"session.messages.jumpToLatest": "Aller au dernier",
"session.context.addToContext": "Ajouter {{selection}} au contexte",
"session.todo.title": "Tâches",
"session.todo.collapse": "Réduire",
"session.todo.expand": "Développer",
"session.question.minimize": "Réduire la question",
"session.question.restore": "Restaurer la question",
"session.question.pending.one": "{{count}} question en attente",
@ -891,8 +888,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
"settings.permissions.tool.todowrite.title": "Écrire Todo",
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
"settings.permissions.tool.webfetch.title": "Récupération Web",
"settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL",
"settings.permissions.tool.websearch.title": "Recherche Web",
@ -955,7 +950,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
"session.review.noVcs.createGit.action": "Créer un dépôt Git",
"session.todo.progress": "{{done}} tâches sur {{total}} terminées",
"session.question.progress": "{{current}} questions sur {{total}}",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorateur de fichiers",

View file

@ -579,9 +579,6 @@ export const dict = {
"session.messages.loading": "メッセージを読み込み中...",
"session.messages.jumpToLatest": "最新へジャンプ",
"session.context.addToContext": "{{selection}}をコンテキストに追加",
"session.todo.title": "ToDo",
"session.todo.collapse": "折りたたむ",
"session.todo.expand": "展開",
"session.question.minimize": "質問を最小化",
"session.question.restore": "質問を復元",
"session.question.pending.one": "{{count}}件の保留中の質問",
@ -874,8 +871,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
"settings.permissions.tool.todowrite.title": "Todo書き込み",
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
"settings.permissions.tool.webfetch.title": "Web取得",
"settings.permissions.tool.webfetch.description": "URLからコンテンツを取得",
"settings.permissions.tool.websearch.title": "Web検索",
@ -938,7 +933,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
"session.review.noVcs.createGit.action": "Git リポジトリを作成",
"session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了",
"session.question.progress": "{{total}} 問中 {{current}} 問",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "エクスプローラー",

View file

@ -471,9 +471,6 @@ export const dict = {
"session.messages.loading": "메시지 로드 중...",
"session.messages.jumpToLatest": "최신으로 이동",
"session.context.addToContext": "컨텍스트에 {{selection}} 추가",
"session.todo.title": "할 일",
"session.todo.collapse": "접기",
"session.todo.expand": "펼치기",
"session.followupDock.summary.one": "{{count}}개의 대기 중인 메시지",
"session.followupDock.summary.other": "{{count}}개의 대기 중인 메시지",
"session.followupDock.sendNow": "지금 전송",
@ -721,8 +718,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
"settings.permissions.tool.webfetch.title": "웹 가져오기",
"settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기",
"settings.permissions.tool.websearch.title": "웹 검색",
@ -785,7 +780,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소",
"session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...",
"session.review.noVcs.createGit.action": "Git 저장소 생성",
"session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료",
"session.question.progress": "{{total}}개의 질문 중 {{current}}개",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "파일 탐색기",

View file

@ -532,9 +532,6 @@ export const dict = {
"session.messages.jumpToLatest": "Hopp til nyeste",
"session.context.addToContext": "Legg til {{selection}} i kontekst",
"session.todo.title": "Oppgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Utvid",
"session.followupDock.summary.one": "{{count}} melding i kø",
"session.followupDock.summary.other": "{{count}} meldinger i kø",
"session.followupDock.sendNow": "Send nå",
@ -806,8 +803,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
"settings.permissions.tool.webfetch.title": "Webhenting",
"settings.permissions.tool.webfetch.description": "Hent innhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøk",
@ -872,7 +867,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet",
"session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...",
"session.review.noVcs.createGit.action": "Opprett Git-depot",
"session.todo.progress": "{{done}} av {{total}} oppgaver fullført",
"session.question.progress": "{{current}} av {{total}} spørsmål",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Filutforsker",

View file

@ -582,9 +582,6 @@ export const dict = {
"session.messages.loading": "Ładowanie wiadomości...",
"session.messages.jumpToLatest": "Przejdź do najnowszych",
"session.context.addToContext": "Dodaj {{selection}} do kontekstu",
"session.todo.title": "Zadania",
"session.todo.collapse": "Zwiń",
"session.todo.expand": "Rozwiń",
"session.question.minimize": "Zminimalizuj pytanie",
"session.question.restore": "Przywróć pytanie",
"session.question.pending.one": "{{count}} oczekujące pytanie",
@ -879,8 +876,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
"settings.permissions.tool.todowrite.title": "Zapis Todo",
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
"settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL",
"settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci",
@ -943,7 +938,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie",
"session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...",
"session.review.noVcs.createGit.action": "Utwórz repozytorium Git",
"session.todo.progress": "Ukończono {{done}} z {{total}} zadań",
"session.question.progress": "{{current}} z {{total}} pytań",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Eksplorator plików",

View file

@ -635,9 +635,6 @@ export const dict = {
"session.messages.jumpToLatest": "Перейти к последнему",
"session.context.addToContext": "Добавить {{selection}} в контекст",
"session.todo.title": "Задачи",
"session.todo.collapse": "Свернуть",
"session.todo.expand": "Развернуть",
"session.question.minimize": "Свернуть вопрос",
"session.question.restore": "Восстановить вопрос",
"session.question.pending.one": "{{count}} вопрос без ответа",
@ -956,8 +953,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Получение контента по URL",
"settings.permissions.tool.websearch.title": "Web Search",
@ -1023,7 +1018,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте",
"session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...",
"session.review.noVcs.createGit.action": "Создать репозиторий Git",
"session.todo.progress": "Выполнено {{done}} из {{total}} задач",
"session.question.progress": "{{current}} из {{total}} вопросов",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Проводник",

View file

@ -632,9 +632,6 @@ export const dict = {
"session.messages.jumpToLatest": "ไปที่ล่าสุด",
"session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
"session.todo.title": "สิ่งที่ต้องทำ",
"session.todo.collapse": "ย่อ",
"session.todo.expand": "ขยาย",
"session.question.minimize": "ย่อคำถาม",
"session.question.restore": "คืนค่าคำถาม",
"session.question.pending.one": "คำถามที่รอดำเนินการ {{count}} ข้อ",
@ -942,8 +939,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
"settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL",
"settings.permissions.tool.websearch.title": "ค้นหาเว็บ",
@ -1008,7 +1003,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้",
"session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git รีโพซิทอรี...",
"session.review.noVcs.createGit.action": "สร้าง Git รีโพซิทอรี",
"session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ",
"session.question.progress": "{{current}} จาก {{total}} คำถาม",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",

View file

@ -642,9 +642,6 @@ export const dict = {
"session.messages.jumpToLatest": "En sona atla",
"session.context.addToContext": "{{selection}} bağlama ekle",
"session.todo.title": "Görevler",
"session.todo.collapse": "Daralt",
"session.todo.expand": "Genişlet",
"session.question.minimize": "Soruyu küçült",
"session.question.restore": "Soruyu geri yükle",
"session.question.pending.one": "{{count}} bekleyen soru",
@ -962,8 +959,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
"settings.permissions.tool.todowrite.title": "Görev Yaz",
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
"settings.permissions.tool.webfetch.title": "Web Getir",
"settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir",
"settings.permissions.tool.websearch.title": "Web Ara",
@ -1028,7 +1023,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "Bu projedeki değişiklikleri takip et, incele ve geri al",
"session.review.noVcs.createGit.actionLoading": "Git deposu oluşturuluyor...",
"session.review.noVcs.createGit.action": "Git deposu oluştur",
"session.todo.progress": "{{total}} görevin {{done}} tanesi tamamlandı",
"session.question.progress": "{{total}} sorunun {{current}} tanesi",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Dosya Gezgini",

View file

@ -663,10 +663,6 @@ export const dict = {
"session.messages.jumpToLatest": "Перейти до останніх",
"session.context.addToContext": "Додати {{selection}} до контексту",
"session.todo.title": "Завдання",
"session.todo.collapse": "Згорнути",
"session.todo.expand": "Розгорнути",
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
"session.question.progress": "{{current}} з {{total}} запитань",
"session.question.minimize": "Згорнути запитання",
"session.question.restore": "Відновити запитання",
@ -1047,8 +1043,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "Завантаження навички за назвою",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Оновлення списку завдань",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Отримання вмісту з URL",
"settings.permissions.tool.websearch.title": "Web Search",

View file

@ -630,9 +630,6 @@ export const dict = {
"session.messages.loading": "正在加载消息...",
"session.messages.jumpToLatest": "跳转到最新",
"session.context.addToContext": "将 {{selection}} 添加到上下文",
"session.todo.title": "待办事项",
"session.todo.collapse": "折叠",
"session.todo.expand": "展开",
"session.question.minimize": "最小化问题",
"session.question.restore": "恢复问题",
"session.question.pending.one": "{{count}} 个待处理问题",
@ -935,8 +932,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名称加载技能",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
"settings.permissions.tool.todowrite.title": "更新待办",
"settings.permissions.tool.todowrite.description": "更新待办列表",
"settings.permissions.tool.webfetch.title": "网页获取",
"settings.permissions.tool.webfetch.description": "从 URL 获取内容",
"settings.permissions.tool.websearch.title": "网页搜索",
@ -1001,7 +996,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "在此项目中跟踪、审查和撤消更改",
"session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...",
"session.review.noVcs.createGit.action": "创建 Git 仓库",
"session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)",
"session.question.progress": "{{current}}/{{total}} 个问题",
"session.header.open.finder": "访达",
"session.header.open.fileExplorer": "文件资源管理器",

View file

@ -626,9 +626,6 @@ export const dict = {
"session.messages.jumpToLatest": "跳到最新",
"session.context.addToContext": "將 {{selection}} 新增到上下文",
"session.todo.title": "待辦事項",
"session.todo.collapse": "折疊",
"session.todo.expand": "展開",
"session.question.minimize": "最小化問題",
"session.question.restore": "還原問題",
"session.question.pending.one": "{{count}} 個待處理問題",
@ -931,8 +928,6 @@ export const dict = {
"settings.permissions.tool.skill.description": "按名稱載入技能",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
"settings.permissions.tool.todowrite.title": "更新待辦",
"settings.permissions.tool.todowrite.description": "更新待辦清單",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "從 URL 取得內容",
"settings.permissions.tool.websearch.title": "Web Search",
@ -997,7 +992,6 @@ export const dict = {
"session.review.noVcs.createGit.description": "追蹤、檢閱及復原此專案中的變更",
"session.review.noVcs.createGit.actionLoading": "正在建立 Git 儲存庫...",
"session.review.noVcs.createGit.action": "建立 Git 儲存庫",
"session.todo.progress": "已完成 {{done}} 個待辦事項(共 {{total}} 個)",
"session.question.progress": "{{current}}/{{total}} 個問題",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "檔案總管",

View file

@ -565,8 +565,6 @@ export default function Page() {
})
let reviewFrame: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
@ -773,41 +771,6 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(
on(
() => {
const id = params.id
return [
sdk().directory,
id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync().data.todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk().directory !== dir || params.id !== id) return
untrack(() => {
void sync().session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
},
{ defer: true },
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@ -1882,8 +1845,6 @@ export default function Page() {
onCleanup(() => {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@ -1898,12 +1859,7 @@ export default function Page() {
sessionKey,
sessionID: () => params.id,
prompt,
ready: () => !store.deferRender && messagesReady(),
centered,
todo: {
collapsed: () => view().todoCollapsed.get(),
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
},
followup: () =>
params.id && !isChildSession()
? {

View file

@ -1,7 +1,4 @@
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
import type { PromptInputState } from "@/components/prompt-input"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
@ -26,12 +23,7 @@ export function createSessionComposerRegionController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
prompt: PromptInputState
ready: Accessor<boolean>
centered: Accessor<boolean>
todo: {
collapsed: Accessor<boolean>
onToggle: () => void
}
followup: Accessor<SessionComposerFollowupDock | undefined>
revert: Accessor<SessionComposerRevertDock | undefined>
onResponseSubmit: () => void
@ -40,41 +32,6 @@ export function createSessionComposerRegionController(input: {
setDockRef: (el: HTMLDivElement) => void
}) {
const sync = useSync()
const [store, setStore] = createStore({
ready: input.ready() || input.state.dock(),
height: 320,
body: undefined as HTMLDivElement | undefined,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) window.clearTimeout(timer)
if (frame !== undefined) cancelAnimationFrame(frame)
timer = undefined
frame = undefined
}
createEffect(() => {
input.sessionKey()
const ready = input.ready()
const dock = input.state.dock()
clear()
if (store.ready || (!ready && !dock)) return
if (dock) {
setStore("ready", true)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setStore("ready", true)
timer = undefined
}, 140)
})
})
createEffect(() => {
if (!input.prompt.ready()) return
@ -92,27 +49,10 @@ export function createSessionComposerRegionController(input: {
})
})
createEffect(() => {
const el = store.body
if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(el, update)
update()
})
onCleanup(clear)
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.get(id)?.parentID : undefined
})
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
{ visualDuration: 0.3, bounce: 0 },
() => `${input.sessionKey()}\0${store.ready}`,
)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const ready = Promise.resolve()
const [promptReady] = createResource(
() => input.prompt.ready.promise ?? ready,
@ -122,7 +62,6 @@ export function createSessionComposerRegionController(input: {
return {
state: input.state,
centered: input.centered,
todo: input.todo,
followup: input.followup,
revert: input.revert,
onResponseSubmit: input.onResponseSubmit,
@ -134,11 +73,7 @@ export function createSessionComposerRegionController(input: {
showComposer: () => !input.state.blocked() || !!parentID(),
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
promptReady: () => input.prompt.ready() || promptReady(),
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
dockProgress: value,
dockHeight: () => Math.max(78, store.height),
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
lift: () => (input.revert()?.items.length ? 18 : 0),
}
}

View file

@ -5,7 +5,6 @@ import { SessionPermissionDock } from "@/pages/session/composer/session-permissi
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
export function SessionComposerRegion(props: {
@ -60,28 +59,6 @@ export function SessionComposerRegion(props: {
</Show>
<Show when={controller.showComposer()}>
<Show when={controller.dock()}>
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": controller.dockProgress() < 0.98,
}}
style={{
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
}}
>
<div ref={controller.setDockBodyRef}>
<SessionTodoDock
todos={controller.state.todos()}
collapsed={controller.todo.collapsed()}
onToggle={controller.todo.onToggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={controller.dockProgress()}
/>
</div>
</div>
</Show>
<Show
when={controller.promptReady()}
fallback={
@ -98,10 +75,7 @@ export function SessionComposerRegion(props: {
</div>
)}
</Show>
<div
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{controller.handoffPrompt() || language.t("prompt.loading")}
</div>
</>
@ -109,11 +83,7 @@ export function SessionComposerRegion(props: {
>
<Show when={rolled()} keyed>
{(revert) => (
<div
style={{
"margin-top": `${-36 * controller.dockProgress()}px`,
}}
>
<div>
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}

View file

@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
@ -104,35 +103,3 @@ describe("sessionQuestionRequest", () => {
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
})
describe("todoState", () => {
test("hides when there are no todos", () => {
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
})
test("opens while the session is still working", () => {
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
})
test("closes completed todos after a running turn", () => {
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
})
test("clears stale todos when the turn ends", () => {
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
})
test("clears completed todos when the session is no longer live", () => {
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
})
})
describe("todoDockAtBoundary", () => {
test("shows active todos when entering a session", () => {
expect(todoDockAtBoundary("open")).toBe(true)
})
test("hides completed todos when entering a session", () => {
expect(todoDockAtBoundary("close")).toBe(false)
})
})

View file

@ -1,35 +1,20 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: {
count: number
done: boolean
live: boolean
}): "hide" | "clear" | "open" | "close" => {
if (input.count === 0) return "hide"
if (!input.live) return "clear"
if (!input.done) return "open"
return "close"
}
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
const idle = { type: "idle" as const }
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
export function createSessionComposerController() {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const language = useLanguage()
const permission = usePermission()
@ -49,24 +34,8 @@ export function createSessionComposerController(options?: { closeMs?: number | (
return !!permissionRequest() || !!questionRequest()
})
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return serverSync().session.data.todo[id] ?? []
})
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const [store, setStore] = createStore({
sessionID: params.id,
responding: undefined as string | undefined,
dock: todos().length > 0 && !done() && live(),
closing: false,
opening: false,
})
const permissionResponding = createMemo(() => {
@ -92,112 +61,12 @@ export function createSessionComposerController(options?: { closeMs?: number | (
})
}
let timer: number | undefined
let raf: number | undefined
const closeMs = () => {
const value = options?.closeMs
if (typeof value === "function") return Math.max(0, value())
if (typeof value === "number") return Math.max(0, value)
return 400
}
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, closeMs())
}
// Keep stale turn todos from reopening if the model never clears them.
const clear = () => {
const id = params.id
if (!id) return
sync().set("todo", id, [])
}
createEffect(
on(
() => [params.id, todos().length, done(), live()] as const,
([id, count, complete, active], previous) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
const next = todoState({
count,
done: complete,
live: active,
})
if (!previous || previous[0] !== id) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
if (next === "clear") clear()
return
}
if (next === "hide") {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ dock: false, closing: false, opening: false })
return
}
if (next === "clear") {
if (timer) window.clearTimeout(timer)
timer = undefined
clear()
return
}
if (next === "open") {
if (timer) window.clearTimeout(timer)
timer = undefined
const hidden = !store.dock || store.closing
setStore({ dock: true, closing: false })
if (hidden) {
setStore("opening", true)
raf = requestAnimationFrame(() => {
setStore("opening", false)
raf = undefined
})
return
}
setStore("opening", false)
return
}
setStore({ dock: true, opening: false, closing: true })
if (!timer) scheduleClose()
},
),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
onCleanup(() => {
if (!raf) return
cancelAnimationFrame(raf)
})
return {
blocked,
questionRequest,
permissionRequest,
permissionResponding,
decide,
todos,
dock: () =>
store.sessionID === params.id
? store.dock
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
closing: () => store.sessionID === params.id && store.closing,
opening: () => store.sessionID === params.id && store.opening,
}
}

View file

@ -1,256 +0,0 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
const doneToken = "\u0000done\u0000"
const totalToken = "\u0000total\u0000"
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
class="block"
>
<circle
cx="6"
cy="6"
r="3"
style={{
animation: "var(--animate-pulse-scale)",
"transform-origin": "center",
"transform-box": "fill-box",
}}
/>
</svg>
)
}
export function SessionTodoDock(props: {
todos: Todo[]
collapsed: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
dockProgress: number
}) {
const language = useLanguage()
const [store, setStore] = createStore({
height: 78,
})
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() }))
const progress = createMemo(() =>
language
.t("session.todo.progress", { done: doneToken, total: totalToken })
.split(/(\u0000done\u0000|\u0000total\u0000)/),
)
const active = createMemo(
() =>
props.todos.find((todo) => todo.status === "in_progress") ??
props.todos.find((todo) => todo.status === "pending") ??
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
props.todos[0],
)
const preview = createMemo(() => active()?.content ?? "")
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98)
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
const full = createMemo(() => Math.max(78, store.height))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setStore("height", (height) => Math.max(height, el.scrollHeight))
}
update()
createResizeObserver(el, update)
})
return (
<DockTray
data-component="session-todo-dock"
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
}}
>
<div ref={contentRef}>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
role="button"
tabIndex={0}
onClick={props.onToggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
}}
>
<span
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": "600ms",
"--tool-motion-mask": "18%",
"--tool-motion-mask-height": "0px",
"--tool-motion-spring-ms": "560ms",
"white-space": "pre",
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
}}
>
<Index each={progress()}>
{(item) =>
item() === doneToken ? (
<AnimatedNumber value={done()} />
) : item() === totalToken ? (
<AnimatedNumber value={total()} />
) : (
<span>{item()}</span>
)
}
</Index>
</span>
<div
data-slot="session-todo-preview"
class="ml-1 min-w-0 overflow-hidden"
style={{
flex: "1 1 auto",
"max-width": "100%",
}}
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={props.collapsed ? preview() : undefined}
duration={600}
travel={25}
edge={17}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={props.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
props.onToggle()
}}
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div
data-slot="session-todo-list"
aria-hidden={props.collapsed || off()}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
}}
>
<TodoList todos={props.todos} />
</div>
</div>
</DockTray>
)
}
function TodoList(props: { todos: Todo[] }) {
const [store, setStore] = createStore({
stuck: false,
})
return (
<div class="relative">
<div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
style={{ "overflow-anchor": "none" }}
onScroll={(e) => {
setStore("stuck", e.currentTarget.scrollTop > 0)
}}
>
<Index each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo().status === "completed"}
indeterminate={todo().status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined}
data-state={todo().status}
icon={dot(todo().status)}
style={{
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.94" : "1",
}}
>
<TextStrikethrough
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
class="text-14-regular min-w-0 break-words"
style={{
"line-height": "var(--line-height-normal)",
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
opacity: todo().status === "pending" ? "0.92" : "1",
}}
/>
</Checkbox>
)}
</Index>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
style={{
background: "linear-gradient(to bottom, var(--background-base), transparent)",
opacity: store.stuck ? 1 : 0,
}}
/>
</div>
)
}

View file

@ -1,623 +0,0 @@
// @ts-nocheck
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { useServerSync } from "@/context/global-sync"
import { PromptInput } from "@/components/prompt-input"
import { usePrompt } from "@/context/prompt"
import {
SessionComposerRegion,
createSessionComposerController,
createSessionComposerRegionController,
} from "@/pages/session/composer"
export default {
title: "UI/Todo Panel Motion",
id: "components-todo-panel-motion",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
This playground renders the real session composer region from app code.
### Source path
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
### Includes
- \`SessionTodoDock\` (real)
- \`PromptInput\` (real)
No visual reimplementation layer is used for the dock/input stack.`,
},
},
},
}
const pool = [
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
"Run typechecks/tests and report what changed",
"Verify reduced-motion behavior in timeline",
"Review diff for animation edge cases",
"Document rollout notes in PR description",
"Check keyboard and screen reader semantics",
"Add storybook controls for iteration speed",
]
const btn = (accent?: boolean) =>
({
padding: "6px 14px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #333)",
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
"font-size": "13px",
}) as const
const controls = {
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const css = `
[data-component="todo-stage"] {
display: grid;
gap: 20px;
padding: 20px;
}
[data-component="todo-preview"] {
height: 560px;
min-height: 0;
}
[data-component="todo-session-root"] {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--background-base);
border: 1px solid var(--border-weak-base);
border-radius: 12px;
}
[data-component="todo-session-frame"] {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
[data-component="todo-session-panel"] {
position: relative;
flex: 1 1 auto;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-stronger);
}
[data-slot="todo-preview-content"] {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
[data-slot="todo-preview-scroll"] {
height: 100%;
overflow: auto;
min-height: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
[data-slot="todo-preview-spacer"] {
flex: 1 1 auto;
min-height: 0;
}
[data-slot="todo-preview-msg"] {
border-radius: 8px;
border: 1px solid var(--border-weak-base);
background: var(--surface-base);
color: var(--text-weak);
padding: 8px 10px;
font-size: 13px;
line-height: 1.35;
}
[data-slot="todo-preview-msg"][data-strong="true"] {
color: var(--text-strong);
}
`
export const Playground = {
render: () => {
const global = useServerSync()
const prompt = usePrompt()
const [cfg, setCfg] = createStore({
open: true,
collapsed: false,
step: 1,
dockOpenDuration: 0.3,
dockOpenBounce: 0,
dockCloseDuration: 0.3,
dockCloseBounce: 0,
drawerExpandDuration: 0.3,
drawerExpandBounce: 0,
drawerCollapseDuration: 0.3,
drawerCollapseBounce: 0,
subtitleDuration: 600,
subtitleAuto: true,
subtitleTravel: 25,
subtitleEdge: 17,
countDuration: 600,
countMask: 18,
countMaskHeight: 0,
countWidthDuration: 560,
})
const open = () => cfg.open
const step = () => cfg.step
const dockOpenDuration = () => cfg.dockOpenDuration
const dockOpenBounce = () => cfg.dockOpenBounce
const dockCloseDuration = () => cfg.dockCloseDuration
const dockCloseBounce = () => cfg.dockCloseBounce
const drawerExpandDuration = () => cfg.drawerExpandDuration
const drawerExpandBounce = () => cfg.drawerExpandBounce
const drawerCollapseDuration = () => cfg.drawerCollapseDuration
const drawerCollapseBounce = () => cfg.drawerCollapseBounce
const subtitleDuration = () => cfg.subtitleDuration
const subtitleAuto = () => cfg.subtitleAuto
const subtitleTravel = () => cfg.subtitleTravel
const subtitleEdge = () => cfg.subtitleEdge
const countDuration = () => cfg.countDuration
const countMask = () => cfg.countMask
const countMaskHeight = () => cfg.countMaskHeight
const countWidthDuration = () => cfg.countWidthDuration
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
let frame
let scrollRef
const todos = createMemo<Todo[]>(() => {
const done = Math.max(0, Math.min(3, step()))
return pool.slice(0, 3).map((content, i) => ({
id: `todo-${i + 1}`,
content,
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
}))
})
createEffect(() => {
global.todo.set("story-session", todos())
})
const clear = () => {
if (frame) cancelAnimationFrame(frame)
frame = undefined
}
const pin = () => {
if (!scrollRef) return
scrollRef.scrollTop = scrollRef.scrollHeight
}
const collapsed = () => cfg.collapsed
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
const openDock = () => {
clear()
setCfg("open", true)
frame = requestAnimationFrame(() => {
pin()
frame = undefined
})
}
const closeDock = () => {
clear()
setCfg("open", false)
}
const dockOpen = () => open()
const toggleDock = () => {
if (dockOpen()) {
closeDock()
return
}
openDock()
}
const toggleDrawer = () => {
if (!dockOpen()) {
openDock()
frame = requestAnimationFrame(() => {
pin()
setCollapsed(true)
frame = undefined
})
return
}
setCollapsed(!collapsed())
}
const cycle = () => {
setCfg("step", (value) => (value + 1) % 4)
}
onCleanup(clear)
return (
<div data-component="todo-stage">
<style>{css}</style>
<div data-component="todo-preview">
<div data-component="todo-session-root">
<div data-component="todo-session-frame">
<div data-component="todo-session-panel">
<div data-slot="todo-preview-content">
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
<div data-slot="todo-preview-spacer" />
<div data-slot="todo-preview-msg" data-strong="true">
Thinking Checking type safety
</div>
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
</div>
</div>
<div>
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt,
ready: () => true,
centered: () => false,
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: pin,
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={controls}
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
</div>
</div>
</div>
</div>
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={toggleDock} style={btn(dockOpen())}>
{dockOpen() ? "Animate close" : "Animate open"}
</button>
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
</button>
<button onClick={cycle} style={btn(step() > 0)}>
Cycle progress ({step()}/3 done)
</button>
{[0, 1, 2, 3].map((value) => (
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
{value} done
</button>
))}
</div>
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={dockOpenDuration()}
onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(dockOpenDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={dockOpenBounce()}
onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{dockOpenBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Dock close
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={dockCloseDuration()}
onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(dockCloseDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={dockCloseBounce()}
onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{dockCloseBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Drawer expand
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={drawerExpandDuration()}
onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(drawerExpandDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={drawerExpandBounce()}
onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{drawerExpandBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Drawer collapse
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={drawerCollapseDuration()}
onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(drawerCollapseDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={drawerCollapseBounce()}
onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{drawerCollapseBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Subtitle odometer
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="120"
max="1400"
step="10"
value={subtitleDuration()}
onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(subtitleDuration())}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
auto fit
</span>
<input
type="checkbox"
checked={subtitleAuto()}
onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{subtitleAuto() ? "on" : "off"}
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
travel
</span>
<input
type="range"
min="0"
max="40"
step="1"
value={subtitleTravel()}
onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
edge
</span>
<input
type="range"
min="1"
max="40"
step="1"
value={subtitleEdge()}
onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Count odometer
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="120"
max="1400"
step="10"
value={countDuration()}
onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(countDuration())}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
mask
</span>
<input
type="range"
min="4"
max="40"
step="1"
value={countMask()}
onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
mask height
</span>
<input
type="range"
min="0"
max="14"
step="1"
value={countMaskHeight()}
onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
width spring
</span>
<input
type="range"
min="0"
max="1200"
step="10"
value={countWidthDuration()}
onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(countWidthDuration())}ms
</span>
</label>
</div>
</div>
)
},
}

View file

@ -10,7 +10,7 @@
// /permission [kind] → triggers a permission request variant
// /question [kind] → triggers a question request variant
// /fmt <kind> → emits a specific tool/text type (text, reasoning, bash,
// write, edit, patch, task, todo, question, error, mix)
// write, edit, patch, task, question, error, mix)
//
// Demo mode also handles permission and question replies locally, completing
// or failing the synthetic tool parts as appropriate.
@ -30,7 +30,6 @@ const KINDS = [
"edit",
"patch",
"task",
"todo",
"question",
"error",
"mix",
@ -733,30 +732,6 @@ function emitTask(state: State): void {
})
}
function emitTodo(state: State): void {
const ref = make(state, "todowrite", {
todos: [
{
content: "Trigger permission UI",
status: "completed",
},
{
content: "Trigger question UI",
status: "in_progress",
},
{
content: "Tune tool formatting",
status: "pending",
},
],
})
doneTool(state, ref, {
title: "todowrite",
output: "",
metadata: {},
})
}
function emitQuestionTool(state: State): void {
const ref = make(state, "question", {
questions: [
@ -952,7 +927,6 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
options: [
{ label: "Diff", description: "Show an edit diff in the footer" },
{ label: "Task", description: "Show a structured task summary" },
{ label: "Todo", description: "Show a todo snapshot" },
{ label: "Error", description: "Show an error transcript row" },
],
multiple: true,
@ -992,7 +966,6 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
options: [
{ label: "Diff", description: "Emit edit diff" },
{ label: "Task", description: "Emit task card" },
{ label: "Todo", description: "Emit todo card" },
],
multiple: true,
custom: true,
@ -1066,11 +1039,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
return true
}
if (kind === "todo") {
emitTodo(state)
return true
}
if (kind === "question") {
emitQuestionTool(state)
return true
@ -1091,7 +1059,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
emitEdit(state)
emitPatch(state)
emitTask(state)
emitTodo(state)
emitQuestionTool(state)
emitError(state, "demo mixed scenario error")
return true

View file

@ -7,26 +7,6 @@ import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
function todoText(item: { status: string; content: string }): string {
if (item.status === "completed") {
return `[✓] ${item.content}`
}
if (item.status === "cancelled") {
return `~[ ] ${item.content}~`
}
if (item.status === "in_progress") {
return `[•] ${item.content}`
}
return `[ ] ${item.content}`
}
function todoColor(theme: RunTheme, status: string) {
return status === "in_progress" ? theme.block.warning : theme.block.muted
}
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
return undefined
@ -137,10 +117,6 @@ export function RunEntryContent(props: {
const next = structured()
return next?.kind === "task" ? next : undefined
})
const todo_snapshot = createMemo(() => {
const next = structured()
return next?.kind === "todo" ? next : undefined
})
const question_snapshot = createMemo(() => {
const next = structured()
return next?.kind === "question" ? next : undefined
@ -242,25 +218,6 @@ export function RunEntryContent(props: {
</box>
</box>
</Match>
<Match when={todo_snapshot()}>
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
# Todos
</text>
<box width="100%" flexDirection="column" gap={0}>
{todo_snapshot()!.items.map((item) => (
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
{todoText(item)}
</text>
))}
{todo_snapshot()!.tail ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
{todo_snapshot()!.tail}
</text>
) : null}
</box>
</box>
</Match>
<Match when={question_snapshot()}>
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>

View file

@ -55,7 +55,6 @@ type ToolInput = ToolDict & {
content?: string
command?: string
workdir?: string
todos?: Array<{ status?: string; content?: string }>
questions?: Array<{ question?: string }>
diff?: string
}
@ -122,7 +121,6 @@ type ToolName =
| "patch"
| "batch"
| "task"
| "todowrite"
| "question"
| "read"
| "glob"
@ -398,25 +396,6 @@ function runTask(p: ToolProps): ToolInline {
}
}
function runTodo(p: ToolProps): ToolInline {
return {
icon: "#",
title: "Todos",
mode: "block",
body: list<{ status?: string; content?: string }>(p.frame.input.todos)
.flatMap((item) => {
const body = typeof item?.content === "string" ? item.content : ""
if (!body) {
return []
}
const mark = item.status === "completed" ? "[✓]" : item.status === "in_progress" ? "[•]" : "[ ]"
return [`${mark} ${body}`]
})
.join("\n"),
}
}
function runSkill(p: ToolProps): ToolInline {
return {
icon: "→",
@ -604,28 +583,6 @@ function snapTask(p: ToolProps): ToolSnapshot {
}
}
function snapTodo(p: ToolProps): ToolSnapshot {
const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
const content = typeof item?.content === "string" ? item.content : ""
if (!content) {
return []
}
return [
{
status: typeof item.status === "string" ? item.status : "",
content,
},
]
})
return {
kind: "todo",
items,
tail: "",
}
}
function snapQuestion(p: ToolProps): ToolSnapshot {
const answers = list<unknown[]>(p.frame.meta.answers)
const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
@ -814,42 +771,6 @@ function scrollTaskFinal(p: ToolProps): string {
return `# ${kind} Task\n${row}`
}
function scrollTodoStart(_: ToolProps): string {
return ""
}
function scrollTodoFinal(p: ToolProps): string {
const items = list<{ status?: string }>(p.input.todos)
const time = span(p.frame.state)
if (items.length === 0) {
if (!time) {
return "0 todos"
}
return `0 todos · ${time}`
}
const doneN = items.filter((item) => item.status === "completed").length
const runN = items.filter((item) => item.status === "in_progress").length
const left = items.length - doneN - runN
const tail = [`${items.length} total`]
if (doneN > 0) {
tail.push(`${doneN} done`)
}
if (runN > 0) {
tail.push(`${runN} active`)
}
if (left > 0) {
tail.push(`${left} pending`)
}
if (time) {
tail.push(time)
}
return tail.join(" · ")
}
function scrollQuestionStart(_: ToolProps): string {
return ""
}
@ -1131,19 +1052,6 @@ const TOOL_RULES = {
},
permission: permTask,
},
todowrite: {
view: {
output: false,
final: true,
snap: "structured",
},
run: runTodo,
snap: snapTodo,
scroll: {
start: scrollTodoStart,
final: scrollTodoFinal,
},
},
question: {
view: {
output: false,

View file

@ -199,15 +199,6 @@ export type ToolTaskSnapshot = {
tail: string
}
export type ToolTodoSnapshot = {
kind: "todo"
items: Array<{
status: string
content: string
}>
tail: string
}
export type ToolQuestionSnapshot = {
kind: "question"
items: Array<{
@ -217,12 +208,7 @@ export type ToolQuestionSnapshot = {
tail: string
}
export type ToolSnapshot =
| ToolCodeSnapshot
| ToolDiffSnapshot
| ToolTaskSnapshot
| ToolTodoSnapshot
| ToolQuestionSnapshot
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
export type EntryLayout = "inline" | "block"

View file

@ -5583,17 +5583,6 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string; readonly sessionID: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "todo.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly todos: ReadonlyArray<{ readonly content: string; readonly status: string; readonly priority: string }>
}
}
| {
readonly id: string
readonly created: number

View file

@ -75,6 +75,62 @@
"summary": "Check server health"
}
},
"/api/server": {
"get": {
"tags": [
"server"
],
"operationId": "v2.server.get",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"urls": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"urls"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Return the URLs that can be used to connect to this server.",
"summary": "Get server information"
}
},
"/api/location": {
"get": {
"tags": [
@ -18676,6 +18732,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"fields"
],
@ -18714,6 +18771,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"url"
],
@ -18791,6 +18849,7 @@
}
},
"required": [
"title",
"mode"
],
"additionalProperties": false
@ -24584,6 +24643,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"fields"
],
@ -24622,6 +24682,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"url"
],
@ -24844,88 +24905,6 @@
],
"additionalProperties": false
},
"Todo": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Brief description of the task"
},
"status": {
"type": "string",
"description": "Current status of the task: pending, in_progress, completed, cancelled"
},
"priority": {
"type": "string",
"description": "Priority level of the task: high, medium, low"
}
},
"required": [
"content",
"status",
"priority"
],
"additionalProperties": false
},
"todo.updated": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": [
"todo.updated"
]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"todos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Todo"
}
}
},
"required": [
"sessionID",
"todos"
],
"additionalProperties": false
}
},
"required": [
"id",
"created",
"type",
"data"
],
"additionalProperties": false
},
"SessionStatus": {
"anyOf": [
{
@ -26475,9 +26454,6 @@
{
"$ref": "#/components/schemas/form.cancelled"
},
{
"$ref": "#/components/schemas/todo.updated"
},
{
"$ref": "#/components/schemas/session.status"
},
@ -27072,6 +27048,9 @@
{
"name": "health"
},
{
"name": "server"
},
{
"name": "location"
},

View file

@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "beb91b32-23e2-405f-99b8-1e8763db94f8",
"id": "8c1748cc-f978-4df4-879d-fe7fda5c4c34",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
"beb91b32-23e2-405f-99b8-1e8763db94f8"
],
"ddl": [
{
@ -78,10 +78,6 @@
"name": "session",
"entityType": "tables"
},
{
"name": "todo",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables"
@ -1446,76 +1442,6 @@
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "priority",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "position",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": false,
@ -1756,21 +1682,6 @@
"entityType": "fks",
"table": "session"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_todo_session_id_session_id_fk",
"entityType": "fks",
"table": "todo"
},
{
"columns": [
"session_id"
@ -1816,16 +1727,6 @@
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"session_id",
"position"
],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": [
"id"
@ -2279,20 +2180,6 @@
"name": "session_parent_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "todo_session_idx",
"entityType": "indexes",
"table": "todo"
}
],
"renames": []

View file

@ -50,5 +50,6 @@ export const migrations = (
import("./migration/20260707010146_durable_session_inbox"),
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
import("./migration/20260709013000_generic_session_input"),
import("./migration/20260709025533_drop-todo"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,12 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260709025533_drop-todo",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`todo_session_idx\`;`)
yield* tx.run(`DROP TABLE \`todo\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -227,19 +227,6 @@ export default {
CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`todo\` (
\`session_id\` text NOT NULL,
\`content\` text NOT NULL,
\`status\` text NOT NULL,
\`priority\` text NOT NULL,
\`position\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`),
CONSTRAINT \`fk_todo_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_share\` (
\`session_id\` text PRIMARY KEY,
@ -286,7 +273,6 @@ export default {
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`)
})
},
} satisfies Omit<DatabaseMigration.Migration, "id">

View file

@ -32,7 +32,6 @@ import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction"
import { SessionTitle } from "./session/title"
import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { Snapshot } from "./snapshot"
@ -78,7 +77,6 @@ const locationServiceNodes = [
Image.node,
SkillGuidance.node,
ReferenceGuidance.node,
SessionTodo.node,
InstructionEntry.node,
Form.node,
QuestionV2.node,

View file

@ -161,12 +161,7 @@ export const Plugin = define({
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(
...PermissionV2.merge(defaults, [
{ action: "subagent", resource: "*", effect: "deny" },
{ action: "todowrite", resource: "*", effect: "deny" },
]),
)
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
})
draft.update(AgentV2.ID.make("explore"), (item) => {

View file

@ -28,7 +28,6 @@ import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { PatchTool } from "../tool/patch"
@ -41,7 +40,6 @@ import { ReadTool } from "../tool/read"
import { ShellTool } from "../tool/shell"
import { SkillTool } from "../tool/skill"
import { SubagentTool } from "../tool/subagent"
import { TodoWriteTool } from "../tool/todowrite"
import { Tools } from "../tool/tools"
import { WebFetchTool } from "../tool/webfetch"
import { WebSearchTool } from "../tool/websearch"
@ -78,7 +76,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const reference = yield* Reference.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const todo = yield* SessionTodo.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
@ -107,7 +104,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Reference.Service, reference),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(SessionTodo.Service, todo),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
@ -136,7 +132,6 @@ const pre = [
ShellTool.Plugin,
SkillTool.Plugin,
SubagentTool.Plugin,
TodoWriteTool.Plugin,
WebFetchTool.Plugin,
WebSearchTool.Plugin,
WriteTool.Plugin,

View file

@ -30,7 +30,6 @@ import { PluginPromise } from "../plugin/promise"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem"
@ -368,7 +367,6 @@ export const node = makeLocationNode({
Reference.node,
Ripgrep.node,
SessionInstructions.node,
SessionTodo.node,
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,

View file

@ -20,58 +20,8 @@ When the user directly asks about OpenCode (eg. "can OpenCode do...", "does Open
# Professional objectivity
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
# Task Management
You have access to the todowrite tool to help you manage and plan tasks. Use it VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
This tool is also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the todowrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using the shell tool.
Looks like I found 10 type errors. I'm going to use the todowrite tool to write 10 items to the todo list.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the todowrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
- Use the todowrite tool to plan the task if required
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
@ -93,8 +43,6 @@ user: What is the codebase structure?
assistant: [Uses the subagent tool]
</example>
IMPORTANT: Always use the todowrite tool to plan and track tasks throughout the conversation.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.

View file

@ -6,7 +6,7 @@ You MUST iterate and keep going until the problem is solved.
You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
Only terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH.
@ -19,13 +19,13 @@ understanding of third party packages and dependencies is up to date. You must u
Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
If the user request is "resume" or "continue" or "try again", check the previous conversation history to identify the next incomplete step. Continue from that step, and do not hand back control to the user until the request is complete. Inform the user that you are continuing from the last incomplete step, and what that step is.
Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
You MUST keep working until the problem is completely solved. Do not end your turn until you have completed the necessary work and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
@ -39,7 +39,7 @@ You are a highly capable and autonomous agent, and you can definitely solve this
- What are the dependencies and interactions with other parts of the code?
3. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
4. Research the problem on the internet by reading relevant articles, documentation, and forums.
5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using emoji's to indicate the status of each item.
5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps.
6. Implement the fix incrementally. Make small, testable code changes.
7. Debug as needed. Use debugging techniques to isolate and resolve issues.
8. Test frequently. Run tests after each change to verify correctness.
@ -73,10 +73,7 @@ Carefully read the issue and think hard about a plan to solve it before coding.
## 5. Develop a Detailed Plan
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
- Create a todo list in markdown format to track your progress.
- Each time you complete a step, check it off using `[x]` syntax.
- Each time you check off a step, display the updated todo list to the user.
- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next.
- Continue through the planned steps instead of ending your turn and asking the user what they want to do next.
## 6. Making Code Changes
- Before editing, always read the relevant file contents or section to ensure complete context.
@ -139,8 +136,6 @@ If you are asked to write a prompt, you should always generate the prompt in ma
If you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat.
Remember that todo lists must always be written in markdown format and must always be wrapped in triple backticks.
# Git
If the user tells you to stage and commit, you may do so.

View file

@ -101,25 +101,6 @@ export const PartTable = sqliteTable(
],
)
export const TodoTable = sqliteTable(
"todo",
{
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
content: text().notNull(),
status: text().notNull(),
priority: text().notNull(),
position: integer().notNull(),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.session_id, table.position] }),
index("todo_session_idx").on(table.session_id),
],
)
export const SessionMessageTable = sqliteTable(
"session_message",
{

View file

@ -1,78 +0,0 @@
export * as SessionTodo from "./todo"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { SessionTodo } from "@opencode-ai/schema/session-todo"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { SessionSchema } from "./schema"
import { TodoTable } from "./sql"
export const Info = SessionTodo.Info
export type Info = typeof Info.Type
export const Event = SessionTodo.Event
export interface Interface {
readonly update: (input: {
readonly sessionID: SessionSchema.ID
readonly todos: ReadonlyArray<Info>
}) => Effect.Effect<void>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionTodo") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const update = Effect.fn("SessionTodo.update")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly todos: ReadonlyArray<Info>
}) {
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
yield* tx
.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
.pipe(Effect.orDie)
yield* events.publish(Event.Updated, input)
})
const get = Effect.fn("SessionTodo.get")(function* (sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
})
return Service.of({ update, get })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] })

View file

@ -1,57 +0,0 @@
export * as TodoWriteTool from "./todowrite"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo"
import { Tool } from "./tool"
export const name = "todowrite"
export const Input = Schema.Struct({
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
})
export const Output = Schema.Struct({
todos: Schema.Array(SessionTodo.Info),
})
export type Output = typeof Output.Type
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
export const Plugin = {
id: "opencode.tool.todowrite",
effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) {
const todos = yield* SessionTodo.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
description:
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: ["*"],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
return { todos: input.todos }
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))),
}),
),
)
.pipe(Effect.orDie)
}),
}

View file

@ -24,7 +24,6 @@ const InputObject = Schema.StructWithRest(
bash: Schema.optional(Rule),
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),

File diff suppressed because one or more lines are too long

View file

@ -526,7 +526,6 @@ describe("LocationServiceMap", () => {
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",
@ -559,7 +558,6 @@ describe("LocationServiceMap", () => {
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",
@ -577,7 +575,6 @@ describe("LocationServiceMap", () => {
"shell",
"skill",
"subagent",
"todowrite",
"webfetch",
"websearch",
"write",

View file

@ -182,12 +182,12 @@ describe("PermissionV2", () => {
const agents = yield* AgentV2.Service
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }]
agent.permissions = [{ action: "custom", resource: "*", effect: "allow" }]
}),
)
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion({ action: "todowrite", resources: ["*"] }))).toEqual({
expect(yield* service.ask(assertion({ action: "custom", resources: ["*"] }))).toEqual({
id: PermissionV2.ID.create("per_test"),
effect: "allow",
})

View file

@ -1,94 +0,0 @@
import { describe, expect } from "bun:test"
import { asc } from "drizzle-orm"
import { Effect } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionTodo.node])))
const sessionID = SessionV2.ID.make("ses_todo_test")
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "todo",
directory: "/project",
title: "todo",
version: "test",
})
.run()
.pipe(Effect.orDie)
})
describe("SessionTodo", () => {
it.effect("replaces persisted todos in order and publishes updates", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const todos = yield* SessionTodo.Service
const published = new Array<EventV2.Payload>()
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === SessionTodo.Event.Updated.type) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* todos.update({
sessionID,
todos: [
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
],
})
expect(yield* todos.get(sessionID)).toEqual([
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
])
expect(
(yield* db.select().from(TodoTable).orderBy(asc(TodoTable.position)).all().pipe(Effect.orDie)).map((row) => ({
content: row.content,
position: row.position,
})),
).toEqual([
{ content: "second", position: 0 },
{ content: "first", position: 1 },
])
yield* todos.update({ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] })
expect(yield* todos.get(sessionID)).toEqual([{ content: "replacement", status: "completed", priority: "medium" }])
yield* todos.update({ sessionID, todos: [] })
expect(yield* todos.get(sessionID)).toEqual([])
expect(published.map((event) => event.data)).toEqual([
{
sessionID,
todos: [
{ content: "second", status: "pending", priority: "low" },
{ content: "first", status: "in_progress", priority: "high" },
],
},
{ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] },
{ sessionID, todos: [] },
])
}),
)
})

View file

@ -24,7 +24,6 @@ import { LLM } from "@opencode-ai/schema/llm"
import { Permission } from "@opencode-ai/schema/permission"
import { Pty } from "@opencode-ai/schema/pty"
import { Reference } from "@opencode-ai/schema/reference"
import { SessionTodo } from "@opencode-ai/schema/session-todo"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -46,7 +45,6 @@ test("Core reuses the canonical shared schemas", async () => {
coreReference,
coreSessionInput,
coreSessionMessage,
coreSessionTodo,
coreSkill,
coreV2Schema,
coreSchema,
@ -67,7 +65,6 @@ test("Core reuses the canonical shared schemas", async () => {
import("@opencode-ai/core/reference"),
import("@opencode-ai/core/session/input"),
import("@opencode-ai/core/session/message"),
import("@opencode-ai/core/session/todo"),
import("@opencode-ai/core/skill"),
import("@opencode-ai/core/v2-schema"),
import("@opencode-ai/core/schema"),
@ -160,8 +157,6 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionMessage.Assistant, SessionMessage.Assistant],
[coreSessionMessage.Compaction, SessionMessage.Compaction],
[coreSessionMessage.Info, SessionMessage.Info],
[coreSessionTodo.Info, SessionTodo.Info],
[coreSessionTodo.Event, SessionTodo.Event],
[coreSkill.DirectorySource, Skill.DirectorySource],
[coreSkill.UrlSource, Skill.UrlSource],
[coreSkill.EmbeddedSource, Skill.EmbeddedSource],

View file

@ -1,142 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const todoWriteToolNode = makeLocationNode({
name: "test/todowrite-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(TodoWriteTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, SessionTodo.node],
})
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let deny = false
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionTodo.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
todoWriteToolNode,
]),
[
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
)
const setup = Effect.gen(function* () {
assertions.length = 0
deny = false
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "todowrite",
directory: "/project",
title: "todowrite",
version: "test",
})
.run()
.pipe(Effect.orDie)
})
const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } },
})
describe("TodoWriteTool", () => {
it.effect("registers, approves the wildcard resource, persists todos, and returns typed output", () =>
Effect.gen(function* () {
yield* setup
const registry = yield* ToolRegistry.Service
const service = yield* SessionTodo.Service
const todoList: ReadonlyArray<SessionTodo.Info> = [
{ content: "Implement slice", status: "in_progress", priority: "high" },
]
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* settleTool(registry, call(todoList))).toEqual({
result: { type: "text", value: JSON.stringify(todoList, null, 2) },
output: {
structured: { todos: todoList },
content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }],
},
})
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(yield* service.get(sessionID)).toEqual(todoList)
}),
)
it.effect("does not update persisted todos when permission is denied", () =>
Effect.gen(function* () {
yield* setup
const registry = yield* ToolRegistry.Service
const service = yield* SessionTodo.Service
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
deny = true
expect(
yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])),
).toEqual({
type: "error",
value: "Unable to update todos",
})
expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }])
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
}),
)
})

View file

@ -75,6 +75,62 @@
"summary": "Check server health"
}
},
"/api/server": {
"get": {
"tags": [
"server"
],
"operationId": "v2.server.get",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"urls": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"urls"
],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Return the URLs that can be used to connect to this server.",
"summary": "Get server information"
}
},
"/api/location": {
"get": {
"tags": [
@ -18676,6 +18732,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"fields"
],
@ -18714,6 +18771,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"url"
],
@ -18791,6 +18849,7 @@
}
},
"required": [
"title",
"mode"
],
"additionalProperties": false
@ -24584,6 +24643,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"fields"
],
@ -24622,6 +24682,7 @@
"required": [
"id",
"sessionID",
"title",
"mode",
"url"
],
@ -24844,88 +24905,6 @@
],
"additionalProperties": false
},
"Todo": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Brief description of the task"
},
"status": {
"type": "string",
"description": "Current status of the task: pending, in_progress, completed, cancelled"
},
"priority": {
"type": "string",
"description": "Priority level of the task: high, medium, low"
}
},
"required": [
"content",
"status",
"priority"
],
"additionalProperties": false
},
"todo.updated": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": [
"todo.updated"
]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"todos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Todo"
}
}
},
"required": [
"sessionID",
"todos"
],
"additionalProperties": false
}
},
"required": [
"id",
"created",
"type",
"data"
],
"additionalProperties": false
},
"SessionStatus": {
"anyOf": [
{
@ -26475,9 +26454,6 @@
{
"$ref": "#/components/schemas/form.cancelled"
},
{
"$ref": "#/components/schemas/todo.updated"
},
{
"$ref": "#/components/schemas/session.status"
},
@ -27072,6 +27048,9 @@
{
"name": "health"
},
{
"name": "server"
},
{
"name": "location"
},

View file

@ -50,7 +50,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `read.ts`
- [x] `skill.ts`
- [x] `task.ts`
- [x] `todo.ts`
- [x] `webfetch.ts`
- [x] `websearch.ts`
- [x] `write.ts`

View file

@ -381,7 +381,6 @@ Mode pushes are automatically tracked by the plugin runtime. If a plugin is disa
- `vcs?.branch`
- `session.count()`
- `session.diff(sessionID)`
- `session.todo(sessionID)`
- `session.messages(sessionID)`
- `session.status(sessionID)`
- `session.permission(sessionID)`
@ -511,12 +510,11 @@ Metadata is persisted by plugin id.
- `internal:sidebar-context`
- `internal:sidebar-mcp`
- `internal:sidebar-lsp`
- `internal:sidebar-todo`
- `internal:sidebar-files`
- `internal:sidebar-footer`
- `internal:plugin-manager`
Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, todo `400`, files `500`.
Sidebar content order is currently: context `100`, mcp `200`, lsp `300`, files `500`.
The plugin manager is exposed as a command with title `Plugins` and value `plugins.list`.

View file

@ -182,13 +182,7 @@ const layer = Layer.effect(
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todowrite: "deny",
}),
user,
),
permission: Permission.merge(defaults, user),
options: {},
mode: "subagent",
native: true,

View file

@ -8,20 +8,18 @@ import type { Agent } from "./agent"
* 1. The parent session's deny rules and external_directory rules.
* Parent agent restrictions only govern that agent; the subagent's own
* permissions determine its capabilities.
* 2. Default `todowrite` and `task` denies if the subagent's own ruleset
* doesn't already permit them.
* 2. A default `task` deny if the subagent's own ruleset doesn't already
* permit it.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: PermissionV1.Ruleset
subagent: Agent.Info
}): PermissionV1.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
return [
...input.parentSessionPermission.filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
]
}

View file

@ -16,19 +16,7 @@ type AgentMode = "all" | "primary" | "subagent"
// Permission keys (not raw tool names). Multiple tools can map to a single
// permission — e.g. write/edit/apply_patch all gate on `edit` — so we configure
// agents at the permission level to match how the runtime actually enforces it.
const AVAILABLE_PERMISSIONS = [
"bash",
"read",
"edit",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"websearch",
"lsp",
"skill",
]
const AVAILABLE_PERMISSIONS = ["bash", "read", "edit", "glob", "grep", "webfetch", "task", "websearch", "lsp", "skill"]
const AgentCreateCommand = effectCmd({
command: "create",

View file

@ -820,7 +820,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?:
async function subscribeSessionEvents() {
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Shell", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],

View file

@ -20,7 +20,6 @@ import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { Todo } from "@/session/todo"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { SessionRunState } from "@/session/run-state"
@ -75,7 +74,6 @@ export const AppLayer = AppNodeBuilderV1.build(
Discovery.node,
Question.node,
Permission.node,
Todo.node,
Session.node,
SessionProjector.node,
SessionStatus.node,

View file

@ -8,7 +8,6 @@ import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { Schema, Struct } from "effect"
@ -80,7 +79,6 @@ export const SessionPaths = {
status: `${root}/status`,
get: `${root}/:sessionID`,
children: `${root}/:sessionID/children`,
todo: `${root}/:sessionID/todo`,
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
message: `${root}/:sessionID/message/:messageID`,
@ -153,18 +151,6 @@ export const SessionApi = HttpApi.make("session")
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
}),
),
HttpApiEndpoint.get("diff", SessionPaths.diff, {
params: { sessionID: SessionID },
query: DiffQuery,

View file

@ -13,7 +13,6 @@ import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Effect, Option, Schema, Scope } from "effect"
@ -56,7 +55,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const agentSvc = yield* Agent.Service
const permissionSvc = yield* Permission.Service
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service
const events = yield* EventV2Bridge.Service
const scope = yield* Scope.Scope
@ -91,11 +89,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
return yield* session.children(ctx.params.sessionID)
})
const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* requireSession(ctx.params.sessionID)
return yield* todoSvc.get(ctx.params.sessionID)
})
const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
params: { sessionID: SessionID }
query: typeof DiffQuery.Type
@ -415,7 +408,6 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
.handle("status", status)
.handle("get", get)
.handle("children", children)
.handle("todo", todo)
.handle("diff", diff)
.handle("messages", messages)
.handle("message", message)

View file

@ -38,7 +38,6 @@ import { SessionRunState } from "@/session/run-state"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { Skill } from "@/skill"
@ -233,7 +232,6 @@ const app = LayerNode.group([
Question.node,
Permission.node,
PermissionSaved.node,
Todo.node,
Session.node,
SessionProjector.node,
SessionStatus.node,

View file

@ -20,58 +20,7 @@ When the user directly asks about OpenCode (eg. "can OpenCode do...", "does Open
# Professional objectivity
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
# Task Management
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using Bash.
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
- Use the TodoWrite tool to plan the task if required
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
@ -93,8 +42,6 @@ user: What is the codebase structure?
assistant: [Uses the Task tool]
</example>
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.

View file

@ -6,7 +6,7 @@ You MUST iterate and keep going until the problem is solved.
You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
Only terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH.
@ -19,13 +19,13 @@ understanding of third party packages and dependencies is up to date. You must u
Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
If the user request is "resume" or "continue" or "try again", check the previous conversation history to identify the next incomplete step. Continue from that step, and do not hand back control to the user until the problem is solved. Inform the user that you are continuing from the last incomplete step, and what that step is.
Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
You MUST keep working until the problem is completely solved. Do not end your turn until you have completed all necessary steps and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
@ -39,7 +39,7 @@ You are a highly capable and autonomous agent, and you can definitely solve this
- What are the dependencies and interactions with other parts of the code?
3. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
4. Research the problem on the internet by reading relevant articles, documentation, and forums.
5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using emoji's to indicate the status of each item.
5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps.
6. Implement the fix incrementally. Make small, testable code changes.
7. Debug as needed. Use debugging techniques to isolate and resolve issues.
8. Test frequently. Run tests after each change to verify correctness.
@ -73,9 +73,6 @@ Carefully read the issue and think hard about a plan to solve it before coding.
## 5. Develop a Detailed Plan
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
- Create a todo list in markdown format to track your progress.
- Each time you complete a step, check it off using `[x]` syntax.
- Each time you check off a step, display the updated todo list to the user.
- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next.
## 6. Making Code Changes
@ -139,7 +136,6 @@ If you are asked to write a prompt, you should always generate the prompt in ma
If you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat.
Remember that todo lists must always be written in markdown format and must always be wrapped in triple backticks.
# Git
If the user tells you to stage and commit, you may do so.

View file

@ -23,14 +23,14 @@ You must use webfetch tool to recursively gather all information from URL's prov
1. Understand the problem deeply. Carefully read the issue and think critically about what is required.
2. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
3. Develop a clear, step-by-step plan. Break down the fix into manageable,
incremental steps - use the todo tool to track your progress.
incremental steps and track your progress.
4. Implement the fix incrementally. Make small, testable code changes.
5. Debug as needed. Use debugging techniques to isolate and resolve issues.
6. Test frequently. Run tests after each change to verify correctness.
7. Iterate until the root cause is fixed and all tests pass.
8. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.
**CRITICAL - Before ending your turn:**
- Review and update the todo list, marking completed, skipped (with explanations), or blocked items.
- Review the plan, noting completed, skipped (with explanations), or blocked items.
## 1. Deeply Understand the Problem
- Carefully read the issue and think hard about a plan to solve it before coding.
@ -50,8 +50,8 @@ incremental steps - use the todo tool to track your progress.
## 3. Develop a Detailed Plan
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
- Create a todo list to track your progress.
- Each time you check off a step, update the todo list.
- Create a plan to track your progress.
- Keep the plan current as you complete each step.
- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next.
## 4. Making Code Changes

View file

@ -1,74 +0,0 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { asc } from "drizzle-orm"
import { TodoTable } from "@opencode-ai/core/session/sql"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionTodo } from "@opencode-ai/schema/session-todo"
export const Info = SessionTodo.Info
export type Info = SessionTodo.Info
export const Event = SessionTodo.Event
export interface Interface {
readonly update: (input: { sessionID: SessionID; todos: ReadonlyArray<Info> }) => Effect.Effect<void>
readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: ReadonlyArray<Info> }) {
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
yield* tx
.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
.pipe(Effect.orDie)
yield* events.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
})
return Service.of({ update, get })
}),
)
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] })
export * as Todo from "./todo"

View file

@ -1,5 +1,5 @@
export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql"
export { ProjectTable } from "@opencode-ai/core/project/sql"
export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
export { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
export { SessionShareTable } from "@opencode-ai/core/share/sql"
export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"

View file

@ -11,7 +11,6 @@ import { GrepTool } from "./grep"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { Database } from "@opencode-ai/core/database/database"
import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
@ -39,7 +38,6 @@ import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { Question } from "../question"
import { Todo } from "../session/todo"
import { LSP } from "@/lsp/lsp"
import { Instruction } from "../session/instruction"
import { FSUtil } from "@opencode-ai/core/fs-util"
@ -97,7 +95,6 @@ const layer = Layer.effect(
const task = yield* TaskTool
const read = yield* ReadTool
const question = yield* QuestionTool
const todo = yield* TodoWriteTool
const lsptool = yield* LspTool
const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool
@ -211,7 +208,6 @@ const layer = Layer.effect(
write: Tool.init(writetool),
task: Tool.init(task),
fetch: Tool.init(webfetch),
todo: Tool.init(todo),
search: Tool.init(websearch),
skill: Tool.init(skilltool),
patch: Tool.init(patchtool),
@ -234,7 +230,6 @@ const layer = Layer.effect(
tool.write,
tool.task,
tool.fetch,
tool.todo,
tool.search,
tool.skill,
tool.patch,
@ -426,7 +421,6 @@ export const node = LayerNode.make({
Config.node,
Plugin.node,
Question.node,
Todo.node,
Agent.node,
Skill.node,
Session.node,

View file

@ -117,9 +117,6 @@ export const TaskTool = Tool.define(
subagent: next,
})
const childToolDenies = [
...(next.permission.some((rule) => rule.permission === "todowrite")
? []
: [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
...(next.permission.some((rule) => rule.permission === id)
? []
: [{ permission: id, pattern: "*" as const, action: "deny" as const }]),

View file

@ -1,46 +0,0 @@
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo"
export const Parameters = Schema.Struct({
todos: Schema.mutable(Schema.Array(Todo.Info)).annotate({ description: "The updated todo list" }),
})
type Metadata = {
todos: Todo.Info[]
}
export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
return {
description: DESCRIPTION_WRITE,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
yield* todo.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
}),
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
}),
)

View file

@ -1,44 +0,0 @@
Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status to the user.
## When to use
Use proactively when:
- The task requires 3+ distinct steps or actions (not just 3 tool calls for a single conceptual step)
- The work is non-trivial and benefits from planning
- The user provides multiple tasks (numbered or comma-separated) or explicitly asks for a todo list
- New instructions arrive - capture them as todos
- You start a task - mark it `in_progress` (only one at a time) before working
- You finish a task - mark it `completed` and add any follow-ups discovered during the work
## When NOT to use
Skip when:
- The work is a single, straightforward task (or <3 trivial steps)
- The request is purely informational or conversational
- Tracking adds no organizational value
## States
- `pending` - not started
- `in_progress` - actively working (exactly ONE at a time)
- `completed` - finished successfully
- `cancelled` - no longer needed
## Rules
- Update status in real time; don't batch completions
- Mark `completed` only after the required work is actually done, including any required verification. Never based on intent.
- Keep exactly one `in_progress` while work remains
- If blocked or partial, keep it `in_progress` and add a follow-up todo describing the blocker
- Preserve user-provided commands verbatim (flags, args, order)
- Items should be specific and actionable; break large work into smaller steps
## Examples
Use it:
- "Add a dark mode toggle and run the tests" -> multi-step feature + explicit verification
- "Rename getCwd -> getCurrentWorkingDirectory across the repo" -> grep reveals 15 occurrences in 8 files
- "Implement registration, catalog, cart, checkout" -> multiple complex features
Skip it:
- "How do I print Hello World in Python?" -> informational
- "Add a comment to calculateTotal" -> single edit
- "Run npm install and tell me what happened" -> one command
When in doubt, use it.

View file

@ -116,7 +116,6 @@ it.instance("explore agent denies edit and write", () =>
expect(explore?.mode).toBe("subagent")
expect(evalPerm(explore, "edit")).toBe("deny")
expect(evalPerm(explore, "write")).toBe("deny")
expect(evalPerm(explore, "todowrite")).toBe("deny")
}),
)
@ -160,16 +159,6 @@ it.instance(
},
)
it.instance("general agent denies todo tools", () =>
Effect.gen(function* () {
const general = yield* load((svc) => svc.get("general"))
expect(general).toBeDefined()
expect(general?.mode).toBe("subagent")
expect(general?.hidden).toBeUndefined()
expect(evalPerm(general, "todowrite")).toBe("deny")
}),
)
it.instance("compaction agent denies all permissions", () =>
Effect.gen(function* () {
const compaction = yield* load((svc) => svc.get("compaction"))

View file

@ -93,7 +93,7 @@ describe("extractResponseText", () => {
})
test("returns text even when tool parts follow", () => {
const parts = [createTextPart("I'll help with that."), createToolPart("todowrite", "3 todos")]
const parts = [createTextPart("I'll help with that."), createToolPart("read", "src/index.ts")]
expect(extractResponseText(parts)).toBe("I'll help with that.")
})
@ -103,8 +103,7 @@ describe("extractResponseText", () => {
})
test("returns null for tool-only response (signals summary needed)", () => {
// This is the exact scenario from the bug report - todowrite with no text
const parts = [createToolPart("todowrite", "8 todos")]
const parts = [createToolPart("read", "src/index.ts")]
expect(extractResponseText(parts)).toBeNull()
})

View file

@ -519,8 +519,8 @@ Options:
--description what the agent should do [string]
--mode agent mode [string] [choices: "all", "primary", "subagent"]
--permissions, --tools comma-separated list of permissions to allow (default: all).
Available: "bash, read, edit, glob, grep, webfetch, task, todowrite,
websearch, lsp, skill" [string]
Available: "bash, read, edit, glob, grep, webfetch, task, websearch,
lsp, skill" [string]
-m, --model model to use in the format of provider/model [string]"
`;

View file

@ -321,50 +321,8 @@ test("holds markdown code blocks until final commit and keeps newline ownership"
}
})
test("renders todo and question summaries without boilerplate footer copy", async () => {
test("renders question summaries without boilerplate footer copy", async () => {
const cases = [
{
title: "# Todos",
include: [
"[✓] List files under `run/`",
"[•] Count functions in each `run/` file",
"[ ] Mark each tracking item complete",
],
exclude: ["Updating", "todos completed"],
start: toolCommit({
tool: "todowrite",
phase: "start",
toolState: "running",
state: {
status: "running",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
time: { start: 1 },
},
}),
final: toolCommit({
tool: "todowrite",
phase: "final",
toolState: "completed",
state: {
status: "completed",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
metadata: {},
time: { start: 1, end: 4 },
},
}),
},
{
title: "# Questions",
include: ["What should I work on in the codebase next?", "Bug fix"],

View file

@ -220,7 +220,6 @@ export default {
api.kv.set(options.kv_key, "stored")
const kv_after = api.kv.get(options.kv_key, "missing")
const diff = api.state.session.diff(options.session_id)
const todo = api.state.session.todo(options.session_id)
const lsp = api.state.lsp()
const mcp = api.state.mcp()
const depth_before = api.ui.dialog.depth
@ -263,8 +262,6 @@ export default {
kv_ready: api.kv.ready,
diff_count: diff.length,
diff_file: diff[0]?.file,
todo_count: todo.length,
todo_first: todo[0]?.content,
lsp_count: lsp.length,
mcp_count: mcp.length,
mcp_first: mcp[0]?.name,
@ -513,10 +510,6 @@ export default {
if (sessionID !== "ses_test") return []
return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
},
todo(sessionID) {
if (sessionID !== "ses_test") return []
return [{ content: "ship it", status: "pending" }]
},
},
lsp() {
return [{ id: "ts", root: "/tmp/project", status: "connected" }]
@ -867,8 +860,6 @@ describe("tui.plugin.loader", () => {
expect(data.local.kv_ready).toBe(true)
expect(data.local.diff_count).toBe(1)
expect(data.local.diff_file).toBe("src/app.ts")
expect(data.local.todo_count).toBe(1)
expect(data.local.todo_first).toBe("ship it")
expect(data.local.lsp_count).toBe(1)
expect(data.local.mcp_count).toBe(1)
expect(data.local.mcp_first).toBe("github")

Some files were not shown because too many files have changed in this diff Show more