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:
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user