refactor: archive GUI clients; keep minimal TUI with hooks/API
Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user