Files
CST-agent/app/README.md
T
WHC 3fd039cb51 v0.8.14: icons from the desktop's theme (Breeze), readable text, composer frame
The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.

Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
  image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
  desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
  hicolor), tinted with QPainter's SourceIn and rasterised at the requested
  size and the screen's device pixel ratio. Qt only builds its theme search
  paths from XDG once a theme name is set, so the provider fills them itself
  (GenericDataLocation/icons, ~/.icons and, on NixOS,
  /run/current-system/sw/share/icons, for a shell started outside the desktop
  session). An unknown name yields a null image, which QML reports as
  Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
  still names the old character as `glyph:`, so a host with no icon theme
  degrades to exactly what the window looked like before instead of going
  blank. installThemeIcons() is called by main() and by --uitest, so the test
  loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
  (mail-attachment), copy (edit-copy), session delete (edit-delete), new
  session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
  (go-down), the plan checklist (task-complete/box). A tool card also shows
  what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
  for read, folder for list/glob, magnifier for grep, braces for a shell,
  globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
  size), card and row icons 14-16 px.

Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
  hints, provider URLs, session metadata and placeholders, so it is text, not
  decoration.
- A selected provider row and the open session row write on the accent fill;
  they now use accentText / accentTextDim / okOnAccent instead of faint grey
  and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
  property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.

Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
  implicitHeight + 2*pad). With the top margin alone the last row -- the clip
  and Send -- hung over the lower border, which is what put the clip on the
  frame instead of inside it.

app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).

Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).

Version 0.8.14.

mode = coding
2026-10-11 12:53:49 +08:00

299 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app/ — the Qt front end
CST-agent's GUI: **Qt 6 + QML + the C engine, in one process**. No HTTP
server, no port, no token, no second process — the app creates a `cst_agent_t`
directly and drives it through `cst_agent.h`.
```
app/ the GUI
src/main.cpp entry point, --workspace / --selftest
src/core/AgentCore.* QObject around cst_agent_t: turns, hooks, consent
src/core/ToolProvider.h how the front end hands tools to the engine
src/platform/<os>/ platform tools the app implements itself
qml/Main.qml the window (chat, tool runs, consent dialog)
tools/selftest.c C smoke test of the embedding path (no Qt)
```
## Build
```sh
# engine + GUI + C smoke test
cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug
cmake --build build-app -j
QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest
# the app alone
./build-app/cst-agent-app --workspace ~/notes
./build-app/cst-agent-app --size 480x900 # the narrow (drawer) layout
./build-app/cst-agent-app --uitest # drive the window, exit code = failures
./build-app/cst-agent-app --selftest # same checks as the C test
```
The standalone build is the supported one: `app/CMakeLists.txt` uses the
`cstcore` target when the parent project defines one, and otherwise builds
the engine itself from `../src` (TUI, `main.c` and the HTTP front end
excluded). Wiring it into the repository's `CMakeLists.txt` as
`-DCST_APP=ON` is on the list below.
Needs Qt 6.4+ with **Quick / Qml / QuickControls2** (`qt6.qtdeclarative`,
`qt6.qtquickcontrols2` on Nix; the online installer's "Qt 6.x Desktop" on
Windows/macOS). Without them the configure step warns and builds only the C
smoke test — that is deliberate: the embedding path stays verifiable
everywhere. Kirigami is not required yet; `qml/Main.qml` uses Qt Quick
Controls and keeps the layout in Kirigami's shape (single column, cards,
navigation drawer) so swapping in `org.kde.kirigami` later is a QML-only
change. Point the app at a Kirigami outside the Qt prefix with
`-DCST_APP_QML_IMPORT=/path/to/qml`.
## Why in one process
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
`cstcore` target (CMake). The app owns one `cst_agent_t` per window, so
there is no protocol to version, no socket to authenticate and no second
process to babysit — and the CLI, TUI and WebUI keep working off the same
source.
- `AgentCore::send()` runs `cst_agent_run_turn_ex()` on a worker thread;
the `cst_agent_hooks_t` callbacks turn into Qt signals (assistant deltas,
tool events, tool results, todo, turn end).
- Consent is a blocking hook: the worker waits on a `QWaitCondition`, the
GUI answers, the turn goes on. `CST_PERM_DENY / ONCE / ALWAYS_TOOL /
ALWAYS_PREFIX` map 1:1 onto the buttons, and `ALWAYS_*` still lands in
`.agent/permissions.json`, so a desktop decision is visible to the TUI.
- `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal
result, not an exception.
## Layout and input
One shell, three sizes (everything is styled from `qml/Theme.qml`):
- **Desktop**: the sidebar is docked and its divider can be dragged (double
click restores the auto width). Hover states, tooltips, selectable text and
a wheel-scrolled transcript.
- **Narrow window** (below `Theme.compactBreakpoint`, 880 px): the sidebar
folds away, the header grows a `☰` button, and the settings slide in over
the transcript with a scrim; the sidebar itself scrolls, because a phone
window cannot show it all.
- **Touch**: controls keep a `Theme.touchTarget` (34 px) hit area, buttons do
not depend on hover, the composer asks the platform keyboard not to
autocorrect (`ImhNoPredictiveText`), and Enter/Shift+Enter still behave.
`--uitest` checks this without a human: it loads the window, clicks with
synthesized **mouse** events, taps with a synthesized **touch** device, types
into the composer, drives the session list, then narrows the window to 460 px
to assert the dock → drawer switch and opens/closes the drawer. One line per
check, non-zero exit on failure:
```sh
QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
# ok wide window: sidebar docked
# ok mouse click on "general" reaches the engine
# ok touch tap on "coding" reaches the engine
# ...
```
## Sessions
A GUI session is a file in the workspace's store (`.agent/sessions/<id>.jsonl`),
the same one the CLI, the TUI and the WebUI use — `AgentCore::open()` resolves
the workspace's current session before the engine starts, so nothing is written
next to the process's working directory.
- The sidebar lists the store (newest first): title or first prompt, message
count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×`
deletes one (the engine first moves off a session it has open, so it never
sits on a file that is gone), and a click switches.
- Switching **replays** the transcript: `AgentCore::history()` walks the stored
messages and returns the same rows the live signals build — messages,
thoughts, notes, and one card per tool call, with its result paired by name
(a call with no result keeps state `unfinished`, so no spinner runs
forever). A reopened session therefore renders like the turn that wrote it.
- Session controls are disabled while a turn runs: switching would move the
engine out from under it.
`--uitest` covers the flow end to end: `writeFixtureSession()` writes a small
session through the store API (a tool call with its result), the checks click
its row, assert the five replayed rows and the paired result, start two new
sessions (the view empties), delete one by its `×` (the open one stays), and
switch back.
`--uitest` uses a scratch workspace unless `--workspace` is given, since it
creates and deletes sessions. On a machine without a screen,
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
## Provider, model, key
The sidebar shows where the engine points (`provider · model`); the gear in the
header opens the dialog that changes it.
- The provider list is the engine's own preset table (`cst_provider_count()`),
with each entry's base URL and where its key would come from: "key saved"
(`.agent`-independent, `<config>/cst-agent/credentials`), "key from
OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side —
the dialog only ever describes it.
- "Use this" calls `AgentCore::useProvider()`, which runs
`cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config:
a pasted key is verified against the provider's `/models` before it is saved
(offline it is kept unverified and the header says so), the model is
remembered in `settings.json`, and the **session is not touched**. Model
candidates come from the provider entry, the fetched cache
(`cst_ps_cached`, no network) and the remembered pick; the label next to the
field is `cst_model_info` ("200K · text only").
- The dialog also reports the session's token use (`cst_agent_usage`).
`--uitest` checks it end to end: the presets are readable, an unknown provider
is refused, the gear opens the panel, the provider rows exist, a model typed
into the field reaches the engine when "Use this" is clicked, the note says so,
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
directory, so settings and keys of the real user are never rewritten.
## Attachments
The next prompt can carry files, and they arrive three ways:
- **📎 in the composer** opens a file picker (`QtQuick.Dialogs`).
- **Dropping files on the window** — a `DropArea` covers it; while something is
dragged over, the border turns accent and says "drop to attach".
- **Ctrl+V** with an image on the clipboard. Text is left to the editor's own
paste: the composer asks `AgentCore::clipboardHasImage()` first and only
intercepts the keystroke when there is an image.
`AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` /
`cst_agent_attach_bytes()`: the engine validates the file, copies it next to
the session (`.agent/sessions/`), and the next `run_turn` carries it — the
window only shows what the engine's `pending` list says, one chip per file in
the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all"
(`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away
with the turn. When the model cannot be shown images
(`cst_agent_sees_images() == 0`), the composer says so and the engine sends
them as text notes instead.
`--uitest` writes a file into its scratch workspace and checks the whole path:
`attachFile` succeeds, the engine reports one pending attachment, a missing file
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`).
## Icons, colors
- Icons come from the desktop's **icon theme** (Breeze on KDE), through
`image://csttheme/<name>?color=%23rrggbb` (`app/src/ui/ThemeIcons.{h,cpp}`).
Qt Quick has no icon provider of its own without Kirigami, so the window used
unicode glyphs before (a clip emoji, a gear, a hamburger): those render in
whichever font covers them — colour emoji in a grey toolbar — and are a box
on a machine without that glyph. `app/qml/ThemeIcon.qml` is the one place
that asks for an icon, and every use still names the old character as
`glyph:`: a host with no icon theme degrades to what it looked like before
rather than going blank.
- The provider fills Qt's theme search paths itself (XDG data dirs, `~/.icons`
and, on NixOS, `/run/current-system/sw/share/icons`): Qt only builds them
from XDG once a theme name is set. It then tries the theme the desktop chose,
else `breeze-dark` (this window is dark), `breeze`, `hicolor`. Rasterising
Breeze needs the Qt SVG image format plugin (`qt6.qtsvg` on Nix, part of a
desktop Qt install) — without it the icons fall back to the glyphs instead of
disappearing.
- `Theme.toolIcon(name)` maps a tool onto its icon: patch/write → pencil,
read → text page, list/glob → folder, grep → magnifier, shell → braces,
web → globe, plan → list, ask → question mark. Toolbar icons are 22 px
(Breeze's toolbar size), card and row icons 14–16 px.
- `textFaint` is readable grey, not decoration (~5:1 on the card fill): it
carries hints, secondary lines and provider URLs. Anything written *on* the
accent fill (a selected provider row, the open session) uses `accentText`,
`accentTextDim` and `okOnAccent` — green or faint grey on `accentDim` is
unreadable. The names are not `onAccent…`: QML reads every `onFoo` property
as a signal handler and refuses the assignment.
- The composer's frame is one `Theme.pad` around its column on **all** sides;
with the top margin alone the clip and Send hung over the lower border.
## Tools the app provides
A tool implemented in the front end is registered with `cst_tool_register()`
and the owner `app:<name>`, exactly like a plugin or MCP server would:
```c
cst_tool_def_t def = {0};
def.name = "notification_send";
def.desc = "Send a system notification.";
def.perm = CST_TP_ASK; /* class: drives the consent dialog */
def.perm_name = "notify"; /* policy: what the user is really granting */
def.platforms = ANDROID_ONLY; /* NULL = everywhere */
def.available = probe; /* 0 / -1 with a reason; cached */
def.run_ex = my_run;
def.ud = my_state;
def.owner = "app:android";
cst_tool_register(&def);
```
- `platforms` + `available` decide *whether* the tool exists here: an
Android-only tool never reaches the prompt or `tools[]` on Linux, and
`cst_tool_run()` refuses it with the reason if a model asks anyway.
- A front-end tool may take over the **name** of a built-in that cannot run
on this host (the Android clipboard replacing the desktop `xclip` one);
it may not shadow one that works.
- `cst_tool_unregister_owner("app:android")` drops them again — do it when a
platform service goes away, then `cst_tool_meta_reload()`.
- `setenv("CST_PLATFORM", ...)` overrides the detected host name (tests, a
launcher that knows better).
Note `run_ex` runs on the worker thread while the turn is going: touch the
GUI only through queued signals.
## Platform notes
| Platform | How the engine gets built | Platform tools |
|---|---|---|
| Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) |
| Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=<Qt>/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path |
| Android | engine compiled with the NDK inside the APK (`-DCST_TUI=0`, `CST_NO_WEBUI=1`); the app talks to Android APIs through Qt (JNI) | notification (Android), share sheet, clipboard, file picker |
| macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) |
Android's long-term shape: the core lives in the APK (no Termux needed), the
platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract.
The build flag that keeps Unix helpers out of the picture is
`CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine).
## Status
- [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API
- [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule
- [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test
- [x] QML shell: transcript cards, tool cards with diff coloring, thinking
blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
`--uitest` for mouse, touch and small-screen checks
- [x] session list in the sidebar: new / switch / delete, transcript replay
- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters
- [x] attachments: picker, drop on the window, Ctrl+V image paste
- [x] icons from the desktop's icon theme (Breeze), with a glyph fallback
- [x] contrast pass: faint text and selected rows are readable
- [ ] a real turn driven through the window (needs a provider key)
- [ ] tool results that carry images (they are stored, not drawn yet)
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
global shortcuts
- [ ] Android APK (engine in-process, JNI platform tools)