From 324f042747a1ec86d6d5b905fdba6c200c154448 Mon Sep 17 00:00:00 2001 From: Brian H Date: Wed, 7 Oct 2026 01:33:32 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(playbook):=20agent=20panel=20=E2=80=94?= =?UTF-8?q?=20Back=20button,=20editable=20starting=20point,=20tool-call=20?= =?UTF-8?q?detail,=20revision=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - each tab is a history entry (Back no longer leaves kr0ki); the agent tab is kept alive - starting point: edit, render in place, apply as a revision, open in editor, download - tool calls show arguments, result and a real success/failure, and link to the revision they produced - revision timeline: diff vs parent, snapshot, compare, restore (non-destructive), fork, download - the agent now starts every request from the CURRENT revision (it used to keep the original handoff) - 'Clear panels' actually clears (and is renamed Hide panels); 'Clear' is Clear chat; Context panel lists what the agent receives - server: panels carry toolCallId; the diagram-context prompt no longer tells the model to 'review' every time Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- containers/kr0ki-storyb00k-agent/server.py | 11 +- .../kr0ki-storyb00k-agent/test_panels.py | 18 ++ playbook/src/App.assurance.test.js | 1 + playbook/src/App.test.js | 15 + playbook/src/App.vue | 10 +- playbook/src/components/RevisionTimeline.vue | 120 ++++++++ playbook/src/components/StoryB00k.vue | 283 +++++++++++++----- .../components/StoryB00kAgentPanel.test.js | 114 +++++++ .../src/components/StoryB00kPanel.test.js | 4 +- playbook/src/lib/__tests__/lineDiff.test.js | 15 + .../src/lib/__tests__/renderSource.test.js | 24 ++ .../src/lib/__tests__/revisionGraph.test.js | 13 +- .../src/lib/__tests__/viewHistory.test.js | 51 ++++ playbook/src/lib/lineDiff.js | 32 ++ playbook/src/lib/renderSource.js | 31 ++ playbook/src/lib/revisionGraph.js | 17 +- playbook/src/lib/viewHistory.js | 36 +++ 17 files changed, 711 insertions(+), 84 deletions(-) create mode 100644 containers/kr0ki-storyb00k-agent/test_panels.py create mode 100644 playbook/src/components/RevisionTimeline.vue create mode 100644 playbook/src/components/StoryB00kAgentPanel.test.js create mode 100644 playbook/src/lib/__tests__/lineDiff.test.js create mode 100644 playbook/src/lib/__tests__/renderSource.test.js create mode 100644 playbook/src/lib/__tests__/viewHistory.test.js create mode 100644 playbook/src/lib/lineDiff.js create mode 100644 playbook/src/lib/renderSource.js create mode 100644 playbook/src/lib/viewHistory.js diff --git a/containers/kr0ki-storyb00k-agent/server.py b/containers/kr0ki-storyb00k-agent/server.py index 428ed2a..84b3cb1 100644 --- a/containers/kr0ki-storyb00k-agent/server.py +++ b/containers/kr0ki-storyb00k-agent/server.py @@ -468,7 +468,7 @@ def render_recommendation_samples(recommendations): return panels -def panel_from_tool_result(name, arguments, content_type, result): +def panel_from_tool_result(name, arguments, content_type, result, tool_call_id=None): """Preserve binary render output rather than corrupting PNG bytes as UTF-8.""" # Tool-name → editor format/route mapping: the k8s tools take a `manifest` # arg and have no `format` argument, so without this the EDIT button would @@ -486,6 +486,7 @@ def panel_from_tool_result(name, arguments, content_type, result): panel = { "kind": "render" if name.startswith("render_") else "query-result", "toolName": name, + "toolCallId": tool_call_id, # lets the UI tie this picture to the tool call that made it "source": { "text": arguments.get("source") or arguments.get("manifest"), "format": panel_format, @@ -984,14 +985,14 @@ def _run(self, stream, payload, thread_id, draft, run_log): if diagram_context and diagram_context.get("source"): ctx = diagram_context context_msg = ( - f"DIAGRAM CONTEXT (from Code Editor handoff):\n" + f"CURRENT DIAGRAM (the user's working state; the Code Editor handoff or the revision they restored or last edited):\n" f"Title: {ctx.get('title', 'Untitled')}\n" f"Format: {ctx.get('format', 'unknown')}\n" f"Detected Type: {ctx.get('detectedType', 'unknown')}\n" f"Output: {ctx.get('output', 'svg')}\n" f"\nDiagram Source Code:\n```\n{ctx['source']}\n```\n" - f"\nThe user wants you to review this diagram and suggest improvements. " - f"Analyze both the code structure and suggest specific improvements." + f"\nApply the user's next request to THIS diagram: start from this source and change only what they ask for. " + f"If they ask for a review, analyze the code structure and suggest specific improvements." ) messages.append({"role": "user", "content": context_msg}) # Clear the context after injecting it (one-time use) @@ -1301,7 +1302,7 @@ def _run(self, stream, payload, thread_id, draft, run_log): content_type, result = http_call(method, url, body) if call_key: completed_calls.add(call_key) - panel = panel_from_tool_result(name, arguments, content_type, result) + panel = panel_from_tool_result(name, arguments, content_type, result, tool_call_id) tool_content = panel["content"] or "Rendered binary image." tool_image_data_url = panel.get("imageDataUrl") tool_ok = True diff --git a/containers/kr0ki-storyb00k-agent/test_panels.py b/containers/kr0ki-storyb00k-agent/test_panels.py new file mode 100644 index 0000000..ed71d24 --- /dev/null +++ b/containers/kr0ki-storyb00k-agent/test_panels.py @@ -0,0 +1,18 @@ +import unittest + +import server + + +class PanelTest(unittest.TestCase): + def test_a_render_panel_names_the_tool_call_that_made_it(self): + panel = server.panel_from_tool_result("render_diagram", {"format": "d2", "source": "a -> b"}, "image/svg+xml", b"", "call-7") + self.assertEqual(panel["toolCallId"], "call-7") + self.assertEqual(panel["source"]["text"], "a -> b") + + def test_the_tool_call_id_is_optional(self): + panel = server.panel_from_tool_result("render_diagram", {"format": "d2", "source": "x"}, "image/svg+xml", b"") + self.assertIsNone(panel["toolCallId"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/playbook/src/App.assurance.test.js b/playbook/src/App.assurance.test.js index 1a58246..ae6fa36 100644 --- a/playbook/src/App.assurance.test.js +++ b/playbook/src/App.assurance.test.js @@ -14,6 +14,7 @@ const AssuranceStub = { } beforeEach(() => { + window.history.replaceState(null, '', '#/gallery') // deep links persist across tests in jsdom sessionStorage.clear() localStorage.clear() Element.prototype.scrollIntoView = vi.fn() diff --git a/playbook/src/App.test.js b/playbook/src/App.test.js index 1129407..73217e9 100644 --- a/playbook/src/App.test.js +++ b/playbook/src/App.test.js @@ -12,6 +12,7 @@ const StoryStub = { name: 'StoryB00k', props: { planner: Boolean, threadId: Stri let renders beforeEach(() => { + window.history.replaceState(null, '', '#/gallery') // deep links persist across tests in jsdom renders = [] Element.prototype.scrollIntoView = vi.fn() vi.stubGlobal('CSS', { escape: (s) => s }) @@ -42,6 +43,20 @@ describe('App navigation', () => { w.unmount() }) + it('makes each tab a history entry, so Back returns to the previous tab instead of leaving kr0ki', async () => { + const w = await mountApp() + await w.find('[data-testid="tab-editor"]').trigger('click') + await w.find('[data-testid="tab-agent"]').trigger('click') + expect(window.location.hash).toBe('#/agent') + expect(w.find('[data-testid="tab-agent"]').classes()).toContain('active') + window.history.back() + await new Promise((r) => setTimeout(r, 50)) // jsdom fires popstate asynchronously + await flushPromises() + expect(window.location.hash).toBe('#/editor') + expect(w.find('[data-testid="tab-editor"]').classes()).toContain('active') + w.unmount() + }) + it('shows the diagram-type tree in the sidebar, and selecting a type opens the editor and renders it', async () => { const w = await mountApp() expect(w.find('[data-testid="catalog-tree"]').exists()).toBe(true) diff --git a/playbook/src/App.vue b/playbook/src/App.vue index b7eb9a6..ce36465 100644 --- a/playbook/src/App.vue +++ b/playbook/src/App.vue @@ -10,6 +10,7 @@ import ProjectsView from './components/ProjectsView.vue' import AssuranceView from './components/AssuranceView.vue' import { connectUiBridge, plannerSessionId } from './lib/uiBridge.js' import { loadLspUrls, saveLspUrls } from './lib/lsp.js' +import { bindViewHistory } from './lib/viewHistory.js' // Version from Cargo.toml (injected at build time by Vite) const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0' @@ -23,6 +24,10 @@ const viewMode = ref('gallery') // The planner chat stays mounted once opened, so its conversation survives switching tabs. const plannerMounted = ref(false) watch(viewMode, (v) => { if (v === 'planner') plannerMounted.value = true }) +// Each tab is a browser history entry (#/agent, #/editor, ...), so Back returns to the previous tab instead of leaving kr0ki. +const viewHistory = bindViewHistory({ getView: () => viewMode.value, setView: (v) => { viewMode.value = v } }) +watch(viewMode, (v) => viewHistory.push(v)) +onBeforeUnmount(() => viewHistory.stop()) // Source handed over from StoryB00k's EDIT button (agent-rendered diagram). const editedSource = ref('') const editedRoute = ref(null) @@ -364,7 +369,10 @@ onMounted(async () => { @open-in-editor="openInEditor" @agent-handoff="agentHandoff" /> - + + + + +// Revision history as a plain timeline: one row per state of the diagram, oldest first, with what changed (+/- lines against its +// parent), the tool call behind it, a snapshot of the picture, and the actions that matter: view, compare, restore, fork, download. +// Restoring only changes which revision is current; nothing is ever deleted, so every state stays recoverable. +import { computed, ref, watch } from 'vue' +import { diffLines, diffStats } from '../lib/lineDiff.js' +import { parentOf } from '../lib/revisionGraph.js' +import { downloadText, sourceFilename } from '../lib/renderSource.js' + +const props = defineProps({ + graph: { type: Object, required: true }, + /** tool call id -> {name, ok}, to label the row with its outcome */ + toolCalls: { type: Object, default: () => ({}) }, + /** revision to open (e.g. from a tool call's "revision" link) */ + focusId: { type: String, default: '' }, +}) +const emit = defineEmits(['restore', 'fork']) + +const viewId = ref(props.focusId) +watch(() => props.focusId, (id) => { if (id) viewId.value = id }) +const compareId = ref('') + +const rows = computed(() => + props.graph.nodes.map((n, i) => { + const parent = parentOf(props.graph, n.id) + const stats = parent ? diffStats(parent.source, n.source) : { added: n.source ? n.source.split('\n').length : 0, removed: 0 } + return { n, i, stats, active: n.id === props.graph.activeId, tool: n.toolCallId ? props.toolCalls[n.toolCallId] : null } + }), +) +const byId = (id) => props.graph.nodes.find((x) => x.id === id) +const viewed = computed(() => byId(viewId.value)) +const compared = computed(() => byId(compareId.value)) +// Compare the chosen revision to the one being viewed (or to the current one when only a compare target is set). +const diff = computed(() => { + if (!compared.value) return [] + const base = viewed.value || byId(props.graph.activeId) + return diffLines(compared.value.source, base.source) +}) +const time = (iso) => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +const kindLabel = { root: 'start', prompt: 'agent', edit: 'edit', fork: 'fork' } + +function view(n) { viewId.value = viewId.value === n.id ? '' : n.id } +function compare(n) { compareId.value = compareId.value === n.id ? '' : n.id } +function download(n) { downloadText(sourceFilename(n.format, `revision-${n.id.slice(-6)}`), n.source) } + + + + + diff --git a/playbook/src/components/StoryB00k.vue b/playbook/src/components/StoryB00k.vue index 0d75cf7..5026088 100644 --- a/playbook/src/components/StoryB00k.vue +++ b/playbook/src/components/StoryB00k.vue @@ -2,10 +2,11 @@ import { computed, reactive, ref, watch } from 'vue' import { useChat } from '@synoped/ag-ui-vue' import StoryB00kPanel from './StoryB00kPanel.vue' -import RevisionFlow from './RevisionFlow.vue' +import RevisionTimeline from './RevisionTimeline.vue' +import { downloadText, renderSvg, sourceFilename } from '../lib/renderSource.js' import { createRevisionGraph, activeNode, addPromptNode, addEditNode, - checkoutNode, forkFrom, serialize as serializeGraph, + checkoutNode, forkFrom, serialize as serializeGraph, nodeForToolCall, } from '../lib/revisionGraph.js' const props = defineProps({ @@ -17,6 +18,7 @@ const props = defineProps({ llmKey: { type: String, default: '' }, llmModel: { type: String, default: 'gpt-4o' }, agentUrl: { type: String, default: '' }, + rendererUrl: { type: String, default: '' }, // Editor → Agent handoff: full diagram data from the editor editorHandoff: { type: Object, default: null }, // Planner mode (gallery side panel): a chat-only column whose thread id doubles as the UI session id, so @@ -71,11 +73,44 @@ const handoffProblem = computed(() => (props.editorHandoff ? validateHandoff(pro watch(() => props.editorHandoff, (handoff) => { handoffError.value = '' startOpen.value = true + startDraft.value = handoff?.source || '' + startSvg.value = '' + startRenderState.value = '' + // A new handoff arriving while this panel is alive becomes a new revision on top of what is there (kept alive across tabs). + if (handoff?.source?.trim() && handoff.source !== activeNode(revisionGraph).source) { + const n = addEditNode(revisionGraph, { source: handoff.source, format: handoff.format, route: handoff.route || null, label: handoff.title ? `From Code Editor: ${handoff.title}` : 'From Code Editor' }) + n.detectedType = handoff.detectedType + n.output = handoff.output + n.title = handoff.title + } if (handoff) console.info('[storyb00k] handoff received:', { format: handoff.format, detectedType: handoff.detectedType, title: handoff.title, sourceLen: handoff.source?.length ?? 0, }) }) +// The starting point is editable and renderable in place: edit the code, Render to see it, and apply it as a new revision (the +// agent's next request then starts from your edit). Resets when a new handoff arrives. +const startDraft = ref(props.editorHandoff?.source || '') +const startSvg = ref('') +const startRenderState = ref('') +async function renderStart() { + const h = startingPoint.value + if (!h) return + startRenderState.value = 'rendering…' + try { + startSvg.value = await renderSvg({ rendererUrl: props.rendererUrl, format: h.format, route: h.route || null, source: startDraft.value }) + startRenderState.value = '' + } catch (err) { + startSvg.value = '' + startRenderState.value = `Render failed: ${err.message}` + } +} +function applyStartEdit() { + const h = startingPoint.value + if (!h || !startDraft.value.trim() || startDraft.value === activeRevision.value.source) return + addEditNode(revisionGraph, { source: startDraft.value, format: h.format, route: h.route || null, label: 'Edited starting point' }) + saveState.value = 'applied your edit as a new revision' +} const autoScroll = ref(true) const transcriptEl = ref(null) const chat = useChat({ @@ -171,13 +206,40 @@ async function testAgentConnection() { // Test connection on mount testAgentConnection() -const toolActivity = computed(() => Array.from(toolCallTrackers.value?.values() || []).map(t => ({ - id: t.toolCallId, - name: t.toolName, - running: t.state === 'input-streaming' || t.state === 'input-available' || t.state === 'approval-requested', - failed: t.state === 'output-error' || t.state === 'output-denied', - output: t.output, -}))) +// What each tool call did, in the order it happened: arguments, outcome, and (for renders) the revision it produced. +// A call "failed" when the tracker says so OR its result text says the tool failed / was held back (the agent reports +// those as ordinary results, so the tracker alone would call them successes). +function toolOutcome(t) { + if (t.state === 'output-error' || t.state === 'output-denied' || t.error) return 'failed' + if (t.state === 'input-streaming' || t.state === 'input-available' || t.state === 'approval-requested') return 'running' + if (/^tool \S+ failed:|was NOT run yet/.test(String(t.output || ''))) return 'failed' + return 'ok' +} +function prettyArgs(raw) { + try { return JSON.stringify(JSON.parse(raw || '{}'), null, 2) } catch { return String(raw || '') } +} +const toolActivity = computed(() => Array.from(toolCallTrackers.value?.values() || []).map((t) => { + const outcome = toolOutcome(t) + const rev = nodeForToolCall(revisionGraph, t.toolCallId) + return { + id: t.toolCallId, + name: t.toolName, + running: outcome === 'running', + failed: outcome === 'failed', + ok: outcome === 'ok', + args: prettyArgs(t.args), + output: t.output || t.error || '', + revisionId: rev?.id || '', + } +})) +const toolOutcomes = computed(() => Object.fromEntries(toolActivity.value.map((t) => [t.id, { name: t.name, ok: t.ok }]))) +const openTool = ref('') +function showToolRevision(tool) { + if (!tool.revisionId) return + showRevisions.value = true + revisionFocus.value = tool.revisionId +} +const revisionFocus = ref('') // ---- Project: the conceptual unit of work within a session ---- // The agent tracks goal/Q&A/prompts server-side (project_store.py); the UI // mirrors it so the user can see (and edit) what was asked and answered. @@ -286,18 +348,28 @@ if (props.editorHandoff) { // Watch panel renders: when the agent produces a new diagram, record it as a // prompt node (the prompt that produced it) on top of the active revision. +// Every render becomes a revision, once (keyed by the tool call that made it; by source when the panel has no id), with +// its picture so the timeline can show it later without re-rendering. +function lastUserText() { + const u = [...items.value].reverse().find((i) => i.role === 'user') + return (u?.parts || []).map((p) => (p.type === 'text' ? p.text : '')).join('').trim() +} watch(panels, (list) => { - const latest = [...list].reverse().find((p) => p.kind === 'render' && p.source?.text) - if (!latest) return - if (revisionGraph.nodes.some((n) => n.source === latest.source.text && n.kind !== 'root')) return - addPromptNode(revisionGraph, { - prompt: latest.promptUsed || latest.toolName || 'agent render', - source: latest.source.text, - format: latest.source.format || 'd2', - route: latest.source.route || null, - notes: '', - }) - saveState.value = '' + for (const p of list) { + if (p.kind !== 'render' || !p.source?.text) continue + if (p.toolCallId ? nodeForToolCall(revisionGraph, p.toolCallId) : revisionGraph.nodes.some((n) => n.source === p.source.text && n.kind !== 'root')) continue + addPromptNode(revisionGraph, { + prompt: lastUserText() || p.toolName || 'agent render', + source: p.source.text, + format: p.source.format || 'd2', + route: p.source.route || null, + notes: '', + rendered: p.imageDataUrl ? { imageDataUrl: p.imageDataUrl } : p.content ? { svg: p.content } : null, + toolCallId: p.toolCallId || null, + toolName: p.toolName || null, + }) + saveState.value = '' + } }, { deep: true }) async function persistChart(description) { @@ -319,14 +391,12 @@ async function persistChart(description) { } } +// Restoring only changes which revision is current (the agent's next request starts from it); the evidence panels and every +// other revision are left alone, so nothing is lost by going back. function checkoutRevision(nodeId) { checkoutNode(revisionGraph, nodeId) - console.info('[storyb00k] time travel →', nodeId) - // Push the restored state into the live panel stream so the dashboard shows it. - const node = activeNode(revisionGraph) - if (node.source) { - chat.state.value = { panels: [{ kind: 'render', toolName: 'time-travel', content: '', source: { text: node.source, format: node.format, route: node.route } }], drafts: drafts.value } - } + console.info('[storyb00k] restored revision →', nodeId) + saveState.value = `restored ${activeNode(revisionGraph).label}` } function forkRevision(nodeId) { @@ -388,42 +458,37 @@ async function sendMessage() { } } - // If there's an editor handoff, validate and store the diagram context first - if (props.editorHandoff) { - const herr = validateHandoff(props.editorHandoff) - if (herr) { - console.error('[storyb00k] skipping diagram context: ', herr) - } else { - try { - const contextRes = await fetch(`${agentUrl}/projects/set-diagram-context`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - threadId: threadId.value, - source: props.editorHandoff.source, - format: props.editorHandoff.format, - detectedType: props.editorHandoff.detectedType, - output: props.editorHandoff.output, - imageData: props.editorHandoff.imageData, - title: props.editorHandoff.title, - }), - }) - if (!contextRes.ok) { - const errBody = await contextRes.text().catch(() => '') - console.warn('[storyb00k] Failed to set diagram context:', contextRes.status, errBody) - handoffError.value = `Handoff failed: HTTP ${contextRes.status}` - } else { - const data = await contextRes.json().catch(() => ({})) - console.info('[storyb00k] Diagram context set for thread:', threadId.value, data) - handoffError.value = '' - } - } catch (err) { - console.error('[storyb00k] Error setting diagram context:', err) - handoffError.value = `Handoff network error: ${err.message}` + // The agent starts from the CURRENT revision (the editor handoff at first; later the agent's last render, an edit, or a + // revision the user restored). The server consumes this once per run, so it is sent with every message. + const cur = activeRevision.value + if (cur?.source?.trim() && cur.format) { + try { + const contextRes = await fetch(`${agentUrl}/projects/set-diagram-context`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + threadId: threadId.value, + source: cur.source, + format: cur.format, + detectedType: cur.detectedType || undefined, + output: cur.output || undefined, + imageData: cur.kind === 'root' ? props.editorHandoff?.imageData : undefined, + title: cur.title || cur.label, + }), + }) + if (!contextRes.ok) { + const errBody = await contextRes.text().catch(() => '') + console.warn('[storyb00k] Failed to set diagram context:', contextRes.status, errBody) + handoffError.value = `Could not give the agent the current diagram: HTTP ${contextRes.status}` + } else { + handoffError.value = '' } + } catch (err) { + console.error('[storyb00k] Error setting diagram context:', err) + handoffError.value = `Could not give the agent the current diagram: ${err.message}` } } - + input.value = '' console.info('[storyb00k] send →', text, '| thread:', threadId.value, '| agent:', agentUrl) try { @@ -544,11 +609,40 @@ function editPanelSource({ source, format }) { emit('edit-in-editor', { source, format }) } +// "Clear chat" empties the transcript the agent is sent. The project record on the server (goal, answers), the evidence panels +// and every revision are kept, so nothing that was made is lost. function clearThread() { console.info('[storyb00k] clearing thread', threadId.value) chat.clear() } +// "Hide panels" only empties the evidence column. Revisions (with their pictures) are untouched and still listed below. +function clearPanels() { + chat.agent.setState({ ...(chat.state.value || {}), panels: [] }) + chat.state.value = chat.agent.state + comparisonPanels.value = [] +} + +// What the agent receives on the next request, shown so there is no hidden state to guess at. +const showContext = ref(false) +const contextSummary = computed(() => ({ + thread: threadId.value, + messages: items.value.length, + diagram: activeRevision.value.source ? `${activeRevision.value.format}, ${activeRevision.value.source.split('\n').length} lines (revision "${activeRevision.value.label}")` : 'none', + answers: project.value?.qa?.length || 0, + lockedType: project.value?.lockedType || props.lockedType || '', + goal: project.value?.goal || '', +})) + +function downloadCurrent() { + const r = activeRevision.value + if (r.source) downloadText(sourceFilename(r.format, 'diagram'), r.source) +} +function downloadStart() { + const h = startingPoint.value + if (h) downloadText(sourceFilename(h.format, 'starting-point'), startDraft.value) +} + function reloadLast() { if (!busy.value) chat.reload() } @@ -625,9 +719,17 @@ function formatTokens(u) { {{ startingPoint.title }}{{ startingPoint.detectedType && startingPoint.detectedType !== 'unknown' ? startingPoint.detectedType + ' · ' : '' }}{{ startingPoint.format }}
- Diagram transferred from the Code Editor -

No rendered image was captured with this transfer.

-
{{ startingPoint.source }}
+
+ Diagram transferred from the Code Editor +

No picture yet: press Render.

+ +
+ + + + + {{ startRenderState }} +

Tell me what you want to show. I'll narrow the gallery and suggest the best-fit diagram type.

@@ -668,11 +770,19 @@ function formatTokens(u) { -
- {{ tool.name }} - running… - failed -
+
+ + {{ tool.name }} + running… + ✗ failed + ✓ ok + + +

Arguments

+
{{ tool.args }}
+

Result

+
{{ tool.output ? tool.output.slice(0, 4000) : '(none yet)' }}
+