The window could be started, but it could not be pointed anywhere: a provider,
a model and a key only came in on the command line. The gear in the header now
opens a dialog that changes all three on the engine's live config.
AgentCore (app/src/core/AgentCore.*):
- providers(): the engine's preset table (cst_provider_count/at), with base
URL, the remembered or preset model, whether a key exists and where it comes
from (cst_creds_resolve: "saved", or the environment variable), the preset's
key variable and the source ("built-in" / "user"). A key is read into a local
buffer only to answer "is there one"; it never leaves the C++ side.
- modelChoices(provider): the provider's own list (cst_provider_models), the
fetched cache (cst_ps_cached -- which writes through its out-parameters, so
it takes a real int, not NULL: passing one cost a segfault) and the
remembered pick. No network. modelInfo() is cst_model_info + its "200K ·
text only" label; usage() is cst_agent_usage for the session and the turn.
- useProvider(provider, model, key) -> "" or a reason: a different provider
goes through cst_ps_switch_provider() (the key is verified against /models
before it is saved; offline it is kept unverified and the header says so),
a key for the current one through cst_ps_set_key(), and the model is
remembered in settings.json. syncConfig() takes the engine's config back
over the app's copy, so provider/model/approval read correctly afterwards.
The engine's session is never touched by a switch.
- open() no longer starts with an empty provider: it takes the one the engine
used last (settings.json), else the built-in default, plus that provider's
base URL and model. Without this the first turn could not be sent at all and
no entry in the dialog was "in use".
QML (app/qml/SettingsDialog.qml, Main.qml):
- a modal dialog with the themed surface the other dialogs use (the Basic
style's light panel washes out the dark theme), the provider list with its
key state, a model field with the info label and clickable candidates, a
password field for a new key, the session's token counters, and a note that
reports what the last "Use this" answered.
Tests: 8 more checks -- the presets are readable, an unknown provider is
refused, the counters are readable, the gear opens the panel, the rows exist, a
model typed into the field reaches the engine, the note says so, Close puts the
dialog away. 36 checks pass. --uitest also points XDG_CONFIG_HOME at its
scratch directory now, so a run cannot rewrite the user's settings.json or
credentials, and CST_APP_SHOT writes shot.png at the end plus
shot-settings.png while the dialog is open (that is how the dialog above was
reviewed on a machine without a screen).
Verified: 36/36 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of the dialog.
Version 0.8.10.
212 lines
11 KiB
Markdown
212 lines
11 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`.
|
||
|
||
## 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.
|
||
|
||
## 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
|
||
- [ ] a real turn driven through the window (needs a provider key)
|
||
- [ ] attachments (drag in, paste)
|
||
- [ ] Kirigami (Breeze look), tray, global shortcuts
|
||
- [ ] Android APK (engine in-process, JNI platform tools)
|