mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 09:25:15 +00:00
fix(tui): remove completion notice links (#47426)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
This commit is contained in:
parent
86ba09c6e0
commit
8d1a9799f4
4 changed files with 139 additions and 139 deletions
|
|
@ -87,7 +87,6 @@ import { useLocation } from "../../context/location"
|
|||
import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
|
|
@ -95,7 +94,6 @@ import {
|
|||
sessionRowID,
|
||||
turnDuration,
|
||||
turnTokensPerSecond,
|
||||
type BackgroundToolTarget,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
|
|
@ -144,7 +142,6 @@ const context = createContext<{
|
|||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
|
||||
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
|
||||
jumpToBackgroundTool: (target: BackgroundToolTarget, beforeMessageID: string) => void
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
|
|
@ -671,25 +668,6 @@ export function Session(props: {
|
|||
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
|
||||
})
|
||||
|
||||
const jumpToBackgroundTool = (target: BackgroundToolTarget, beforeMessageID: string) => {
|
||||
if (firstJump()) clearMessageNavigation()
|
||||
const jump = () => {
|
||||
const index = backgroundToolRowIndex(rows, messages(), target, beforeMessageID)
|
||||
if (index === -1) {
|
||||
if (data.session.message.more(route.sessionID)) prependHistory(0, jump)
|
||||
return
|
||||
}
|
||||
const id = sessionRowID(rows[index]!, boundaries()[index])
|
||||
if (!id) return
|
||||
ensureAllRows(() => {
|
||||
const child = scroll.getRenderable(id)
|
||||
if (!child) return
|
||||
alignMessage(id, Math.max(0, scroll.scrollTop + child.y - scroll.viewport.y - 1))
|
||||
})
|
||||
}
|
||||
jump()
|
||||
}
|
||||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
ensureAllRowsPending = undefined
|
||||
|
|
@ -1310,7 +1288,6 @@ export function Session(props: {
|
|||
config,
|
||||
mutatePending,
|
||||
pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID),
|
||||
jumpToBackgroundTool,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
|
|
@ -2057,15 +2034,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
|||
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
|
||||
const source = () => stringValue(metadata()?.source)
|
||||
const target = createMemo<BackgroundToolTarget | undefined>(() => {
|
||||
if (source() !== "shell") return
|
||||
const id = stringValue(metadata()?.shellID) ?? stringValue(metadata()?.jobID)
|
||||
return id ? { source: "shell", id } : undefined
|
||||
})
|
||||
const completion = () => source() === "subagent" || source() === "shell"
|
||||
const state = () => stringValue(metadata()?.state)
|
||||
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
|
||||
|
|
@ -2083,7 +2053,6 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
|||
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
|
||||
const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
|
||||
const color = () => {
|
||||
if (hover()) return theme.text.action.secondary.hovered
|
||||
if (state() === "error") return theme.text.feedback.error.default
|
||||
if (state() === "cancelled") return theme.text.feedback.warning.default
|
||||
return theme.text.feedback.info.default
|
||||
|
|
@ -2097,19 +2066,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
|||
</InlineToolRow>
|
||||
}
|
||||
>
|
||||
<box
|
||||
id={target() ? `${target()!.source}-completion:${target()!.id}` : undefined}
|
||||
marginLeft={3}
|
||||
onMouseOver={() => {
|
||||
if (target()) setHover(true)
|
||||
}}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
const item = target()
|
||||
if (!item || renderer.getSelection()?.getSelectedText()) return
|
||||
ctx.jumpToBackgroundTool(item, props.message.id)
|
||||
}}
|
||||
>
|
||||
<box marginLeft={3}>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: color() }}>{heading()}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{suffix()}</span>
|
||||
|
|
|
|||
|
|
@ -35,8 +35,6 @@ export type SessionRow =
|
|||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export type BackgroundToolTarget = { source: "shell"; id: string }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
|
|
@ -409,29 +407,6 @@ export function sessionRowID(row: SessionRow, boundaryID?: string) {
|
|||
if (row.type === "part") return `session-part:${row.ref.messageID}:${row.ref.partID}`
|
||||
}
|
||||
|
||||
export function backgroundToolRowIndex(
|
||||
rows: SessionRow[],
|
||||
messages: SessionMessageInfo[],
|
||||
target: BackgroundToolTarget,
|
||||
beforeMessageID: string,
|
||||
) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const end = rows.findIndex((row) => row.type === "message" && row.messageID === beforeMessageID)
|
||||
return rows.slice(0, end === -1 ? rows.length : end).findLastIndex((row) => {
|
||||
if (row.type !== "part") return false
|
||||
if (row.ref.partID === target.id) return true
|
||||
const message = byID.get(row.ref.messageID)
|
||||
if (message?.type !== "assistant") return false
|
||||
const part = resolvePart(message, row.ref.partID)
|
||||
return (
|
||||
part?.type === "tool" &&
|
||||
part.name.toLowerCase() === "shell" &&
|
||||
part.state.status !== "streaming" &&
|
||||
part.state.metadata?.shellID === target.id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
if (row.type === "message") {
|
||||
const message = messages.get(row.messageID)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
messageBoundaryIDs,
|
||||
reduceSessionRows,
|
||||
|
|
@ -259,63 +258,6 @@ test("assigns stable IDs to tool rows for direct navigation", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("finds background tool launch rows for completion navigation", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "shell-1",
|
||||
name: "shell",
|
||||
state: completed({ shellID: "sh_first", status: "running" }),
|
||||
time: { created: 1 },
|
||||
},
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-1",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "running" }),
|
||||
time: { created: 2 },
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "completion-1",
|
||||
text: "First background run completed",
|
||||
description: "First run",
|
||||
time: { created: 3 },
|
||||
},
|
||||
assistant("assistant-3", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-2",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "running" }),
|
||||
time: { created: 4 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-foreground",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "completed" }),
|
||||
time: { created: 5 },
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "completion-2",
|
||||
text: "Second background run completed",
|
||||
description: "Second run",
|
||||
time: { created: 6 },
|
||||
},
|
||||
]
|
||||
const rows = reduceSessionRows(messages)
|
||||
|
||||
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "shell-1" }, "completion-2")).toBe(0)
|
||||
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "sh_first" }, "completion-2")).toBe(0)
|
||||
})
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
|
|
@ -620,14 +562,3 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses
|
|||
function pending() {
|
||||
return { status: "streaming" as const, input: "" }
|
||||
}
|
||||
|
||||
function completed(
|
||||
metadata: Record<string, string>,
|
||||
): Extract<SessionMessageAssistantTool["state"], { status: "completed" }> {
|
||||
return {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "Background" }],
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
137
packages/tui/test/completion-notices.test.tsx
Normal file
137
packages/tui/test/completion-notices.test.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test.each([40, 120])("completion notices do not navigate at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const session = {
|
||||
id: "ses_notices",
|
||||
title: "Completion notices",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const notices = [
|
||||
{ source: "shell", state: "completed", shellID: "shell-1", label: "Shell finished", description: "Done" },
|
||||
{
|
||||
source: "shell",
|
||||
state: "error",
|
||||
jobID: "shell-1",
|
||||
label: "Shell failed",
|
||||
description: "Long command ".repeat(30),
|
||||
},
|
||||
{
|
||||
source: "shell",
|
||||
state: "cancelled",
|
||||
shellID: "shell-1",
|
||||
label: "Shell cancelled",
|
||||
description: "Cancelled command",
|
||||
},
|
||||
{ source: "subagent", state: "completed", sessionID: "child-1", label: "Subagent finished", description: "Done" },
|
||||
{ source: "subagent", state: "error", sessionID: "child-1", label: "Subagent failed", description: "Failed" },
|
||||
{
|
||||
source: "subagent",
|
||||
state: "cancelled",
|
||||
sessionID: "child-1",
|
||||
label: "Subagent cancelled",
|
||||
description: "Cancelled",
|
||||
},
|
||||
]
|
||||
const messages = [
|
||||
{ id: "user-0", type: "user", text: "Run background tasks", time: { created: 0 } },
|
||||
{
|
||||
id: "assistant-0",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "shell-1",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "echo done", background: true },
|
||||
content: [{ type: "text", text: "Running" }],
|
||||
metadata: { shellID: "shell-1" },
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `history-${index}`,
|
||||
type: "user",
|
||||
text: `History message ${index}`,
|
||||
time: { created: index + 3 },
|
||||
})),
|
||||
...notices.map(({ label, description, ...metadata }, index) => ({
|
||||
id: `notice-${index}`,
|
||||
type: "synthetic",
|
||||
text: label,
|
||||
description,
|
||||
metadata,
|
||||
time: { created: index + 30 },
|
||||
})),
|
||||
]
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
|
||||
return undefined
|
||||
}, createEventStream())
|
||||
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({ animations: false, tabs: { enabled: false } }),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
args: { sessionID: session.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await setup.waitForFrame((frame) => frame.includes("Subagent cancelled"))
|
||||
await setup.waitForVisualIdle()
|
||||
const find = (root: Renderable): ScrollBoxRenderable | undefined =>
|
||||
root instanceof ScrollBoxRenderable && root.getRenderable("history-19")
|
||||
? root
|
||||
: root.getChildren().map(find).find(Boolean)
|
||||
const scroll = find(setup.renderer.root)
|
||||
if (!scroll) throw new Error("Session scrollbox not found")
|
||||
expect(scroll.scrollTop).toBeGreaterThan(0)
|
||||
const before = scroll.scrollTop
|
||||
for (const notice of notices) {
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
const y = lines.findIndex((line) => line.includes(notice.label))
|
||||
expect(y).toBeGreaterThanOrEqual(0)
|
||||
const x = lines[y].indexOf(notice.label)
|
||||
await setup.mockMouse.click(x + 1, y)
|
||||
await setup.waitForVisualIdle()
|
||||
expect(scroll.scrollTop).toBe(before)
|
||||
expect(setup.renderer.currentFocusedRenderable?.id).toBe(scroll.id)
|
||||
expect(setup.captureCharFrame()).toContain(notice.label)
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue