fix(tui): remove completion notice links (#47426)

Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-09-04 22:46:28 -04:00 committed by GitHub
parent 86ba09c6e0
commit 8d1a9799f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 139 additions and 139 deletions

View file

@ -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>

View file

@ -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)

View file

@ -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,
}
}

View 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()
}
})