A tool that takes minutes -- a build, a wide search, a command nobody can
predict -- read as a window that had hung: the card said "running" and
nothing else, forever. The chip now counts from the point the card starts
running (from five seconds on, before that it is noise), which is where a
person looks when a call seems stuck; the status line and the Stop button
were already there. A card that appears already running (a replayed
transcript) starts its clock too.
--uitest: two checks for it (a card four minutes eighteen in says
"running · 4:18"; a finished one stops counting).
And the run now writes uitest.log in its workspace, every line and the
verdict. That is the only way to read a run *on a phone*: stdout reaches
nobody there, an app started from adb cannot be handed CST_APP_SHOT or any
other environment (so the shots a desktop run can take are out), and logcat
does not carry this build's Qt messages at all -- checked: a plain
`qInfo` from the app is not in the buffer.
First device run (NX789J, 105 checks): 88 ok, 17 FAIL, every failure
pre-existing and unrelated to this work -- the settings dialog does not
open from the gear (so nothing in it is reachable: the five dialog checks,
the two provider-form ones and the four clicks that follow), Open/Share
leave no status line on the device, and the clipboard-image checks have no
clipboard. All the md_to_pdf and card checks pass on the phone, including
"black ink on paper (darkest pixel 0)" from 4c69ef3.
468 lines
26 KiB
Markdown
468 lines
26 KiB
Markdown
# 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`.
|
||
|
||
## Install (Linux desktop)
|
||
|
||
The window installs itself: the binary, a launcher entry and the icon the
|
||
launcher draws for it. All three are one CMake component (`app`), so the CLI
|
||
keeps coming from `scripts/bootstrap_linux.sh` and the profile never holds two
|
||
`cst-agent` of different vintages; the files live in `app/linux/`.
|
||
|
||
```sh
|
||
# NixOS / nix users: PATH + menu entry + icon. The Qt apps hook bakes the
|
||
# plugin and QML module paths into the wrapper, so the app runs outside a
|
||
# nix-shell and nix-collect-garbage cannot pull Qt out from under it.
|
||
nix-build # ./result/bin/cst-agent-app, run it in place
|
||
nix profile add --file . # or: ./result/bin/cst-agent-app
|
||
nix profile remove cst-agent # uninstall
|
||
|
||
# anywhere with Qt 6 (Quick/Qml/QuickControls2) and libcurl:
|
||
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
|
||
cmake --build build-app -j
|
||
cmake --install build-app --component app --prefix ~/.local
|
||
```
|
||
|
||
Launched from a menu, the workspace is the directory the app starts in
|
||
(`$HOME` there), so sessions land in `~/.agent/sessions`; pass
|
||
`--workspace <dir>` to pin another one.
|
||
|
||
## 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 directory in the workspace's store
|
||
(`.agent/sessions/<id>/session.jsonl`, with its `title`, its `attachments/` and
|
||
its sub-agent transcripts beside it), 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. A store an older version wrote (flat `<id>.jsonl` files) still
|
||
reads; `cst-agent --migrate-store` moves it in.
|
||
|
||
- 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.
|
||
|
||
Every line the run prints also goes to `uitest.log` in the workspace, and the
|
||
verdict is the last line of it. That is how a run *on a phone* is read back:
|
||
stdout reaches nobody there, an app started from adb cannot be handed
|
||
environment variables, and logcat does not carry this build's Qt messages --
|
||
so `--uitest --workspace /sdcard/cst-uitest` plus
|
||
`adb pull /sdcard/cst-uitest/uitest.log` is the whole round trip.
|
||
|
||
## 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:
|
||
|
||
```json
|
||
{ "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 into the
|
||
session's own directory (`.agent/sessions/<id>/attachments/`), 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.
|
||
|
||
### Making one: `md_to_pdf`
|
||
|
||
The front end registers a tool of its own for this (`app/src/core/PdfTools.cpp`,
|
||
owner `app:qt`, `platforms = NULL` so every platform the window runs on gets
|
||
it): markdown, or a workspace `.md`/`.html` file, becomes an A4 PDF with 15 mm
|
||
margins in the workspace, and goes through the same
|
||
`cst_attach_import_artifact()` path as `deliver_file` -- so it arrives as a
|
||
file card the person can open, print or share, and its bytes never enter a
|
||
request. The markdown is rendered by the engine (`cst_web_md_document()`, the
|
||
same renderer the WebUI and `md_to_html` use) and Qt lays the HTML out
|
||
(`QTextDocument` + `QPdfWriter`, both QtGui); pagination is written here,
|
||
page rect by page rect, because `QPrinter`/PrintSupport is desktop-only and a
|
||
phone has no print subsystem. The tool runs on the turn's worker thread, and
|
||
`--uitest` runs it through the registry and checks the file starts with
|
||
`%PDF-`.
|
||
|
||
The card row on the phone is the same one a picture uses: the store keeps the
|
||
PDF next to the session, the image provider refuses it (it is not an image),
|
||
and the file card carries it.
|
||
|
||
## 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 (`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).
|
||
`CstService.java` (the foreground service that holds a phone awake while a
|
||
turn runs) went in the same way; it is started by `app::holdForeground()`
|
||
when a turn begins and stopped by `app::releaseForeground()` when it ends,
|
||
so nothing about it is visible when the app is idle.
|
||
|
||
## 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
|
||
- [x] images in the transcript: tool results and attachments are drawn as
|
||
thumbnails (`image://cstattach/<stored name>`, see the Images section)
|
||
- [x] Android APK: the engine is linked in (no port), builds, installs under
|
||
`xyz.whcself.cstagent` and runs on the device (see `docs/android.md`)
|
||
- [x] the saved provider key reaches the engine on every start (`AgentCore::open()`
|
||
resolves it: saved key, provider environment, `CST_API_KEY`)
|
||
- [x] 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)
|
||
- [x] Android platform tools (JNI: notification, clipboard, share sheet) and the
|
||
file card's open/share through the app's FileProvider
|
||
- [x] a turn keeps running with the app off screen (Android foreground service
|
||
+ wake lock; nothing to do on a desktop)
|
||
- [x] desktop install: launcher entry + icon, `nix profile add --file .`
|
||
- [ ] 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
|