Compare commits

..
1 Commits
Author SHA1 Message Date
opencode-agent[bot] adba484df4 chore: generate 2026-07-24 02:50:38 +00:00
25 changed files with 329 additions and 471 deletions
@@ -41,12 +41,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
@@ -112,25 +107,9 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -165,8 +144,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
@@ -42,8 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url())
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -65,9 +64,7 @@ test("shows a pending question dock", async ({ page }) => {
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@@ -100,8 +97,8 @@ test("shows a pending permission dock", async ({ page }) => {
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ reply: "once" })
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -1,6 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SubagentNavigation"
@@ -72,19 +72,16 @@ async function setup(page: Page, events?: () => EventPayload[]) {
events,
eventRetry: events ? 16 : undefined,
})
// The child session resolves by ID but is absent from the session list,
// The child session resolves via /session/:id but is absent from the /session list,
// matching a subagent session that has not been loaded into the list cache yet.
await page.route(
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
cursor: {},
}),
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
}),
)
await configurePage(page)
+7 -3
View File
@@ -69,11 +69,15 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.api.session.fork({ sessionID, messageID: item.id })
.client.session.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -7,11 +7,6 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sessionCreateInputs: Array<{
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
@@ -24,15 +19,11 @@ const optimistic: Array<{
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sessionDirectories: Record<string, string> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
@@ -41,7 +32,7 @@ let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue,
cursor: 0,
@@ -73,39 +64,23 @@ const prompt = {
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: `New session ${createdSessions.length}`,
location: { directory: location },
}
},
prompt: async (input: unknown) => {
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
command: async (input: unknown) => {
sentCommands.push(input)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
},
},
session: {
create: async () => {
await createSessionGate
createdSessions.push(directory)
return {
data: {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
},
}
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
promptAsync: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
@@ -115,6 +90,27 @@ const clientFor = (directory: string) => {
}
}
const api = {
session: {
async create(input: { location: { directory: string } }) {
await createSessionGate
createdSessions.push(input.location.directory)
const session = {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
}
sessionDirectories[session.id] = input.location.directory
return session
},
async shell(input: { sessionID: string }) {
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
},
async prompt() {},
async command() {},
async interrupt() {},
},
}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@@ -197,8 +193,8 @@ beforeAll(async () => {
const sdk = {
scope: "local",
directory: "/repo/main",
api,
client: rootClient,
api: rootClient.api,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@@ -210,7 +206,7 @@ beforeAll(async () => {
mock.module("@/context/sync", () => ({
useSync: () => () => ({
data: { command: commands },
data: { command: [] },
session: {
optimistic: {
add: (value: {
@@ -237,9 +233,6 @@ beforeAll(async () => {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
},
child: (directory: string) => {
syncedDirectories.push(directory)
@@ -281,17 +274,11 @@ beforeAll(async () => {
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
search = {}
sentShell.length = 0
@@ -300,8 +287,8 @@ beforeEach(() => {
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
})
describe("prompt submit worktree selection", () => {
@@ -334,24 +321,8 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
@@ -472,7 +443,6 @@ describe("prompt submit worktree selection", () => {
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
@@ -481,53 +451,6 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect(sentPrompts).toEqual(["/repo/main"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
files: [],
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([
{
sessionID: "session-1",
id: expect.stringMatching(/^msg_/),
command: "review",
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
files: [],
},
])
expect(serverSessionSyncs).toBe(0)
})
test("uses an injected model selection", async () => {
@@ -588,8 +511,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(optimisticSeeded).toEqual([true])
})
})
@@ -124,7 +124,6 @@ describe("bootstrapDirectory", () => {
expect(store.status).toBe("complete")
expect(mcpReads).toEqual([])
})
})
describe("query keys", () => {
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type {
AgentListOutput,
ModelDefaultOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
+15 -23
View File
@@ -13,7 +13,6 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import {
acceptKey,
directoryAcceptKey,
@@ -244,20 +243,9 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.api.permission
.reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response })
.catch(() => {
responded.delete(request.permissionID)
})
}
const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
input.sdk.client.permission.respond(request).catch(() => {
responded.delete(request.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
@@ -355,12 +343,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}),
)
list(directory)
.then((permissions) => {
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const permission of permissions) {
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
@@ -387,14 +377,16 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}),
)
list(directory)
.then((permissions) => {
input.sdk.client.permission
.list({ directory })
.then((x) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const permission of permissions) {
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(
permission,
perm,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
@@ -25,7 +25,12 @@ describe("v2 session reducer", () => {
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
})
apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } })
apply({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
})
apply({
...base,
id: "evt_step",
@@ -136,12 +141,15 @@ describe("v2 session reducer", () => {
})
test("requests hydration when promotion admission was missed", () => {
const result = createV2SessionReducer().reduce([], event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}))
const result = createV2SessionReducer().reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
@@ -103,40 +103,63 @@ export function createV2SessionReducer() {
time: { created: event.created },
})
case "session.shell.ended":
return updateMessage<Shell>(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({
...item,
status: event.data.shell.status,
exit: event.data.shell.exit,
output: event.data.output,
time: { ...item.time, completed: event.created },
}), sessionID)
return updateMessage<Shell>(
source,
(item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
(item) => ({
...item,
status: event.data.shell.status,
exit: event.data.shell.exit,
output: event.data.output,
time: { ...item.time, completed: event.created },
}),
sessionID,
)
case "session.step.started": {
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
const completed = current && current.id !== event.data.assistantMessageID
? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item)
: [...source]
const completed =
current && current.id !== event.data.assistantMessageID
? update(source, current.id, (item) =>
item.type === "assistant"
? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
: item,
)
: [...source]
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
if (existing?.type === "assistant")
return result(update(completed, existing.id, (item) => item.type === "assistant" ? {
...item,
agent: event.data.agent,
model: event.data.model,
retry: undefined,
error: undefined,
finish: undefined,
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
time: { ...item.time, completed: undefined },
} : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id])
return result([...completed, {
id: event.data.assistantMessageID,
type: "assistant",
metadata: event.metadata,
agent: event.data.agent,
model: event.data.model,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
}], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID])
return result(
update(completed, existing.id, (item) =>
item.type === "assistant"
? {
...item,
agent: event.data.agent,
model: event.data.model,
retry: undefined,
error: undefined,
finish: undefined,
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
time: { ...item.time, completed: undefined },
}
: item,
),
current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
)
return result(
[
...completed,
{
id: event.data.assistantMessageID,
type: "assistant",
metadata: event.metadata,
agent: event.data.agent,
model: event.data.model,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
},
],
current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
)
}
case "session.step.ended":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
@@ -144,9 +167,10 @@ export function createV2SessionReducer() {
finish: event.data.finish,
cost: event.data.cost,
tokens: event.data.tokens,
snapshot: event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
snapshot:
event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
time: { ...item.time, completed: event.created },
}))
case "session.step.failed":
@@ -157,9 +181,10 @@ export function createV2SessionReducer() {
retry: undefined,
cost: event.data.cost ?? item.cost,
tokens: event.data.tokens ?? item.tokens,
snapshot: event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
snapshot:
event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
time: { ...item.time, completed: event.created },
}))
case "session.text.started":
@@ -188,29 +213,46 @@ export function createV2SessionReducer() {
}),
}))
case "session.reasoning.delta":
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
...item,
text: item.text + event.data.delta,
}))
return updateContent(
source,
event.data.assistantMessageID,
sessionID,
"reasoning",
event.data.ordinal,
(item) => ({
...item,
text: item.text + event.data.delta,
}),
)
case "session.reasoning.ended":
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
...item,
text: event.data.text,
state: event.data.state ?? item.state,
time: { created: item.time?.created ?? event.created, completed: event.created },
}))
return updateContent(
source,
event.data.assistantMessageID,
sessionID,
"reasoning",
event.data.ordinal,
(item) => ({
...item,
text: event.data.text,
state: event.data.state ?? item.state,
time: { created: item.time?.created ?? event.created, completed: event.created },
}),
)
case "session.tool.input.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
? item.content
: [...item.content, {
type: "tool",
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
}],
: [
...item.content,
{
type: "tool",
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
},
],
}))
case "session.tool.input.delta":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
@@ -295,12 +337,21 @@ export function createV2SessionReducer() {
time: { created: event.created },
})
case "session.compaction.delta":
return updateMessage<Extract<Compaction, { status: "running" }>>(source, (item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running", (item) => ({
...item,
summary: item.summary + event.data.text,
}), sessionID)
return updateMessage<Extract<Compaction, { status: "running" }>>(
source,
(item): item is Extract<Compaction, { status: "running" }> =>
item.type === "compaction" && item.status === "running",
(item) => ({
...item,
summary: item.summary + event.data.text,
}),
sessionID,
)
case "session.compaction.ended": {
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
const current = source.findLast(
(item): item is Extract<Compaction, { status: "running" }> =>
item.type === "compaction" && item.status === "running",
)
if (!current)
return append({
id: messageID(event.id),
@@ -312,16 +363,22 @@ export function createV2SessionReducer() {
recent: event.data.recent,
time: { created: event.created },
})
return result(update(source, current.id, () => ({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})), [current.id])
return result(
update(source, current.id, () => ({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})),
[current.id],
)
}
case "session.compaction.failed": {
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
const current = source.findLast(
(item): item is Extract<Compaction, { status: "running" }> =>
item.type === "compaction" && item.status === "running",
)
const failed: Extract<Compaction, { status: "failed" }> = {
id: current?.id ?? event.data.inputID ?? messageID(event.id),
type: "compaction",
@@ -332,7 +389,10 @@ export function createV2SessionReducer() {
time: current?.time ?? { created: event.created },
}
if (!current) return append(failed)
return result(update(source, current.id, () => failed), [failed.id])
return result(
update(source, current.id, () => failed),
[failed.id],
)
}
default:
return
@@ -362,7 +422,7 @@ function update(
id: string,
apply: (item: SessionMessageInfo) => SessionMessageInfo,
) {
return source.map((item) => item.id === id ? apply(item) : item)
return source.map((item) => (item.id === id ? apply(item) : item))
}
function updateMessage<T extends SessionMessageInfo>(
@@ -373,7 +433,11 @@ function updateMessage<T extends SessionMessageInfo>(
): V2SessionReduction {
const current = source.findLast(matches)
if (!current) return { sessionID, messages: [...source], touched: [] }
return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] }
return {
sessionID,
messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
touched: [current.id],
}
}
function updateAssistant(
@@ -384,7 +448,7 @@ function updateAssistant(
): V2SessionReduction {
return {
sessionID,
messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item),
messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
}
}
@@ -395,7 +459,9 @@ function updateContent<T extends "text" | "reasoning">(
sessionID: string,
type: T,
ordinal: number,
apply: (item: Extract<Assistant["content"][number], { type: T }>) => Extract<Assistant["content"][number], { type: T }>,
apply: (
item: Extract<Assistant["content"][number], { type: T }>,
) => Extract<Assistant["content"][number], { type: T }>,
) {
return updateAssistant(source, messageID, sessionID, (assistant) => {
let index = -1
@@ -414,11 +480,13 @@ function updateTool(
messageID: string,
callID: string,
sessionID: string,
apply: (item: Extract<Assistant["content"][number], { type: "tool" }>) => Extract<Assistant["content"][number], { type: "tool" }>,
apply: (
item: Extract<Assistant["content"][number], { type: "tool" }>,
) => Extract<Assistant["content"][number], { type: "tool" }>,
) {
return updateAssistant(source, messageID, sessionID, (assistant) => ({
...assistant,
content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item),
content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
}))
}
+1 -6
View File
@@ -11,12 +11,7 @@ import type {
import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import {
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
seedActiveSessionStatuses,
} from "./server-sync"
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
@@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
archive: async () => undefined,
update: async () => undefined,
remove: () => {
removed = true
},
@@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
archive: async () => Promise.reject(failure),
update: async () => Promise.reject(failure),
remove: () => {
removed = true
},
+12 -2
View File
@@ -6,15 +6,25 @@ type HomeSession = {
directory: string
}
type SessionUpdate = {
directory: string
sessionID: string
time: { archived: number }
}
export async function archiveHomeSession(input: {
server: ServerConnection.Key
session: HomeSession
archive: (sessionID: string) => Promise<unknown>
update: (value: SessionUpdate) => Promise<unknown>
remove: () => void
onError?: (error: unknown) => void
}) {
await input
.archive(input.session.id)
.update({
directory: input.session.directory,
sessionID: input.session.id,
time: { archived: Date.now() },
})
.then(() => {
input.remove()
notifySessionTabsRemoved({
+1 -1
View File
@@ -606,7 +606,7 @@ export function NewHome() {
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
update: (value) => ctx.sdk.client.session.update(value),
remove: () =>
setStore(
produce((draft) => {
@@ -45,8 +45,7 @@ export function createPromptInputController(input: {
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
@@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
setStore("responding", perm.id)
sdk()
.api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -223,8 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) =>
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
@@ -236,7 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -283,14 +283,6 @@ export function MessageTimeline(props: {
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
@@ -332,7 +324,7 @@ export function MessageTimeline(props: {
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
sessionMessages: projectedMessages,
userMessages: () => props.userMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
@@ -672,7 +664,8 @@ export function MessageTimeline(props: {
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }),
mutationFn: (input: { id: string; title: string }) =>
sdk().client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
@@ -816,7 +809,7 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.api.session.archive({ sessionID })
.client.session.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
@@ -845,8 +838,8 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.api.session.remove({ sessionID })
.then(() => true)
.client.session.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
@@ -1,14 +1,16 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { Binary } from "@opencode-ai/core/util/binary"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, type Accessor } from "solid-js"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
sessionMessages: Accessor<SessionMessageInfo[]>
userMessages: Accessor<UserMessage[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
@@ -28,19 +30,47 @@ export function createTimelineProjection(input: {
})
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
input.showReasoningSummaries(),
input.status().type,
input.inlineComments(),
const activeMessageID = createMemo(() => {
const parentID = input
.messages()
.findLast(
(message): message is AssistantMessage =>
message.role === "assistant" && typeof message.time.completed !== "number",
)?.parentID
if (parentID) {
const messages = input.messages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message?.role === "user") return message.id
}
if (input.status().type === "idle") return
return input.messages().findLast((message) => message.role === "user")?.id
})
const messageRowMemos = createMemo(
mapArray(input.userMessages, (userMessage, indexAccessor) =>
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
Timeline.constructMessageRows(
userMessage,
input.parts,
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
indexAccessor(),
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
input.inlineComments(),
),
),
),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
reuseTimelineRows(
previous,
messageRowMemos().flatMap((memo) => memo()),
),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
@@ -1,92 +0,0 @@
import { describe, expect, mock, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "@/utils/session-message"
mock.module("@opencode-ai/session-ui/message-part", () => ({
renderable: () => true,
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
refs.map((ref) => ({
type: "part" as const,
key: ref.part.id,
ref: { messageID: ref.messageID, partID: ref.part.id },
})),
}))
const { Timeline, TimelineRow } = await import("./rows")
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
const source = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_3", type: "user", text: "second", time: { created: 4 } },
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "reasoning", text: "working" }],
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
true,
)
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
])
})
test("renders a current shell message as a standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
)
expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_shell",
"assistant-part:msg_shell:msg_shell:tool",
])
})
})
@@ -1,5 +1,4 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -32,47 +31,6 @@ export type TimelineRowMap = {
}
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
getMessageParts: (messageID: string) => Part[],
showReasoning: boolean,
status: SessionStatus["type"],
inlineComments: boolean,
) {
const turns = messages.reduce<{ user: UserMessage; assistants: AssistantMessage[] }[]>((result, message) => {
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
result.push({ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] })
return result
}
if (projected?.role === "user") {
result.push({ user: projected, assistants: [] })
return result
}
if (projected?.role !== "assistant") return result
result.at(-1)?.assistants.push(projected)
return result
}, [])
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
),
),
}
}
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
@@ -5,6 +5,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
@@ -18,7 +19,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -40,6 +40,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
@@ -47,7 +48,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
@@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const client = sdk().client
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
@@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const parts = sync().data.part[message.id]
if (sync().data.session_working(sessionID)) {
await session.interrupt({ sessionID }).catch(() => {})
await client.session.abort({ sessionID }).catch(() => {})
}
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: message.id }),
request: () => client.session.revert({ sessionID, messageID: message.id }),
updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
},
@@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const client = sdk().client
const messages = userMessages()
const promptSession = prompt.capture()
@@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.clear({ sessionID }),
request: () => client.session.unrevert({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
})
@@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }),
request: () => client.session.revert({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
})
@@ -375,9 +375,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return
}
await sdk().api.session.compact({
await sdk().client.session.summarize({
sessionID,
model: { providerID: model.provider.id, modelID: model.id },
modelID: model.id,
providerID: model.provider.id,
})
}
@@ -122,9 +122,7 @@ describe("normalizeSessionMessages", () => {
expect.objectContaining({ id: "msg_shell", role: "user" }),
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
])
expect(result.parts.get("msg_shell")).toEqual([
expect.objectContaining({ type: "text", text: "printf hello" }),
])
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
expect(result.parts.get("msg_shell:assistant")).toEqual([
expect.objectContaining({
type: "tool",
@@ -521,7 +521,6 @@ export function getToolInfo(
}
}
case "bash":
case "shell":
return {
icon: "console",
title: i18n.t("ui.tool.shell"),
@@ -539,7 +538,6 @@ export function getToolInfo(
title: i18n.t("ui.messagePart.title.write"),
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
}
case "patch":
case "apply_patch":
return {
icon: "code-lines",
@@ -731,8 +729,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
}
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash" || tool === "shell") return shell
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
if (tool === "bash") return shell
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
}
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
@@ -1508,7 +1506,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
}
export function getTool(name: string) {
return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
return state[name]?.render
}
export const ToolRegistry = {
@@ -2103,7 +2101,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "shell",
name: "bash",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "running"
@@ -2339,7 +2337,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "patch",
name: "apply_patch",
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
@@ -51,8 +51,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
webfetch: "ui.tool.webfetch",
websearch: "ui.tool.websearch",
bash: "ui.tool.shell",
shell: "ui.tool.shell",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
}