mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d9603bd8a | |||
| d6625397d9 |
@@ -8,10 +8,13 @@ const directory = "C:/OpenCode/SubagentNavigation"
|
||||
const projectID = "proj_subagent_navigation"
|
||||
const parentID = "ses_subagent_parent"
|
||||
const childID = "ses_subagent_child"
|
||||
const grandchildID = "ses_subagent_grandchild"
|
||||
const parentTitle = "Parent session"
|
||||
const childTitle = "Subagent child session"
|
||||
const grandchildTitle = "Nested subagent session"
|
||||
// Child session pages derive their heading from the task part that spawned them.
|
||||
const taskDescription = "Inspect child navigation"
|
||||
const nestedTaskDescription = "Inspect nested navigation"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
@@ -26,6 +29,23 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("keeps the root tab active for a nested subagent", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(grandchildID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
await card.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/server/.+/session/${grandchildID}$`), { timeout: 15_000 })
|
||||
await expectSessionTitle(page, nestedTaskDescription)
|
||||
|
||||
const rootTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(parentID)}"])`)
|
||||
await expect(rootTab).toHaveAttribute("data-active", "true")
|
||||
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("keeps the parent visible while the child session resolves", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
@@ -94,8 +114,10 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
|
||||
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
|
||||
sessions: [session(parentID, parentTitle, 1700000000000), childSession(), grandchildSession()],
|
||||
pageMessages: (sessionID) => ({
|
||||
items: sessionID === parentID ? parentMessages() : sessionID === childID ? childMessages() : [],
|
||||
}),
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
})
|
||||
@@ -145,6 +167,10 @@ function childSession() {
|
||||
return session(childID, childTitle, 1700000001000, { parentID })
|
||||
}
|
||||
|
||||
function grandchildSession() {
|
||||
return session(grandchildID, grandchildTitle, 1700000002000, { parentID: childID })
|
||||
}
|
||||
|
||||
function parentMessages(): SessionMessageInfo[] {
|
||||
const userID = "msg_user_0001"
|
||||
const assistantID = "msg_assistant_0001"
|
||||
@@ -182,6 +208,43 @@ function parentMessages(): SessionMessageInfo[] {
|
||||
]
|
||||
}
|
||||
|
||||
function childMessages(): SessionMessageInfo[] {
|
||||
const userID = "msg_user_0002"
|
||||
const assistantID = "msg_assistant_0002"
|
||||
return [
|
||||
{
|
||||
id: userID,
|
||||
type: "user",
|
||||
time: { created: 1700000002000 },
|
||||
text: "Delegate nested work to a subagent",
|
||||
},
|
||||
{
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000003000, completed: 1700000004000 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent_0002",
|
||||
name: "subagent",
|
||||
time: { created: 1700000003000, ran: 1700000003000, completed: 1700000004000 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: nestedTaskDescription, agent: "explore", prompt: "Inspect the nested work." },
|
||||
content: [{ type: "text", text: "Nested subagent finished" }],
|
||||
metadata: { sessionID: grandchildID },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -34,6 +34,7 @@ import { tabKey, useTabs } from "@/context/tabs"
|
||||
import type { PromptSession } from "@/context/prompt"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
|
||||
const v2TitlebarHeight = 36
|
||||
const minTitlebarZoom = 0.25
|
||||
@@ -170,15 +171,43 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
const tabsStoreActions = tabs
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return undefined
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
|
||||
const routeContext = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
|
||||
})
|
||||
const [resolvedSession] = createResource(routeContext, ({ route, ctx }) =>
|
||||
(async () => {
|
||||
const session =
|
||||
ctx.data.session.get(route.sessionId) ??
|
||||
(await ctx.sdk.api.session.get({ sessionID: route.sessionId }))
|
||||
const root = await rootSession(
|
||||
session,
|
||||
async (sessionID) =>
|
||||
ctx.data.session.get(sessionID) ?? (await ctx.sdk.api.session.get({ sessionID })),
|
||||
)
|
||||
return { session, rootID: root.id }
|
||||
})().catch(() => undefined),
|
||||
)
|
||||
const session = () => {
|
||||
const input = routeContext()
|
||||
if (!input) return
|
||||
const loaded = input.ctx.data.session.get(input.route.sessionId)
|
||||
if (loaded) return loaded
|
||||
const resolved = resolvedSession()
|
||||
return resolved?.session.id === input.route.sessionId ? resolved.session : undefined
|
||||
}
|
||||
const rootID = () => {
|
||||
const input = routeContext()
|
||||
if (!input) return
|
||||
const current = input.ctx.data.session.get(input.route.sessionId)
|
||||
const resolved = resolvedSession()
|
||||
if (!current) return resolved?.session.id === input.route.sessionId ? resolved.rootID : undefined
|
||||
const root = input.ctx.data.session.root(current.id)
|
||||
if (!current.parentID || input.ctx.data.session.get(root)) return root
|
||||
return resolved?.session.id === input.route.sessionId ? resolved.rootID : undefined
|
||||
}
|
||||
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
if (route.type === "home") return
|
||||
@@ -186,19 +215,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
}
|
||||
if (route.type === "session") {
|
||||
const main = tabsStore.find(
|
||||
(item) =>
|
||||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
const sessionId = rootID() ?? route.sessionId
|
||||
return tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === sessionId,
|
||||
)
|
||||
if (main) return main
|
||||
const s = session()
|
||||
if (s?.parentID) {
|
||||
const parentID = s.parentID
|
||||
const parent = tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||
)
|
||||
if (parent) return parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,9 +234,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
}
|
||||
|
||||
if (route.type === "session") {
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const sessionId = s.parentID ?? s.id
|
||||
const sessionId = rootID()
|
||||
if (!sessionId) return
|
||||
const next = { server: route.server, sessionId }
|
||||
tabsStoreActions.addSessionTab(next)
|
||||
}
|
||||
|
||||
@@ -59,11 +59,6 @@
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
"default": "./src/attention-sounds.bun.ts"
|
||||
},
|
||||
"#terminal-win32": {
|
||||
"bun": "./src/terminal-win32.bun.ts",
|
||||
"node": "./src/terminal-win32.node.ts",
|
||||
"default": "./src/terminal-win32.bun.ts"
|
||||
},
|
||||
"#string-width": {
|
||||
"bun": "./src/util/string-width.bun.ts",
|
||||
"node": "./src/util/string-width.node.ts",
|
||||
|
||||
@@ -96,7 +96,6 @@ import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
@@ -266,7 +265,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
|
||||
),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const finalizers = new Set<() => Promise<void>>()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
@@ -450,7 +448,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
}),
|
||||
)
|
||||
yield* Effect.sync(() => {
|
||||
win32FlushInputBuffer()
|
||||
if (result.reason !== undefined)
|
||||
process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n")
|
||||
if (result.epilogue) process.stdout.write(result.epilogue + "\n")
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { dlopen, ptr } from "bun:ffi"
|
||||
import type { ReadStream } from "node:tty"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { args: ["i32"], returns: "ptr" },
|
||||
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
|
||||
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
|
||||
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
|
||||
})
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
|
||||
*/
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any queued console input (mouse events, key presses, etc.).
|
||||
*/
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
k32!.symbols.FlushConsoleInputBuffer(handle)
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Keep ENABLE_PROCESSED_INPUT disabled.
|
||||
*
|
||||
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
|
||||
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
|
||||
* (sometimes on a later tick), and the flag is console-global, not per-process.
|
||||
*
|
||||
* We combine:
|
||||
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
|
||||
* - A low-frequency poll as a backstop for native/external mode changes.
|
||||
*/
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32") return
|
||||
if (!process.stdin.isTTY) return
|
||||
if (!load()) return
|
||||
if (unhook) return unhook
|
||||
|
||||
const stdin = process.stdin as ReadStream
|
||||
const original = stdin.setRawMode
|
||||
|
||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buf = new Uint32Array(1)
|
||||
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const initial = buf[0]!
|
||||
|
||||
const enforce = () => {
|
||||
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
|
||||
const mode = buf[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
// Some runtimes can re-apply console modes on the next tick; enforce twice.
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
|
||||
let wrapped: ReadStream["setRawMode"] | undefined
|
||||
|
||||
if (typeof original === "function") {
|
||||
wrapped = (mode: boolean) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
|
||||
stdin.setRawMode = wrapped
|
||||
}
|
||||
|
||||
// Ensure it's cleared immediately too (covers any earlier mode changes).
|
||||
later()
|
||||
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
|
||||
let done = false
|
||||
unhook = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
clearInterval(interval)
|
||||
if (wrapped && stdin.setRawMode === wrapped) {
|
||||
stdin.setRawMode = original
|
||||
}
|
||||
|
||||
k32!.symbols.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
|
||||
return unhook
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { dlopen } from "node:ffi"
|
||||
import type { ReadStream } from "node:tty"
|
||||
|
||||
const STD_INPUT_HANDLE = -10
|
||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||
|
||||
const kernel = () =>
|
||||
dlopen("kernel32.dll", {
|
||||
GetStdHandle: { arguments: ["i32"], return: "pointer" },
|
||||
GetConsoleMode: { arguments: ["pointer", "pointer"], return: "i32" },
|
||||
SetConsoleMode: { arguments: ["pointer", "u32"], return: "i32" },
|
||||
FlushConsoleInputBuffer: { arguments: ["pointer"], return: "i32" },
|
||||
}).functions
|
||||
|
||||
let k32: ReturnType<typeof kernel> | undefined
|
||||
|
||||
function load() {
|
||||
if (process.platform !== "win32") return false
|
||||
try {
|
||||
k32 ??= kernel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function win32DisableProcessedInput() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
|
||||
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buffer = new Uint32Array(1)
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const mode = buffer[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
|
||||
k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
|
||||
export function win32FlushInputBuffer() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
|
||||
k32!.FlushConsoleInputBuffer(k32!.GetStdHandle(STD_INPUT_HANDLE))
|
||||
}
|
||||
|
||||
let unhook: (() => void) | undefined
|
||||
|
||||
export function win32InstallCtrlCGuard() {
|
||||
if (process.platform !== "win32" || !process.stdin.isTTY || !load() || unhook) return unhook
|
||||
const stdin = process.stdin as ReadStream
|
||||
const original = stdin.setRawMode
|
||||
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
|
||||
const buffer = new Uint32Array(1)
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const initial = buffer[0]!
|
||||
const enforce = () => {
|
||||
if (k32!.GetConsoleMode(handle, buffer) === 0) return
|
||||
const mode = buffer[0]!
|
||||
if ((mode & ENABLE_PROCESSED_INPUT) !== 0) k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
|
||||
}
|
||||
const later = () => {
|
||||
enforce()
|
||||
setImmediate(enforce)
|
||||
}
|
||||
const wrapped: ReadStream["setRawMode"] = (mode) => {
|
||||
const result = original.call(stdin, mode)
|
||||
later()
|
||||
return result
|
||||
}
|
||||
stdin.setRawMode = wrapped
|
||||
later()
|
||||
const interval = setInterval(enforce, 100)
|
||||
interval.unref()
|
||||
unhook = () => {
|
||||
clearInterval(interval)
|
||||
if (stdin.setRawMode === wrapped) stdin.setRawMode = original
|
||||
k32!.SetConsoleMode(handle, initial)
|
||||
unhook = undefined
|
||||
}
|
||||
return unhook
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "#terminal-win32"
|
||||
Reference in New Issue
Block a user