mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-20 19:44:34 +00:00
feat(transcript): native Code Mode presentation (1/8) (#789)
## Increment over existing execute display Execute already renders as a tool card through the generic renderer. This PR adds a specialized presentation of its script and native nested-call metadata (names, inputs, statuses), plus truncation information. It reuses the existing aggregate output and image surfaces. It does not introduce Code Mode execution or mission orchestration (#673). ## Purpose First PR in the V2 product integration series. Present execute scripts, nested tool calls and progress, failure and native truncation information. Preserve shared native output/image rendering and copying. ## Contract and implementation Verified upstream Code Mode contracts at tags 2.0.7 and 2.0.18: code input, metadata.toolCalls (tool/status/input), metadata.error. No API calls or runtime minimum changes. Small dedicated parser and renderer, scoped CSS, nine locales. Subsequent PRs will stack on this branch. ## Local validation - UI typecheck passed. - Four isolated Chromium scenarios passed: native events, history reload, nested errors, images, collapse and narrow layout. - Rendered capture inspected at 380px. - git diff --check passed. ## Gatekeeper Independent review pending; findings will be fixed and re-reviewed until zero. User requested all PRs remain unmerged. Do not merge. Size note: existing tool-call.css is above the source warning threshold; changed only by adding the scoped import.
This commit is contained in:
parent
0983db3c30
commit
97c361c33f
19 changed files with 217 additions and 1 deletions
|
|
@ -26,6 +26,7 @@
|
|||
- Tags and numeric/context/token labels also use rounded geometry via `--chip-radius` (`--pill-radius` is an alias). Register badge variants in `styles/components/badges.css`; use `.badge-shape` for utility-styled labels rather than adding a local radius.
|
||||
- The message-content popup and Chat settings share `components/transcript-visibility.ts` and the semantic icons from `components/message-content-icons.ts`; tool presentation metadata lives independently of renderers in `components/tool-call/tool-presentation.ts`. Popup styles live in `styles/components/transcript-filters.css`.
|
||||
- Tool-result images retain native `state.content` and render through the shared `components/tool-call/output-images.tsx` surface, including MCP and specialized tools. Keep image bytes out of text projections (copy/search/speech); image styles live in `styles/messaging/tool-call/images.css`.
|
||||
- Code Mode reads native `metadata.toolCalls` in `tool-call/renderers/execute-data.ts`; keep call-list styling in `styles/messaging/tool-call/execute.css`, with native image/output ownership in the shared tool shell.
|
||||
- Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in `styles/messaging/session-timeline-rail.css`. Hide the rail and force conversation-header actions into their overflow menu below 420 CSS px of the `session-center` container on all platforms. Keep that width-only force separate from measured header density so trial layouts can expand again above the breakpoint; filters anchor and return focus to the visible menu. Docked drawers reserve a minimum conversation width of 390 CSS px before overlaying. The composer worktree selector becomes a compact arrow-only cell below 460 CSS px of that conversation container (not the remaining composer width); keep its accessible label and styles in `styles/components/prompt-context-controls.css`. Timeline visibility must not depend on measured header density, changing status controls or viewport height; preserve the saved visibility preference.
|
||||
- Timeline geometry uses the cached structural index independently of transcript/excerpt loading. Aggregate tool markers retain the outline's representative tool name so their icon does not fall back to `other`; preserve that metadata through projection and persisted-index revalidation. Hover/focus previews render bounded Markdown, prefetch visible-nearby excerpts and prioritize the hovered marker through `stores/timeline-previews.ts`; never mount whole message/tool cards or fetch transcript windows for hover. Keep the viewport-bounded surface in `styles/messaging/timeline-preview.css`.
|
||||
- Document any new styling conventions or directory additions in this file so future changes remain consistent.
|
||||
|
|
|
|||
31
dev-docs/V2_PRODUCT_PLAN.md
Normal file
31
dev-docs/V2_PRODUCT_PLAN.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# V2 product integration series
|
||||
|
||||
Base: `dev@a77e37ef` (OpenCode client/plugin 2.0.18; technical minimum 2.0.7).
|
||||
The user requested eight individually reviewed PRs, all kept open and unmerged.
|
||||
Implementation is coordinated in one conversation. Each branch builds on the
|
||||
preceding reviewed branch; each PR describes only its own delta.
|
||||
|
||||
| Order | Branch | Deliverable |
|
||||
| --- | --- | --- |
|
||||
| 1 | `feat/v2-01-code-mode` | Native execute script/call presentation |
|
||||
| 2 | `feat/v2-02-web-results` | Web search results and provider form |
|
||||
| 3 | `feat/v2-03-skills` | Explicit native skill attachments |
|
||||
| 4 | `feat/v2-04-web-settings` | Web search configuration and credentials |
|
||||
| 5 | `feat/v2-05-provider-accounts` | Individual provider accounts |
|
||||
| 6 | `feat/v2-06-plugin-updates` | Native plugin check/update controls |
|
||||
| 7 | `feat/v2-07-mcp-code-mode` | Per-server default/on/off Code Mode |
|
||||
| 8 | `feat/v2-08-usage` | Native usage dashboard |
|
||||
|
||||
Every PR needs focused tests, rendered evidence for UI, and an independent
|
||||
gatekeeper review published on GitHub. Findings are corrected and re-reviewed
|
||||
until none remain. No PR is merged. Native mutations use isolated test data;
|
||||
the shared user daemon is not a test target. Runtime requirements follow
|
||||
demonstrated contracts, not the dependency pin. Track any adjustments and
|
||||
capability boundaries in the relevant PR rather than silently shrinking scope.
|
||||
|
||||
## Contract notes
|
||||
|
||||
- Execute: OpenCode 2.0.7 and 2.0.18 `packages/core/src/codemode/tool.ts` expose
|
||||
`input.code`, `metadata.toolCalls[]` (`tool`, `status`, optional `input`),
|
||||
and `metadata.error`. Individual calls have no result/error text field.
|
||||
Native text/file content remains owned by the shared tool output pipeline.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { ToolState } from "../../../types/tool-state"
|
||||
import { readToolStatePayload } from "../utils"
|
||||
|
||||
export interface ExecuteCall {
|
||||
tool: string
|
||||
status: "running" | "completed" | "error"
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// Read historical and live metadata defensively. Do not infer success for an
|
||||
// unknown status or unpack output.files (native images use the shared surface).
|
||||
export function executeData(state: ToolState | undefined) {
|
||||
const { input, metadata } = readToolStatePayload(state)
|
||||
const calls: ExecuteCall[] = []
|
||||
if (Array.isArray(metadata.toolCalls)) {
|
||||
for (const value of metadata.toolCalls) {
|
||||
if (!record(value) || typeof value.tool !== "string"
|
||||
|| !["running", "completed", "error"].includes(String(value.status))) continue
|
||||
calls.push({ tool: value.tool, status: value.status as ExecuteCall["status"],
|
||||
...(record(value.input) ? { input: value.input } : {}) })
|
||||
}
|
||||
}
|
||||
return { code: typeof input.code === "string" ? input.code : "", calls,
|
||||
failed: metadata.error === true, truncated: metadata.truncated === true,
|
||||
outputPath: typeof metadata.outputPath === "string" ? metadata.outputPath : undefined }
|
||||
}
|
||||
|
||||
export function executeSummary(calls: readonly ExecuteCall[]): string {
|
||||
const counts = new Map<string, number>()
|
||||
for (const call of calls) counts.set(call.tool, (counts.get(call.tool) ?? 0) + 1)
|
||||
return [...counts].slice(0, 4).map(([tool, count]) => count > 1 ? `${tool} ×${count}` : tool).join(", ")
|
||||
+ (counts.size > 4 ? ` +${counts.size - 4}` : "")
|
||||
}
|
||||
49
packages/ui/src/components/tool-call/renderers/execute.tsx
Normal file
49
packages/ui/src/components/tool-call/renderers/execute.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Index, Show, createMemo } from "solid-js"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { defaultRenderer } from "./default"
|
||||
import { executeData, executeSummary } from "./execute-data"
|
||||
import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, limitToolOutputForRender, limitToolTitleForRender } from "../utils"
|
||||
import { getDefaultToolSearchText } from "../search-text"
|
||||
|
||||
export const executeRenderer: ToolRenderer = {
|
||||
tools: ["execute"],
|
||||
getTitle({ toolState, t }) {
|
||||
const data = executeData(toolState())
|
||||
const summary = executeSummary(data.calls)
|
||||
return limitToolTitleForRender(`${t("toolCall.execute.script")}${summary ? ` · ${summary}` : ""}`)
|
||||
},
|
||||
getSearchText(context) {
|
||||
const data = executeData(context.toolState)
|
||||
return [...getDefaultToolSearchText(context), data.code,
|
||||
...data.calls.flatMap(call => [call.tool, formatUnknownForCopy(call.input)?.text ?? ""])]
|
||||
},
|
||||
getOutputChrome: defaultRenderer.getOutputChrome,
|
||||
renderBody(context) {
|
||||
const data = createMemo(() => executeData(context.toolState()))
|
||||
const markdown = (text: string, language: string, key: string) => context.renderMarkdown({
|
||||
content: ensureMarkdownContent(limitToolOutputForRender(text), language, true) ?? "",
|
||||
cacheKey: `execute-${key}`, disableHighlight: context.toolState()?.status === "running",
|
||||
wrap: context.outputWrapEnabled?.() ?? true,
|
||||
})
|
||||
return <div class="tool-call-execute">
|
||||
<Show when={data().code}>{code => <section aria-label={context.t("toolCall.execute.script")}>
|
||||
{markdown(code(), "javascript", "script")}
|
||||
</section>}</Show>
|
||||
<Show when={data().calls.length}>
|
||||
<section aria-label={context.t("toolCall.execute.calls")}>
|
||||
<ol class="execute-calls"><Index each={data().calls.slice(0, 200)}>{(call, index) =>
|
||||
<li><details><summary>
|
||||
<span class="execute-call-name">{call().tool}</span>
|
||||
<span class="execute-call-status" data-status={call().status}>{context.t(`toolCall.status.${call().status}`)}</span>
|
||||
</summary><Show when={call().input}>{input => markdown(formatUnknownForRender(input())?.text ?? "", "json", `call-${index}`)}</Show>
|
||||
</details></li>
|
||||
}</Index></ol>
|
||||
<Show when={data().calls.length > 200}><p>{context.t("toolCall.execute.more", { count: data().calls.length - 200 })}</p></Show>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={data().failed}><p class="execute-call-status" data-status="error">{context.t("toolCall.status.error")}</p></Show>
|
||||
{defaultRenderer.renderBody(context)}
|
||||
<Show when={data().truncated}><p>{context.t("toolCall.execute.truncated")} <span>{data().outputPath}</span></p></Show>
|
||||
</div>
|
||||
},
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { applyPatchRenderer } from "./renderers/apply-patch"
|
|||
import { bashRenderer } from "./renderers/bash"
|
||||
import { defaultRenderer } from "./renderers/default"
|
||||
import { editRenderer } from "./renderers/edit"
|
||||
import { executeRenderer } from "./renderers/execute"
|
||||
import { invalidRenderer } from "./renderers/invalid"
|
||||
import { patchRenderer } from "./renderers/patch"
|
||||
import { questionRenderer } from "./renderers/question"
|
||||
|
|
@ -21,7 +22,7 @@ const renderers: Record<string, ToolRenderer> = {
|
|||
bash: bashRenderer, read: readRenderer, write: writeRenderer, edit: editRenderer,
|
||||
patch: patchRenderer, apply_patch: applyPatchRenderer, webfetch: webfetchRenderer,
|
||||
glob: searchRenderer, grep: searchRenderer, todowrite: todoRenderer, task: taskRenderer,
|
||||
skill: skillRenderer, question: questionRenderer, invalid: invalidRenderer,
|
||||
skill: skillRenderer, question: questionRenderer, invalid: invalidRenderer, execute: executeRenderer,
|
||||
}
|
||||
|
||||
export function resolveToolRenderer(toolName: string): ToolRenderer {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "Verschachtelte Werkzeugaufrufe",
|
||||
"toolCall.execute.more": "{count} weitere Aufrufe ausgeblendet.",
|
||||
"toolCall.execute.truncated": "Native Ausgabe gekürzt. Vollständige Ausgabe:",
|
||||
"toolCall.pending.waitingToRun": "Wartet auf Ausführung...",
|
||||
"toolCall.error.label": "Fehler:",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "Nested tool calls",
|
||||
"toolCall.execute.more": "{count} additional calls omitted.",
|
||||
"toolCall.execute.truncated": "Native output truncated. Full output:",
|
||||
"toolCall.pending.waitingToRun": "Waiting to run...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "Llamadas a herramientas anidadas",
|
||||
"toolCall.execute.more": "Se omitieron {count} llamadas adicionales.",
|
||||
"toolCall.execute.truncated": "Salida nativa truncada. Salida completa:",
|
||||
"toolCall.pending.waitingToRun": "Esperando para ejecutar...",
|
||||
"toolCall.error.label": "Error:",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "Appels d’outils imbriqués",
|
||||
"toolCall.execute.more": "{count} appels supplémentaires masqués.",
|
||||
"toolCall.execute.truncated": "Sortie native tronquée. Sortie complète :",
|
||||
"toolCall.pending.waitingToRun": "En attente d'exécution...",
|
||||
"toolCall.error.label": "Erreur :",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "קריאות כלים מקוננות",
|
||||
"toolCall.execute.more": "{count} קריאות נוספות הוסתרו.",
|
||||
"toolCall.execute.truncated": "הפלט המקורי קוצר. הפלט המלא:",
|
||||
"toolCall.pending.waitingToRun": "ממתין להרצה...",
|
||||
"toolCall.error.label": "שגיאה:",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "入れ子のツール呼び出し",
|
||||
"toolCall.execute.more": "さらに {count} 件の呼び出しを省略しました。",
|
||||
"toolCall.execute.truncated": "ネイティブ出力が切り詰められました。完全な出力:",
|
||||
"toolCall.pending.waitingToRun": "実行待ち...",
|
||||
"toolCall.error.label": "エラー:",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "नेस्टेड उपकरण कलहरू",
|
||||
"toolCall.execute.more": "थप {count} कलहरू लुकाइएका छन्।",
|
||||
"toolCall.execute.truncated": "नेटिभ आउटपुट छोट्याइएको छ। पूरा आउटपुट:",
|
||||
"toolCall.pending.waitingToRun": "चलाउनको लागि पर्खँदै...",
|
||||
"toolCall.error.label": "त्रुटि:",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "Вложенные вызовы инструментов",
|
||||
"toolCall.execute.more": "Скрыто дополнительных вызовов: {count}.",
|
||||
"toolCall.execute.truncated": "Нативный вывод сокращён. Полный вывод:",
|
||||
"toolCall.pending.waitingToRun": "Ожидание запуска…",
|
||||
"toolCall.error.label": "Ошибка:",
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "İç içe araç çağrıları",
|
||||
"toolCall.execute.more": "{count} ek çağrı gizlendi.",
|
||||
"toolCall.execute.truncated": "Yerel çıktı kısaltıldı. Tam çıktı:",
|
||||
"toolCall.pending.waitingToRun": "Çalıştırılmayı bekliyor...",
|
||||
"toolCall.error.label": "Hata:",
|
||||
"toolCall.header.copyTitle": "Araç çağrısı başlığını kopyala",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const toolCallMessages = {
|
||||
"toolCall.execute.script": "Code Mode",
|
||||
"toolCall.execute.calls": "嵌套工具调用",
|
||||
"toolCall.execute.more": "已省略另外 {count} 个调用。",
|
||||
"toolCall.execute.truncated": "原生输出已截断。完整输出:",
|
||||
"toolCall.pending.waitingToRun": "等待运行...",
|
||||
"toolCall.error.label": "错误:",
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
@import "./tool-call/todo.css";
|
||||
@import "./tool-call/task.css";
|
||||
@import "./tool-call/skill.css";
|
||||
@import "./tool-call/execute.css";
|
||||
|
||||
.tool-call-message {
|
||||
@apply flex flex-col gap-0 p-0 w-full;
|
||||
|
|
|
|||
6
packages/ui/src/styles/messaging/tool-call/execute.css
Normal file
6
packages/ui/src/styles/messaging/tool-call/execute.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.tool-call-execute { display: grid; gap: 0.5rem; min-width: 0; }
|
||||
.execute-calls { list-style: none; margin: 0; padding: 0; }
|
||||
.execute-calls summary { display: flex; gap: 0.75rem; cursor: pointer; padding: 0.35rem 0; }
|
||||
.execute-call-name { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: var(--font-mono); }
|
||||
.execute-call-status { color: var(--text-secondary); flex-shrink: 0; }
|
||||
.execute-call-status[data-status="error"] { color: var(--status-error); }
|
||||
|
|
@ -82,4 +82,27 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider>
|
|||
emit("session.tool.success", { id: "tool", content: content("mixed"), executed: true })
|
||||
},
|
||||
reload: () => loadMessages(instanceId, sessionId, { force: true }),
|
||||
execute: async (phase: string) => {
|
||||
const input = { code: "const results = await Promise.all([tools.paint({ prompt: 'sky' }), tools.fail({})]);\nreturn results" }
|
||||
const metadata = { toolCalls: [
|
||||
{ tool: "paint", status: phase === "running" ? "running" : "completed", input: { prompt: "sky" } },
|
||||
{ tool: "fail", status: phase === "running" ? "running" : "error", input: { test: true } },
|
||||
], ...(phase === "running" ? {} : { error: true, truncated: true, outputPath: "/fixture/output.txt" }) }
|
||||
if (phase === "progress") {
|
||||
metadata.toolCalls.push({ tool: "third", status: "running", input: { test: true } })
|
||||
emit("session.tool.progress", { id: "tool", metadata })
|
||||
return
|
||||
}
|
||||
if (phase === "running") {
|
||||
emit("session.step.started", { agent: "build", model, started: 1 })
|
||||
emit("session.tool.input.started", { id: "tool", name: "execute" })
|
||||
emit("session.tool.called", { id: "tool", input })
|
||||
emit("session.tool.progress", { id: "tool", metadata })
|
||||
return
|
||||
}
|
||||
messages = [{ ...completed("mixed", "execute"), content: [{ id: "tool", type: "tool", name: "execute",
|
||||
time: { created: 1, completed: time }, state: { status: "completed", input, metadata, content: content("mixed") } }] }]
|
||||
if (phase === "history") await loadMessages(instanceId, sessionId, { force: true })
|
||||
else emit("session.tool.success", { id: "tool", content: content("mixed"), metadata, executed: true })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,3 +88,30 @@ test("failed and unsupported image sources have a fallback and a later replaceme
|
|||
}
|
||||
assert.equal(await page.evaluate(() => (window as any).unexpectedNavigation), undefined)
|
||||
}))
|
||||
|
||||
test("Code Mode renders native progress, nested errors, script, images and historical output", async () => withPage(async page => {
|
||||
await page.evaluate(() => (window as any).fixture.execute("running"))
|
||||
await page.locator(".execute-calls").waitFor()
|
||||
assert.equal(await page.locator('.execute-call-status[data-status="running"]').count(), 2)
|
||||
const firstCall = page.locator(".execute-calls summary").first()
|
||||
await firstCall.click()
|
||||
await firstCall.focus()
|
||||
await page.evaluate(() => (window as any).fixture.execute("progress"))
|
||||
assert.equal(await page.locator(".execute-calls li").count(), 3)
|
||||
assert.equal(await page.locator(".execute-calls details").first().getAttribute("open"), "")
|
||||
assert.equal(await firstCall.evaluate(el => el === document.activeElement), true)
|
||||
await page.evaluate(() => (window as any).fixture.execute("complete"))
|
||||
await decodedImages(page, 2)
|
||||
await page.locator('.execute-call-status[data-status="completed"]').waitFor()
|
||||
assert.equal(await page.locator('.execute-calls [data-status="error"]').count(), 1)
|
||||
await page.locator(".execute-calls summary").first().click()
|
||||
await page.getByText('"sky"', { exact: false }).first().waitFor()
|
||||
assert.match(await page.locator(".tool-call-execute").innerText(), /Promise\.all/)
|
||||
assert.match(await page.locator(".tool-call-execute").innerText(), /\/fixture\/output.txt/)
|
||||
await page.evaluate(() => (window as any).fixture.execute("history"))
|
||||
await decodedImages(page, 2)
|
||||
assert.equal(await page.locator('.execute-calls [data-status="error"]').count(), 1)
|
||||
await page.setViewportSize({ width: 380, height: 1000 })
|
||||
assert.equal(await page.locator("main").evaluate(el => el.scrollWidth <= el.clientWidth), true)
|
||||
if (process.env.CODENOMAD_EXECUTE_CAPTURE) await page.screenshot({ path: process.env.CODENOMAD_EXECUTE_CAPTURE, fullPage: true })
|
||||
}))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue