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
+25
View File
@@ -175,6 +175,31 @@ is refused with a reason, the composer shows one chip, "clear all" empties the
list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and
`attachClipboard()` behave.
## Transcript, tool calls, streaming text
- A tool call is a card of its own, and the call **id** is what pairs it with
its result: the same tool may run twice in a turn, and pairing by name (what
the window did before) hung the first card on "running" forever while the
second one took its result. `AgentCore::toolEvent()` and `toolResult()` carry
the id (`ctl_tool_call_t.id`), `AgentCore::history()` fills it from the
session (`tool_call_id`, `tool_calls_json`), and only an id-less call from an
old text-protocol session still falls back to the name.
- Nothing may keep spinning after a turn: `closeAllRows()` settles every open
row when the turn ends, and a call with no result becomes "no result"
(`unfinished`) instead of a spinner that runs forever.
- The answer is rendered **while it streams**: a 120 ms timer re-renders the
open answer with the engine's own markdown renderer (`cst_web_md_html`, via
`AgentCore::renderMarkdown()`), so a list or a code fence fills in formatted
instead of turning into markdown when the turn is over. A replayed session
renders once, when its rows are built.
- Every signal handler is a plain function on the window
(`engineToolEvent`, `engineToolResult`, `engineTurnFinished`,
`engineAssistant`, ...) so `--uitest` can drive the same path: it opens two
calls of one tool, delivers one result, and asserts that the result landed on
its own card and that the turn end settled the other one. `rowJson(i)` exists
for that (a row as JSON — an object would not survive
`QMetaObject::invokeMethod`).
## Tools the app provides
A tool implemented in the front end is registered with `cst_tool_register()`