Files
CST-agent/app/README.md
T
WHC d4f65b17cf v0.9.3: notification_send posts on the phone, and Java helpers have a home
The tool was the last Android one that never worked on the NX789J: it would
fail with "Notification.Builder.build() failed: no exception detail" and the
shade stayed empty. The detail was the whole story -- Qt clears a Java
exception before C++ gets to look at it, so a build() that throws and a
build() that returns null look exactly the same from here, and the only way
to catch the difference is to let Java catch it.

- app/android/src/xyz/whcself/cstagent/Notifier.java: the builder, the
  PendingIntent, build() and notify() in one try/catch that returns "" or
  the throwable's class and message. androiddeployqt copies app/android/ with
  the rest of the package source and Qt's Gradle template puts its src/ on
  java.srcDirs, so a class dropped there needs no build-script change (the
  first one is this; the foreground service of the next step goes there too).
- AndroidTools.cpp keeps what it can see from C++ (the context, the channel
  on API 26+, the small icon looked up by name, the launch intent) and calls
  Notifier.post(); the result line now carries the Java failure when there is
  one. The javaErrorText() helper of the previous attempt is gone: reading a
  pending exception from C++ is exactly what does not work here.
- On the device: the tool answers "notification sent", the system holds the
  record (channel cst-agent, importance 3, the framework small icon, the tap
  intent back into the app). Earlier attempts in the same session are in the
  transcript next to it: four failures from the old path, then this one.

Docs: docs/android.md gets the Java-helper mechanism (and why the tool has to
be wrapped in Java at all) and no longer lists notification_send as missing;
app/README.md's platform table and the Android note say where a Java class
goes.

Version 0.9.3.
2026-10-11 16:40:51 +08:00

22 KiB
Raw Blame History

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

# 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:

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. The window starts the engine without a key (AgentCore::open() is passed an empty one), so open() resolves it for the provider it starts on: saved key, then the provider's env variable, then CST_API_KEY — the same order the CLI uses in main.c. Without that the key sat in the file but never in the turn, and every start asked for it again.
  • "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.

Custom providers, model parameters

The dialog's Advanced options covers the two things the presets cannot: a provider of your own, and what the model should do.

  • A custom provider is an entry the engine keeps in <config>/cst-agent/models.json (layer 1 of the model registry, see include/cst_models.h) — the same file a hand-written override would go into, so the GUI and a text editor are interchangeable:

    { "providers": { "myllm": { "api_base": "https://api.example.com/v1",
                                "key_env": "MYLLM_API_KEY",
                                "default_model": "m1",
                                "models": ["m1", "m2"] } } }
    

    AgentCore::addProvider() / removeProvider() write it (cst_models_user_set_provider() / cst_models_user_drop_provider()) and reload the registry, so the provider is in the list, in models[] on the wire and in /provider of the CLI at once. A name a preset uses overrides that preset (a self-hosted endpoint, a proxy); the other content of the file (model rules) is kept, and a file that is not a JSON object is never overwritten. The key is still a key: it is verified against GET /models of the new base URL before it is saved, and stored per provider name as before.

  • The generation parameters — temperature, top-p, max tokens — are remembered per provider in settings.json ("params": {...}, cst_settings_remember_params()), and applied to the engine's config by AgentCore::setGenParams(), AgentCore::useProvider() and cst_agent_create() (so a CLI or TUI run sends what the window was set to). An empty field is not sent at all — that is not the same as sending 0, which is a real temperature; cst_llm_build_body() only adds a field that was set (tests/test_llm_body.c:test_gen_params).

  • A long model list (a router lists hundreds) is filtered and capped at 40 chips with "… +N more", and the whole dialog body scrolls: the buttons stay put and nothing can spill out of the frame.

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.

Images

What a session keeps as a picture is shown, not only stored: a file view_image returned, an image an MCP server sent, a screenshot the user attached or pasted. Each one appears as a thumbnail in the transcript -- a prompt's pictures under the message, a tool's under its card -- with the name, size and origin in the tooltip. The height is capped so a 4K screenshot cannot push the conversation off the screen, and a file the store no longer has says which image it was instead of leaving a hole.

The view never touches the filesystem on its own authority: the thumbnails come from image://cstattach/<stored name> (app/src/ui/SessionImages.cpp), where the name is the one the engine wrote next to the session and is checked with the engine's own cst_attach_rel_ok(), so only the session's attachment directory can be read. The picture is decoded at the size of its thumbnail rather than at its full resolution, and the provider's directory follows the session: AgentCore::attachmentDir() is re-read on every switch.

A document (PDF, UTF-8 text) still arrives as its name in the message text -- those are sent to the model as text, not as a picture.

Files the agent delivers

The opposite direction: the engine's deliver_file tool (registered in src/tools/registry.c, run in src/tools/fs_tools.c) copies a finished file out of the workspace into the session's own store, and the transcript shows it as a file card -- an extension badge, the file's name, its type and size, and two buttons.

  • The copy is marked for_user (cst_attachment_t.for_user, cst_attach_import_artifact()): any bytes up to CST_ARTIFACT_MAX_BYTES (64 MB), the name's extension decides the mime, and no request ever contains it -- src/llm_openai.c skips those entries when it builds the body, which is what keeps a delivered report from being pasted back into the context. tests/test_artifacts.c holds that down at the wire level.
  • A card never invents a path. The row carries the stored file's name, and AgentCore::openArtifact() / shareArtifact() accept it only when one of the session's own messages carries that name (artifactPathOf(), plus the engine's cst_attach_rel_ok()), so a forged name is refused with a reason.
  • Open hands the file to the system: Android builds a content:// URI through the app's FileProvider, checks resolveActivity(), starts ACTION_VIEW and grants the reader permission; a desktop calls QDesktopServices::openUrl. Share uses the phone's share sheet; a desktop has none, so the path goes to the clipboard.
  • CST_APP_NO_OPEN=1 makes both do nothing but report the line they would write. --uitest sets it, so a test run never opens anything for real: it replays a session with a delivered report.md, checks the card and its two buttons, clicks Open (the header must say opened report.md), clicks Share and checks a name the session does not own is refused.

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:

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 (scripts/build_apk_android.sh, -DCST_TUI=0, CST_NO_WEBUI=1); no port, QML talks to AgentCore in-process notification (Notifier.java: channel + permission probe), clipboard (QClipboard under the engine's own tool names), share sheet (ACTION_SEND + chooser), and the file card's open/share through the app's FileProvider (AndroidTools.cpp)
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). A Java class is added by dropping it in app/android/src/<package>/: androiddeployqt copies app/android/ with the rest of the package source and Qt's Gradle template puts its src/ on java.srcDirs, so no build-script change is needed (Notifier.java is the first one; a Java helper is the only way to catch a throwable where the platform throws instead of returning).

Status

  • engine as a library (libcstcore.a / cstcore), in-process embed API
  • tool metadata: perm_name, platforms, available, shadowing rule
  • AgentCore (turns, cancel, consent, todo) and the C smoke test
  • 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
  • session list in the sidebar: new / switch / delete, transcript replay
  • settings dialog: provider, model, key (cst_ps_*), token counters
  • attachments: picker, drop on the window, Ctrl+V image paste
  • icons from the desktop's icon theme (Breeze), with a glyph fallback
  • contrast pass: faint text and selected rows are readable
  • images in the transcript: tool results and attachments are drawn as thumbnails (image://cstattach/<stored name>, see the Images section)
  • Android APK: the engine is linked in (no port), builds, installs under xyz.whcself.cstagent and runs on the device (see docs/android.md)
  • the saved provider key reaches the engine on every start (AgentCore::open() resolves it: saved key, provider environment, CST_API_KEY)
  • delivered files: deliver_file stores the copy next to the session and the transcript draws a file card with Open and Share (see the Files section)
  • Android platform tools (JNI: notification, clipboard, share sheet) and the file card's open/share through the app's FileProvider
  • a real turn driven through the window (needs a provider key)
  • SAF file picker, the back gesture, the icon glyphs on the phone
  • Kirigami (the rest of the Breeze look: colors, controls), tray, global shortcuts