v0.8.13: tool cards paired by call id, markdown rendered while it streams

Two things the window got wrong once a real turn ran through it, plus the
switch that was invisible on a dark theme.

Tool cards (app/src/core/AgentCore.*, app/qml/Main.qml, ToolCard.qml):
- A turn that called the same tool twice left the first card on "running"
  forever and gave its result to the second card: both were paired by name.
  cst_agent_hooks_t hands the call (ctl_tool_call_t) to every callback, so the
  id travels with the signals now (toolEvent(phase, id, name, detail),
  toolResult(id, name, exitCode, output)) and findTool() matches on it, in the
  same way AgentCore::history() fills it from tool_call_id / tool_calls_json
  for a replayed session. Only a call without an id -- an old text-protocol
  session -- still falls back to the name.
- Nothing may keep spinning after a turn: closeAllRows() runs when the turn
  ends and settles every open row; a call that never came back shows "no
  result" (state "unfinished") and no longer claims an exit code.

Streaming text (app/qml/Main.qml, ChatView.qml):
- The answer was plain text while it streamed and turned into markdown only
  when the turn ended. A 120 ms timer now re-renders the open answer with the
  engine's own renderer (AgentCore::renderMarkdown -> cst_web_md_html), so a
  list, a table or a code fence fills in formatted as it arrives. A row keeps
  its own "html" field; a replayed session renders once, where its rows are
  built.

QML details:
- the Plan mode Switch got a drawn indicator: the Basic style's pill is a dark
  pill with a dark knob on this theme, which made the control look disabled.
- Every engine signal handler is a plain window function now
  (engineToolEvent, engineToolResult, engineTurnFinished, engineAssistant,
  ...), which is also what lets --uitest drive the exact path; rowJson(i)
  returns a transcript row as JSON for it (an object would not survive
  QMetaObject::invokeMethod).

Tests: 6 more checks -- a call opens a card, two calls of one tool are two
cards, the result lands on the card of its own call, the other card is
untouched, the turn end settles an open call, a delta opens a streaming answer
and markdown is rendered while it still streams. 52/52 pass, --selftest,
ctest cst-app-selftest.

Version 0.8.13.
This commit is contained in:
WHC
2026-10-11 12:44:20 +08:00
parent cbd71b83c7
commit 8694afca94
9 changed files with 302 additions and 70 deletions
+6 -3
View File
@@ -40,9 +40,11 @@ Item {
role: model.role
text: model.text
complete: model.complete
html: (model.role === "agent" || model.role === "error") &&
(model.complete || model.text.length > 12000) &&
model.text.length > 0
/* html comes from the streaming renderer (Main.qml) or,
* for a replayed session, from here once. */
html: model.html.length > 0 ? model.html
: (model.role === "agent" || model.role === "error") &&
model.complete && model.text.length > 0
? agent.renderMarkdown(model.text) : ""
}
}
@@ -56,6 +58,7 @@ Item {
Component {
id: toolComp
ToolCard {
objectName: "toolCard"
name: model.name
state: model.state
detail: model.detail
+130 -53
View File
@@ -59,8 +59,9 @@ ApplicationWindow {
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
function addRow(kind, role, state) {
transcript.append({ kind: kind, role: role, text: "", complete: false,
name: "", state: state, detail: "", output: "", exitCode: 0 })
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
name: "", callId: "", state: state, detail: "", output: "",
exitCode: 0 })
}
// The engine streams one kind at a time: close whatever was open before.
@@ -77,33 +78,72 @@ ApplicationWindow {
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
}
// The last tool row with this name that is still open, -1 when none.
function findTool(name) {
// The open card of this call, -1 when none. The id is what tells two calls
// of the same tool apart (pairing by name left the first card running
// forever); a call without one -- an old text-protocol session -- falls
// back to the name.
function findTool(callId, name) {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "tool")
if (r.kind !== "tool" || r.complete)
continue
if (!r.complete && r.name === name)
if (callId.length) {
if (r.callId === callId)
return i
continue /* another call: its card is further back */
}
if (r.callId.length)
continue
if (r.name === name)
return i
if (r.complete)
return -1
return -1
}
return -1
}
function openTool(name, state) {
function openTool(callId, name, state) {
closeOpenRows("tool")
addRow("tool", "tool", state)
transcript.setProperty(lastIndex(), "name", name)
transcript.setProperty(lastIndex(), "callId", callId)
return lastIndex()
}
function submit(text) {
transcript.append({ kind: "message", role: "user", text: text, complete: true,
name: "", state: "", detail: "", output: "", exitCode: 0 })
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
name: "", callId: "", state: "", detail: "", output: "",
exitCode: 0 })
agent.send(text)
}
// A turn is over: nothing else is coming, so nothing may keep spinning.
function closeAllRows() {
for (let i = 0; i < transcript.count; i++) {
const r = transcript.get(i)
if (r.complete)
continue
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
r.state === "running"))
transcript.setProperty(i, "state", "unfinished")
transcript.setProperty(i, "complete", true)
}
}
// Markdown while it streams: the open answer is re-rendered with the
// engine's own renderer (the timer below), so the text fills in formatted
// instead of turning into markdown when the turn ends.
function renderStreaming() {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "message" || r.role !== "agent" || r.complete)
continue
const html = agent.renderMarkdown(r.text)
if (html !== r.html)
transcript.setProperty(i, "html", html)
return
}
}
// ---- sessions --------------------------------------------------------
function reloadSessions() { win.sessionsView = agent.sessions() }
@@ -453,51 +493,94 @@ ApplicationWindow {
}
// ---- engine -> UI ----------------------------------------------------
/* The signal handlers only forward: every one of them has a function
* here, so the same path can be driven from --uitest (which is how the
* two-calls-of-one-tool case stays fixed). */
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
function engineThink(text) { win.appendDelta("think", "agent", text) }
function engineToolEvent(phase, id, name, detail) {
win.closeOpenRows("tool")
let i = win.findTool(id, name)
if (phase === "open" || phase === "arg") {
if (i < 0)
i = win.openTool(id, name, "pending")
if (detail.length)
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
return
}
if (phase === "ready" || phase === "run") {
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
}
function engineToolResult(id, name, exitCode, output) {
let i = win.findTool(id, name)
if (i < 0) /* a result whose call is not in the view: its own card */
i = win.openTool(id, name, "done")
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "state", "done")
transcript.setProperty(i, "complete", true)
}
function engineTurnFinished(rc, err) {
win.renderStreaming(); /* one last render while the row is still open */
win.closeAllRows()
if (err.length)
transcript.append({ kind: "note", role: "error", text: err, html: "",
complete: true, name: "", callId: "", state: "error",
detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
/* For --uitest: one transcript row, or null. */
function rowAt(i) {
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
}
/* For --uitest: the same row as JSON (a QObject's return value does not
* survive QMetaObject::invokeMethod, a string does). */
function rowJson(i) {
return JSON.stringify(rowAt(i))
}
/* While a turn runs the open answer is re-rendered a few times a second:
* markdown text fills in formatted rather than at the end of the turn. */
Timer {
interval: 120
repeat: true
running: agent.running
onTriggered: win.renderStreaming()
}
Connections {
target: agent
function onSessionChanged() { win.reloadSessions(); win.replay() }
function onSessionListChanged() { win.reloadSessions() }
function onAssistantDelta(text) { win.appendDelta("message", "agent", text) }
function onThinkDelta(text) { win.appendDelta("think", "agent", text) }
function onAssistantDelta(text) { win.engineAssistant(text) }
function onThinkDelta(text) { win.engineThink(text) }
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, name, detail) {
win.closeOpenRows("tool")
let i = win.findTool(name)
if (phase === "open" || phase === "arg") {
if (i < 0)
i = win.openTool(name, "pending")
if (detail.length)
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
return
}
if (phase === "ready" || phase === "run") {
if (i < 0)
i = win.openTool(name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
function onToolEvent(phase, id, name, detail) {
win.engineToolEvent(phase, id, name, detail)
}
function onToolResult(name, exitCode, output) {
let i = win.findTool(name)
if (i < 0)
i = transcript.count - 1
if (i < 0 || transcript.get(i).kind !== "tool")
return
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "complete", true)
function onToolResult(id, name, exitCode, output) {
win.engineToolResult(id, name, exitCode, output)
}
function onPermissionRequested(tool, detail, preview) {
@@ -513,17 +596,11 @@ ApplicationWindow {
ask.open()
}
function onTurnFinished(rc, err) {
win.closeOpenRows("")
if (err.length)
transcript.append({ kind: "note", role: "error", text: err, complete: true,
name: "", state: "error", detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
function onRunningChanged() {
if (!agent.running)
win.closeOpenRows("")
win.closeAllRows()
}
}
}
+24
View File
@@ -273,6 +273,30 @@ Rectangle {
checked: side.planMode
implicitHeight: Theme.touchTarget
onClicked: side.planModeRequested(checked)
/* The Basic style's indicator is a dark pill with a dark
* knob here, so it is drawn: thumb-sized, accent when
* on, clearly off when not. */
indicator: Rectangle {
implicitWidth: 44
implicitHeight: 22
x: planSwitch.leftPadding
y: parent.height / 2 - height / 2
radius: 11
color: planSwitch.checked ? Theme.accentDim : Theme.surfaceAlt
border.width: 1
border.color: planSwitch.checked ? Theme.accent : Theme.border
Rectangle {
width: 16
height: 16
y: 2
x: planSwitch.checked ? parent.width - width - 3 : 3
radius: 8
color: planSwitch.checked ? Theme.text : Theme.textDim
Behavior on x {
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
}
}
}
}
Text {
text: "Plan mode"
+3 -1
View File
@@ -84,7 +84,9 @@ Rectangle {
Text {
id: chip
anchors.centerIn: parent
text: card.stateText + (card.hasResult ? " · exit " + card.exitCode : "")
/* "no result" is not an outcome: no exit code belongs on it. */
text: card.stateText + (card.hasResult && card.state !== "unfinished"
? " · exit " + card.exitCode : "")
color: card.stateColor
font.pixelSize: Theme.smallSize
}